mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-21 23:47:56 +00:00
Fix all shadow deprecation warnings across entire mobile app
- Replaced shadowColor/shadowOffset/shadowOpacity/shadowRadius with boxShadow - Fixed 28 files (21 screens + 7 components) - Preserved elevation for Android compatibility - All React Native Web deprecation warnings resolved Files fixed: - All screen components - All reusable components - Navigation components - Modal components
This commit is contained in:
+40
-3
@@ -1956,10 +1956,47 @@ supabase db reset
|
||||
|
||||
---
|
||||
|
||||
1. Navigasyon Yapısı (Kesinleşen)
|
||||
Alt Menü (Bottom Bar - 5'li):
|
||||
1. 🏠 Home: Ana Dashboard (Gruplandırılmış ikonlar burada).
|
||||
2. 📱 Apps: Tüm mini-app'lerin alfabetik veya kategorik tam listesi (Hızlı erişim çekmecesi).
|
||||
3. 🏛️ Citizen (Ortada, Büyük): "Dijital Kimlik Kartı". Tıklayınca eğer vatandaş değilse başvuruya,
|
||||
vatandaşsa dijital kimlik kartına (QR kodlu) döner.
|
||||
4. 🤝 Referral: Büyüme motoru.
|
||||
5. 👤 Profile: Kişisel alan.
|
||||
|
||||
Üst Header:
|
||||
* Sol: Avatar (Tıklayınca hızlı durum değişimi: Online/Busy).
|
||||
* Sağ: 🔔 Bildirimler, ⚙️ Ayarlar.
|
||||
* Ekstra : Avatarın yanına pezpallet tiki den canli veri ceken ve kullanicinin sahip oldugu tikileri gosteren alan.
|
||||
|
||||
**Last Updated:** 2025-11-17
|
||||
2. Body (Gövde) Gruplandırması ve Yeni Fikirler
|
||||
Sizin dikdörtgen alanlarınızı zenginleştirelim:
|
||||
|
||||
**Maintained By:** PezkuwiChain Development Team
|
||||
💰 FINANCE (Finansal Alan)
|
||||
Mevcut: Wallet, Bank, Exchange, P2P, B2B.
|
||||
* Benim Eklemelerim:
|
||||
* 📊 Tax (Vergi/Zekat): Gönüllü katkı veya sistem vergilerini şeffafça ödeme/izleme.
|
||||
* 🌱 Launchpad: Yeni Kürt girişimlerine/tokenlarına erken yatırım yapma alanı.
|
||||
* 💳 Cards: Sanal veya fiziksel Pezkuwi Kart yönetimi (Gelecek vizyonu).
|
||||
|
||||
**Production Status:** 95% Complete - Beta Testnet Active
|
||||
🏛️ GOVERNANCE (Yönetim Alanı)
|
||||
Mevcut: President, Assembly, Government, Validators, Nominators, Vote.
|
||||
* Benim Eklemelerim:
|
||||
* ⚖️ Justice (Dad): Anlaşmazlık çözüm merkezi (AI Lawyer entegreli).
|
||||
* 📜 Proposals: Halkın doğrudan yasa teklifi verebileceği alan.
|
||||
* 🗳️ Polls: Hızlı kamuoyu yoklamaları (Anketler).
|
||||
|
||||
💬 SOCIAL (Sosyal Alan)
|
||||
Mevcut: whatsKURD, VPN, Forum, KurdMedia.
|
||||
* Benim Eklemelerim:
|
||||
* 🎭 Events (Çalakî): Kürt dünyasındaki konser, miting, konferans takvimi ve biletleme.
|
||||
* 🤝 Help (Harîkarî): Deprem, sel gibi durumlarda acil yardımlaşma ve bağış ağı.
|
||||
* 🎵 Music: Kürtçe müzik streaming servisi entegrasyonu (Spotify gibi ama yerel).
|
||||
|
||||
📚 EDUCATION (Eğitim Alanı)
|
||||
Mevcut: University, College, Kids, Programs.
|
||||
* Benim Eklemelerim:
|
||||
* 📜 Library (Pirtûkxane): Dijital Kürtçe kütüphane ve arşiv.
|
||||
* 🗣️ Language (Ziman): Kürtçe (Kurmancî/Sorani/Zazaki) öğrenme modülü (Duolingo tarzı).
|
||||
* 🏆 Certificates: Blokzincir tabanlı diplomalar ve sertifikalar cüzdanı.
|
||||
@@ -1,991 +0,0 @@
|
||||
# CLAUDE.md - AI Assistant Guide for PezkuwiChain Web App Projects
|
||||
|
||||
**Last Updated:** 2025-11-17
|
||||
**Production Status:** ~95% Complete
|
||||
**Active Network:** Beta Testnet (`wss://rpc.pezkuwichain.io:9944`)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Start for AI Assistants
|
||||
|
||||
This is a **production-grade blockchain monorepo** for PezkuwiChain with live validators running on VPS. Exercise extreme caution when making changes that could affect blockchain operations.
|
||||
|
||||
### Critical Rules (READ FIRST!)
|
||||
|
||||
⚠️ **NEVER DO THESE WITHOUT EXPLICIT USER PERMISSION:**
|
||||
1. **DO NOT** restart or stop VPS validators (7 validators currently finalizing blocks)
|
||||
2. **DO NOT** modify chain specs (`/root/pezkuwi-sdk/chain-specs/beta/beta-testnet-raw.json`)
|
||||
3. **DO NOT** change blockchain base paths or validator configurations
|
||||
4. **DO NOT** commit `.env` files or secrets to git
|
||||
5. **DO NOT** deploy to production without testing locally first
|
||||
6. **DO NOT** make assumptions about blockchain operations - **ALWAYS ASK**
|
||||
|
||||
### User Preferences (MUST FOLLOW!)
|
||||
|
||||
1. **Screenshot Location:** When user says "ekrana bak" (look at screen), read `/home/mamostehp/pwap/screenshot.png`
|
||||
2. **Git Commits:** NEVER add Claude signature/attribution to commit messages (no "🤖 Generated with Claude Code" or "Co-Authored-By: Claude")
|
||||
3. **Deploy Path:** Web app deploys to `root@37.60.230.9:/var/www/pezkuwichain/web/dist/` - Nginx config points here
|
||||
4. **Documentation:** ALL docs go in `/home/mamostehp/pwap/docs/` folder (subfolders: p2p/, commission/, reports/, testing/, presale/) - NEVER put .md files in project root except README.md and CLAUDE.md
|
||||
5. **Production Wallet:** P2P Escrow wallet is `5DFwqK698vL4gXHEcanaewnAqhxJ2rjhAogpSTHw3iwGDwd3` - NEVER use dev/test addresses like Alice
|
||||
|
||||
### VPS Infrastructure
|
||||
|
||||
- **IP:** 37.60.230.9
|
||||
- **Validators:** 7 running (ports 30333-30339, RPC 9944-9950)
|
||||
- **Frontend:** Nginx serving at `/var/www/pezkuwichain/web/dist/`
|
||||
- **Blockchain:** LIVE on Beta Testnet - handle with care
|
||||
|
||||
---
|
||||
|
||||
## 📁 Repository Structure
|
||||
|
||||
```
|
||||
pezkuwi-web-app-projects/
|
||||
├── web/ # Main React web app (Vite + TypeScript) - 90% complete
|
||||
├── mobile/ # React Native Expo app - 50% complete
|
||||
├── pezkuwi-sdk-ui/ # Polkadot.js SDK UI (branded clone) - 47MB
|
||||
├── shared/ # Shared code library (types, utils, blockchain, i18n)
|
||||
├── README.md # Project overview
|
||||
├── PRODUCTION_READINESS.md # Production status report
|
||||
└── CLAUDE_README_KRITIK.md # CRITICAL operational guidelines (Turkish)
|
||||
```
|
||||
|
||||
### Directory Breakdown
|
||||
|
||||
| Directory | Size | Status | Purpose |
|
||||
|-----------|------|--------|---------|
|
||||
| `web/` | 3.8MB | 90% | Main production web application |
|
||||
| `mobile/` | 737KB | 50% | iOS/Android mobile app |
|
||||
| `pezkuwi-sdk-ui/` | 47MB | Active | Polkadot.js Apps clone |
|
||||
| `shared/` | 402KB | 100% | Shared libraries & utilities |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
### Web Application (`/web/`)
|
||||
|
||||
| Category | Technology | Version | Purpose |
|
||||
|----------|-----------|---------|---------|
|
||||
| **Framework** | React | 18.3.1 | UI framework |
|
||||
| **Language** | TypeScript | 5.5.3 | Type safety |
|
||||
| **Build Tool** | Vite | 5.4.1 | Fast bundler with HMR |
|
||||
| **Blockchain** | Polkadot.js API | 16.4.9 | Blockchain integration |
|
||||
| **Backend** | Supabase | 2.49.4 | Auth & Database |
|
||||
| **UI Library** | shadcn/ui | Latest | Radix UI components |
|
||||
| **Styling** | Tailwind CSS | 3.4.11 | Utility-first CSS |
|
||||
| **State** | React Context | - | Global state management |
|
||||
| **Data Fetching** | TanStack Query | 5.56.2 | Server state caching |
|
||||
| **Routing** | React Router | 6.26.2 | Client-side routing |
|
||||
| **i18n** | i18next | 23.7.6 | 6-language support |
|
||||
| **Forms** | React Hook Form | 7.53.0 | Form management |
|
||||
| **Validation** | Zod | 3.23.8 | Schema validation |
|
||||
| **Charts** | Recharts | 2.12.7 | Data visualization |
|
||||
| **Icons** | Lucide React | 0.462.0 | Icon library |
|
||||
| **Notifications** | Sonner | 1.5.0 | Toast notifications |
|
||||
|
||||
### Mobile Application (`/mobile/`)
|
||||
|
||||
| Category | Technology | Version | Purpose |
|
||||
|----------|-----------|---------|---------|
|
||||
| **Framework** | React Native | 0.81.5 | Mobile framework |
|
||||
| **Runtime** | Expo | 54.0.23 | Development platform |
|
||||
| **Navigation** | React Navigation | 7.x | Native navigation |
|
||||
| **Blockchain** | Polkadot.js API | 16.5.2 | Blockchain integration |
|
||||
| **Storage** | AsyncStorage | 2.2.0 | Persistent storage |
|
||||
| **Security** | Expo SecureStore | 15.0.7 | Encrypted storage |
|
||||
| **Biometrics** | expo-local-authentication | 17.0.7 | Fingerprint/FaceID |
|
||||
| **i18n** | i18next | 25.6.2 | Multi-language |
|
||||
|
||||
### Shared Library (`/shared/`)
|
||||
|
||||
- **Language:** TypeScript (100% typed)
|
||||
- **Runtime:** Platform-agnostic (Node.js + Browser + React Native)
|
||||
- **Dependencies:** Minimal (Polkadot.js only)
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Key Files & Entry Points
|
||||
|
||||
### Web Application
|
||||
|
||||
**Entry Points:**
|
||||
- `web/src/main.tsx` - React root render
|
||||
- `web/src/App.tsx` - Provider hierarchy & routing
|
||||
- `web/index.html` - HTML template
|
||||
|
||||
**Configuration:**
|
||||
- `web/vite.config.ts` - Vite bundler config with path aliases
|
||||
- `web/tailwind.config.ts` - Tailwind with Kurdistan color theme
|
||||
- `web/tsconfig.json` - TypeScript strict mode + path mappings
|
||||
- `web/postcss.config.js` - PostCSS for Tailwind
|
||||
|
||||
**State Management (6 Contexts):**
|
||||
- `contexts/PolkadotContext.tsx` - Blockchain API connection
|
||||
- `contexts/WalletContext.tsx` - Wallet state & multi-token balances
|
||||
- `contexts/AuthContext.tsx` - Supabase authentication
|
||||
- `contexts/AppContext.tsx` - Global application state
|
||||
- `contexts/WebSocketContext.tsx` - Real-time blockchain updates
|
||||
- `contexts/IdentityContext.tsx` - User identity & KYC status
|
||||
|
||||
**Backend:**
|
||||
- `src/lib/supabase.ts` - Supabase client initialization
|
||||
- `supabase/migrations/*.sql` - Database schema migrations (9 files)
|
||||
|
||||
### Mobile Application
|
||||
|
||||
**Entry Points:**
|
||||
- `mobile/index.ts` - Expo registerRootComponent
|
||||
- `mobile/App.tsx` - Root with i18n initialization
|
||||
- `mobile/src/navigation/AppNavigator.tsx` - Navigation setup
|
||||
|
||||
### Shared Library
|
||||
|
||||
**Core Files:**
|
||||
- `shared/blockchain/endpoints.ts` - Network endpoint configurations
|
||||
- `shared/blockchain/polkadot.ts` - Polkadot.js utilities
|
||||
- `shared/constants/index.ts` - KNOWN_TOKENS, KURDISTAN_COLORS, LANGUAGES
|
||||
- `shared/i18n/index.ts` - i18n configuration
|
||||
- `shared/types/blockchain.ts` - Blockchain type definitions
|
||||
- `shared/lib/wallet.ts` - Wallet utilities & formatters
|
||||
|
||||
**Business Logic Libraries:**
|
||||
- `shared/lib/citizenship-workflow.ts` - KYC & citizenship workflow
|
||||
- `shared/lib/tiki.ts` - 70+ government roles (Hemwelatî, Parlementer, etc.)
|
||||
- `shared/lib/perwerde.ts` - Education platform logic
|
||||
- `shared/lib/p2p-fiat.ts` - P2P fiat trading system (production-ready)
|
||||
- `shared/lib/staking.ts` - Staking operations
|
||||
- `shared/lib/multisig.ts` - Multisig treasury operations
|
||||
- `shared/lib/validator-pool.ts` - Validator pool management
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Development Workflows
|
||||
|
||||
### Web Development
|
||||
|
||||
```bash
|
||||
# Navigate to web directory
|
||||
cd web
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server (localhost:8081)
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Preview production build
|
||||
npm run preview
|
||||
|
||||
# Lint code
|
||||
npm run lint
|
||||
```
|
||||
|
||||
**Environment Setup:**
|
||||
1. Copy `.env.example` to `.env`
|
||||
2. Set `VITE_NETWORK=local` (or testnet/beta/mainnet)
|
||||
3. Configure Supabase credentials:
|
||||
- `VITE_SUPABASE_URL`
|
||||
- `VITE_SUPABASE_ANON_KEY`
|
||||
4. Set blockchain endpoint (optional, defaults to beta)
|
||||
|
||||
### Mobile Development
|
||||
|
||||
```bash
|
||||
# Navigate to mobile directory
|
||||
cd mobile
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start Expo development server
|
||||
npm start
|
||||
|
||||
# Run on Android emulator
|
||||
npm run android
|
||||
|
||||
# Run on iOS simulator
|
||||
npm run ios
|
||||
|
||||
# Run in web browser
|
||||
npm run web
|
||||
```
|
||||
|
||||
### Deploying to Production (Web)
|
||||
|
||||
```bash
|
||||
# 1. Build locally
|
||||
cd /home/mamostehp/pwap/web
|
||||
npm run build
|
||||
|
||||
# 2. Deploy to VPS
|
||||
rsync -avz dist/ pezkuwi-vps:/var/www/pezkuwichain/web/dist/
|
||||
|
||||
# 3. Reload Nginx (no restart needed)
|
||||
ssh pezkuwi-vps "systemctl reload nginx"
|
||||
```
|
||||
|
||||
**Important:** Always test locally with `npm run build && npm run preview` before deploying to VPS.
|
||||
|
||||
---
|
||||
|
||||
## 📂 Code Organization Patterns
|
||||
|
||||
### Component Structure
|
||||
|
||||
**Web Components:**
|
||||
```
|
||||
web/src/components/
|
||||
├── ui/ # shadcn/ui primitives (50+ components)
|
||||
│ ├── button.tsx
|
||||
│ ├── card.tsx
|
||||
│ ├── dialog.tsx
|
||||
│ └── ...
|
||||
├── auth/ # Authentication components
|
||||
├── citizenship/ # Citizenship/KYC UI
|
||||
├── dex/ # DEX/Swap interface
|
||||
├── delegation/ # Delegation management
|
||||
├── forum/ # Forum components
|
||||
├── governance/ # Governance interface
|
||||
├── p2p/ # P2P fiat trading
|
||||
├── perwerde/ # Education platform
|
||||
├── staking/ # Staking dashboard
|
||||
└── wallet/ # Wallet components
|
||||
```
|
||||
|
||||
**Pattern:** Feature-based organization with co-located types and utilities.
|
||||
|
||||
### File Naming Conventions
|
||||
|
||||
- **Components:** PascalCase (`StakingDashboard.tsx`)
|
||||
- **Utilities:** camelCase (`wallet.ts`, `formatting.ts`)
|
||||
- **Types:** PascalCase interfaces/types (`WalletAccount`, `TokenInfo`)
|
||||
- **Constants:** UPPER_SNAKE_CASE exports (`ASSET_IDS`, `KURDISTAN_COLORS`)
|
||||
|
||||
### Import Patterns
|
||||
|
||||
**Path Aliases (Web):**
|
||||
```typescript
|
||||
// Local imports
|
||||
import { Component } from '@/components/ui/component';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
|
||||
// Shared library imports
|
||||
import { formatBalance } from '@pezkuwi/lib/wallet';
|
||||
import { WalletAccount } from '@pezkuwi/types';
|
||||
import { KURDISTAN_COLORS } from '@pezkuwi/constants';
|
||||
import { translations } from '@pezkuwi/i18n';
|
||||
```
|
||||
|
||||
**Import Order (Follow This!):**
|
||||
1. React imports
|
||||
2. External libraries
|
||||
3. Shared imports (`@pezkuwi/*`)
|
||||
4. Local imports (`@/`)
|
||||
5. Types
|
||||
6. Styles/assets
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { formatBalance } from '@pezkuwi/lib/wallet';
|
||||
import { WalletAccount } from '@pezkuwi/types';
|
||||
import { ASSET_IDS } from '@pezkuwi/constants';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import type { PoolInfo } from '@/types/dex';
|
||||
import '@/styles/dashboard.css';
|
||||
```
|
||||
|
||||
### TypeScript Conventions
|
||||
|
||||
**Strict Mode Enabled:**
|
||||
```json
|
||||
{
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
}
|
||||
```
|
||||
|
||||
**Type Patterns:**
|
||||
- Use `interface` for object shapes
|
||||
- Use `type` for unions, intersections, and complex types
|
||||
- Use `enum` for fixed sets of values
|
||||
- Use `as const` for literal types
|
||||
- Avoid `any` - use `unknown` and type guards instead
|
||||
|
||||
---
|
||||
|
||||
## ⛓️ Blockchain Integration
|
||||
|
||||
### Network Endpoints
|
||||
|
||||
```typescript
|
||||
// shared/blockchain/endpoints.ts
|
||||
const ENDPOINTS = {
|
||||
MAINNET: 'wss://mainnet.pezkuwichain.io',
|
||||
BETA: 'wss://rpc.pezkuwichain.io:9944', // Currently active
|
||||
STAGING: 'wss://staging.pezkuwichain.io',
|
||||
TESTNET: 'wss://testnet.pezkuwichain.io',
|
||||
LOCAL: 'ws://127.0.0.1:9944'
|
||||
};
|
||||
|
||||
// Default for development
|
||||
DEFAULT_ENDPOINT = 'ws://127.0.0.1:9944';
|
||||
```
|
||||
|
||||
### Asset System
|
||||
|
||||
**⚠️ CRITICAL: wUSDT uses 6 decimals, not 12!**
|
||||
|
||||
```typescript
|
||||
// Native token (no Asset ID)
|
||||
HEZ - Accessed via system.account.data.free
|
||||
|
||||
// Assets pallet (12 decimals except wUSDT)
|
||||
ASSET_IDS = {
|
||||
WHEZ: 0, // Wrapped HEZ - 12 decimals
|
||||
PEZ: 1, // Utility token - 12 decimals
|
||||
WUSDT: 2, // Wrapped USDT - 6 decimals ⚠️
|
||||
}
|
||||
|
||||
// Display mapping (internal vs user-facing)
|
||||
TOKEN_DISPLAY_SYMBOLS = {
|
||||
'wHEZ': 'HEZ', // Show as HEZ to users
|
||||
'wUSDT': 'USDT', // Show as USDT to users
|
||||
'PEZ': 'PEZ' // Keep as PEZ
|
||||
}
|
||||
```
|
||||
|
||||
### Polkadot.js Connection Pattern
|
||||
|
||||
```typescript
|
||||
import { ApiPromise, WsProvider } from '@polkadot/api';
|
||||
|
||||
// Initialize API
|
||||
const provider = new WsProvider(endpoint);
|
||||
const api = await ApiPromise.create({ provider });
|
||||
await api.isReady;
|
||||
|
||||
// Query native balance
|
||||
const { data } = await api.query.system.account(address);
|
||||
const balance = data.free.toString();
|
||||
|
||||
// Query asset balance
|
||||
const assetData = await api.query.assets.account(ASSET_IDS.PEZ, address);
|
||||
const amount = assetData.unwrap().balance.toString();
|
||||
```
|
||||
|
||||
### Transaction Pattern
|
||||
|
||||
```typescript
|
||||
// Simple transaction
|
||||
const extrinsic = api.tx.balances.transfer(dest, amount);
|
||||
const hash = await extrinsic.signAndSend(account, { signer });
|
||||
|
||||
// With event handling
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
let unsub;
|
||||
|
||||
api.tx.module.method(params)
|
||||
.signAndSend(account, { signer }, ({ 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) {
|
||||
// Extract data from events
|
||||
const event = events.find(e =>
|
||||
e.event.section === 'module' &&
|
||||
e.event.method === 'EventName'
|
||||
);
|
||||
resolve(event.data[0].toString());
|
||||
if (unsub) unsub();
|
||||
}
|
||||
})
|
||||
.then(unsubscribe => { unsub = unsubscribe; });
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Pallets
|
||||
|
||||
1. **pallet-tiki** - Governance roles (70+ roles: Hemwelatî, Parlementer, Serok, Wezir, etc.)
|
||||
2. **pallet-identity-kyc** - Zero-knowledge citizenship & KYC
|
||||
3. **pallet-perwerde** - Education platform (courses, enrollments, certificates)
|
||||
4. **pallet-validator-pool** - Validator pool categories & staking
|
||||
5. **pallet-welati** - P2P fiat trading with escrow
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI Patterns & Styling
|
||||
|
||||
### shadcn/ui Components
|
||||
|
||||
Located in `web/src/components/ui/` - 50+ components built on Radix UI primitives.
|
||||
|
||||
**Component Variants (CVA Pattern):**
|
||||
```typescript
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md font-medium',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-kurdish-green text-white',
|
||||
destructive: 'bg-kurdish-red text-white',
|
||||
outline: 'border border-input bg-background',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 px-3',
|
||||
lg: 'h-11 px-8',
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Kurdistan Color System
|
||||
|
||||
**Primary Colors:**
|
||||
```typescript
|
||||
KURDISTAN_COLORS = {
|
||||
kesk: '#00A94F', // Green (Kesk) - Primary brand color
|
||||
sor: '#EE2A35', // Red (Sor) - Danger/error
|
||||
zer: '#FFD700', // Yellow/Gold (Zer) - Warning/accent
|
||||
spi: '#FFFFFF', // White (Spî)
|
||||
res: '#000000', // Black (Reş)
|
||||
}
|
||||
```
|
||||
|
||||
**Tailwind Usage:**
|
||||
```css
|
||||
bg-kurdish-green
|
||||
bg-kurdish-green-dark
|
||||
bg-kurdish-green-light
|
||||
text-kurdish-red
|
||||
border-kurdish-yellow
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Internationalization (i18n)
|
||||
|
||||
### Supported Languages
|
||||
|
||||
| Code | Language | Direction | Status |
|
||||
|------|----------|-----------|--------|
|
||||
| `en` | English | LTR | ✅ Complete |
|
||||
| `tr` | Türkçe (Turkish) | LTR | ✅ Complete |
|
||||
| `kmr` | Kurmancî (Kurdish Kurmanji) | LTR | ✅ Complete |
|
||||
| `ckb` | سۆرانی (Kurdish Sorani) | RTL | ✅ Complete |
|
||||
| `ar` | العربية (Arabic) | RTL | ✅ Complete |
|
||||
| `fa` | فارسی (Persian) | RTL | ✅ Complete |
|
||||
|
||||
### Translation Files
|
||||
|
||||
- **Web:** `web/src/i18n/locales/*.ts` (TypeScript modules - local imports)
|
||||
- **Mobile:** `mobile/src/i18n/locales/*.ts`
|
||||
- **Shared:** `shared/i18n/locales/*.json` (JSON files)
|
||||
|
||||
**⚠️ Important:** Web uses `.ts` files with local imports, not shared JSON files. This was changed to fix loading issues.
|
||||
|
||||
### RTL Support
|
||||
|
||||
```typescript
|
||||
import { isRTL } from '@pezkuwi/i18n';
|
||||
|
||||
// Detect RTL languages
|
||||
const isRightToLeft = isRTL(currentLanguage); // true for ckb, ar, fa
|
||||
|
||||
// Apply direction
|
||||
document.dir = isRightToLeft ? 'rtl' : 'ltr';
|
||||
```
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
```typescript
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function Component() {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{t('welcome.title')}</h1>
|
||||
<button onClick={() => i18n.changeLanguage('kmr')}>
|
||||
{t('language.kurdish')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ State Management
|
||||
|
||||
### Provider Hierarchy
|
||||
|
||||
**Order matters!** This is the provider nesting in `web/src/App.tsx`:
|
||||
|
||||
```typescript
|
||||
<ThemeProvider> // Dark/light mode
|
||||
<ErrorBoundary> // Error handling
|
||||
<AuthProvider> // Supabase authentication
|
||||
<AppProvider> // Global app state
|
||||
<PolkadotProvider> // Blockchain API connection
|
||||
<WalletProvider> // Wallet state & balances
|
||||
<WebSocketProvider> // Real-time blockchain events
|
||||
<IdentityProvider> // User identity & KYC
|
||||
<Router />
|
||||
</IdentityProvider>
|
||||
</WebSocketProvider>
|
||||
</WalletProvider>
|
||||
</PolkadotProvider>
|
||||
</AppProvider>
|
||||
</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>
|
||||
```
|
||||
|
||||
### Context APIs
|
||||
|
||||
**PolkadotContext:**
|
||||
```typescript
|
||||
interface PolkadotContextType {
|
||||
api: ApiPromise | null;
|
||||
isApiReady: boolean;
|
||||
accounts: InjectedAccountWithMeta[];
|
||||
selectedAccount: InjectedAccountWithMeta | null;
|
||||
connectWallet: () => Promise<void>;
|
||||
disconnectWallet: () => void;
|
||||
error: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
**WalletContext:**
|
||||
```typescript
|
||||
interface WalletContextType {
|
||||
isConnected: boolean;
|
||||
account: string | null;
|
||||
accounts: InjectedAccountWithMeta[];
|
||||
balance: string; // HEZ native balance
|
||||
balances: {
|
||||
HEZ: string;
|
||||
PEZ: string;
|
||||
wHEZ: string;
|
||||
USDT: string;
|
||||
};
|
||||
signer: Signer | null;
|
||||
connectWallet: () => Promise<void>;
|
||||
disconnect: () => void;
|
||||
switchAccount: (account: InjectedAccountWithMeta) => void;
|
||||
signTransaction: (tx: SubmittableExtrinsic) => Promise<string>;
|
||||
refreshBalances: () => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### TanStack Query (React Query)
|
||||
|
||||
Used for server state caching and automatic refetching:
|
||||
|
||||
```typescript
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['proposals'],
|
||||
queryFn: () => fetchProposals(api),
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
enabled: !!api, // Only run when API is ready
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security Best Practices
|
||||
|
||||
### Environment Variables
|
||||
|
||||
**NEVER commit `.env` files!**
|
||||
|
||||
```bash
|
||||
# .env.example (commit this)
|
||||
VITE_SUPABASE_URL=your_supabase_url
|
||||
VITE_SUPABASE_ANON_KEY=your_anon_key
|
||||
VITE_NETWORK=local
|
||||
|
||||
# .env (DO NOT commit)
|
||||
VITE_SUPABASE_URL=https://actual-url.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=actual_key_here
|
||||
VITE_NETWORK=beta
|
||||
```
|
||||
|
||||
**Access in code:**
|
||||
```typescript
|
||||
// Web (Vite)
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
|
||||
// Mobile (Expo)
|
||||
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
|
||||
```
|
||||
|
||||
### Sensitive Data Handling
|
||||
|
||||
- **Wallet seeds:** NEVER stored in app - Polkadot.js extension only
|
||||
- **Private keys:** NEVER accessible to frontend code
|
||||
- **KYC data:** AES-GCM encrypted → IPFS → Hash stored on-chain
|
||||
- **API keys:** Environment variables only, never hardcoded
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
// ErrorBoundary for React errors
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
|
||||
// Try-catch for async operations
|
||||
try {
|
||||
await api.tx.method(params).signAndSend(account, { signer });
|
||||
toast.success('Transaction successful!');
|
||||
} catch (error) {
|
||||
console.error('Transaction failed:', error);
|
||||
toast.error(error.message || 'Transaction failed');
|
||||
// Don't expose sensitive error details to users
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧰 Utility Functions
|
||||
|
||||
### Formatting
|
||||
|
||||
```typescript
|
||||
import { formatAddress, formatBalance, parseAmount } from '@pezkuwi/utils/formatting';
|
||||
|
||||
// Address formatting
|
||||
formatAddress('5GrwVaEbzhSSC2biT...xQjz')
|
||||
// → '5GrwV...xQjz'
|
||||
|
||||
// Balance formatting (with decimals)
|
||||
formatBalance('1234567890000', 12) // HEZ, PEZ, wHEZ
|
||||
// → '1234.5679'
|
||||
|
||||
formatBalance('1234567', 6) // wUSDT (6 decimals!)
|
||||
// → '1.2346'
|
||||
|
||||
// Amount parsing (to BigInt)
|
||||
parseAmount('100', 12)
|
||||
// → 100000000000000n
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
```typescript
|
||||
import { isValidAddress, isValidAmount } from '@pezkuwi/utils/validation';
|
||||
|
||||
isValidAddress('5GrwVaEbzhSSC2biT...') // true
|
||||
isValidAmount('100.5') // true
|
||||
isValidAmount('abc') // false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing & Quality
|
||||
|
||||
### Before Committing
|
||||
|
||||
1. **Run linter:** `npm run lint`
|
||||
2. **Check no `.env` committed:** `git status`
|
||||
3. **Remove debug logs:** Search for `console.log`
|
||||
4. **Update types:** If API changed
|
||||
5. **Test i18n:** Check all 6 languages
|
||||
6. **Test RTL:** Check ckb, ar, fa layouts
|
||||
|
||||
### Before Deploying
|
||||
|
||||
1. **Test production build:**
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
2. **Verify environment variables** set correctly
|
||||
3. **Check Supabase migrations** applied
|
||||
4. **Backup database** (if schema changed)
|
||||
5. **Monitor blockchain** validator status
|
||||
|
||||
---
|
||||
|
||||
## 📊 Database Schema (Supabase)
|
||||
|
||||
### Core Tables
|
||||
|
||||
- **profiles** - User profiles (linked to auth.users)
|
||||
- **forum_categories** - Forum categories
|
||||
- **forum_threads** - Forum threads
|
||||
- **forum_posts** - Forum posts with moderation
|
||||
- **courses** - Perwerde education courses
|
||||
- **enrollments** - Course enrollments
|
||||
- **p2p_offers** - P2P fiat trading offers
|
||||
- **p2p_trades** - Active trades with escrow
|
||||
- **p2p_reputation** - User reputation scores
|
||||
- **payment_methods** - Payment method registry
|
||||
|
||||
### Hybrid Architecture
|
||||
|
||||
**Blockchain = Source of Truth**
|
||||
```
|
||||
User action → Blockchain transaction → Event emitted
|
||||
↓
|
||||
Event listener → Supabase sync (for indexing/caching)
|
||||
↓
|
||||
UI queries Supabase (fast) + Blockchain (verification)
|
||||
```
|
||||
|
||||
**Example Flow (Creating a Course):**
|
||||
1. User submits form
|
||||
2. Frontend calls `api.tx.perwerde.createCourse(...)`
|
||||
3. Transaction finalized on-chain
|
||||
4. Event listener catches `CourseCreated` event
|
||||
5. Sync to Supabase for UI display
|
||||
6. UI reads from Supabase (fast) but trusts blockchain
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Common Issues & Solutions
|
||||
|
||||
### Issue: Polkadot.js API not connecting
|
||||
|
||||
**Solution:**
|
||||
1. Check endpoint is reachable: `curl -I http://37.60.230.9:9944`
|
||||
2. Verify WebSocket protocol (wss vs ws)
|
||||
3. Check CORS settings on blockchain node
|
||||
4. Ensure validators are running: `ssh pezkuwi-vps "ps aux | grep pezkuwi"`
|
||||
|
||||
### Issue: Transaction fails with "BadOrigin"
|
||||
|
||||
**Solution:**
|
||||
- User doesn't have required role (check pallet-tiki roles)
|
||||
- Use `dispatch_as` if needed for elevated permissions
|
||||
|
||||
### Issue: Balance shows as 0
|
||||
|
||||
**Solution:**
|
||||
- Check correct Asset ID (wHEZ: 0, PEZ: 1, wUSDT: 2)
|
||||
- Remember wUSDT uses 6 decimals, not 12
|
||||
- Verify account has opted-in to asset (required for assets pallet)
|
||||
|
||||
### Issue: i18n translations not loading
|
||||
|
||||
**Solution:**
|
||||
- Web uses local `.ts` files (not shared JSON)
|
||||
- Check import path: `import en from './locales/en.ts'`
|
||||
- Not: `import en from '@pezkuwi/i18n/locales/en.json'`
|
||||
|
||||
### Issue: Build fails with "Can't resolve @pezkuwi/..."
|
||||
|
||||
**Solution:**
|
||||
- Check Vite path aliases in `vite.config.ts`
|
||||
- Verify TypeScript path mappings in `tsconfig.json`
|
||||
- Run `npm install` in shared directory if using symlinks
|
||||
|
||||
---
|
||||
|
||||
## 📝 Commit Guidelines
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
```
|
||||
<type>: <subject>
|
||||
|
||||
<body (optional)>
|
||||
```
|
||||
|
||||
**Types:**
|
||||
- `feat:` New feature
|
||||
- `fix:` Bug fix
|
||||
- `docs:` Documentation changes
|
||||
- `style:` Code style changes (formatting)
|
||||
- `refactor:` Code refactoring
|
||||
- `test:` Adding tests
|
||||
- `chore:` Build process, dependencies
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
git commit -m "feat: add P2P fiat trading interface"
|
||||
git commit -m "fix: wUSDT decimals now correctly use 6 instead of 12"
|
||||
git commit -m "docs: update CLAUDE.md with blockchain integration patterns"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Learning Resources
|
||||
|
||||
### Polkadot.js
|
||||
|
||||
- **API Docs:** https://polkadot.js.org/docs/
|
||||
- **Apps UI:** https://github.com/polkadot-js/apps
|
||||
- **Extension:** https://polkadot.js.org/extension/
|
||||
|
||||
### UI/UX
|
||||
|
||||
- **shadcn/ui:** https://ui.shadcn.com/
|
||||
- **Radix UI:** https://www.radix-ui.com/
|
||||
- **Tailwind CSS:** https://tailwindcss.com/
|
||||
|
||||
### Mobile
|
||||
|
||||
- **Expo:** https://docs.expo.dev/
|
||||
- **React Native:** https://reactnative.dev/
|
||||
- **React Navigation:** https://reactnavigation.org/
|
||||
|
||||
### Backend
|
||||
|
||||
- **Supabase:** https://supabase.com/docs
|
||||
- **PostgreSQL:** https://www.postgresql.org/docs/
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Useful Commands
|
||||
|
||||
### Blockchain Health Check
|
||||
|
||||
```bash
|
||||
# Check validator logs
|
||||
ssh pezkuwi-vps "tail -f /tmp/validator-1.log"
|
||||
|
||||
# Check finalization
|
||||
ssh pezkuwi-vps "tail -30 /tmp/validator-1.log | grep -E 'peers|finalized' | tail -5"
|
||||
|
||||
# View all validators
|
||||
ssh pezkuwi-vps "ps aux | grep pezkuwi"
|
||||
```
|
||||
|
||||
### Deployment
|
||||
|
||||
```bash
|
||||
# Full web deployment
|
||||
cd web && \
|
||||
npm run build && \
|
||||
rsync -avz dist/ pezkuwi-vps:/var/www/pezkuwichain/web/dist/ && \
|
||||
ssh pezkuwi-vps "systemctl reload nginx"
|
||||
```
|
||||
|
||||
### Database
|
||||
|
||||
```bash
|
||||
# Apply Supabase migrations
|
||||
cd web/supabase
|
||||
supabase db push
|
||||
|
||||
# Reset local database
|
||||
supabase db reset
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 AI Assistant Guidelines
|
||||
|
||||
### When Working on Features
|
||||
|
||||
1. **Read critical docs first:** `CLAUDE_README_KRITIK.md`
|
||||
2. **Check current branch:** Verify you're on correct feature branch
|
||||
3. **Test blockchain connectivity:** Before making blockchain changes
|
||||
4. **Use existing patterns:** Follow component/context patterns
|
||||
5. **Maintain type safety:** No `any` types
|
||||
6. **Test all languages:** Check i18n keys exist
|
||||
7. **Test RTL layout:** For ckb, ar, fa languages
|
||||
|
||||
### When Making Blockchain Changes
|
||||
|
||||
1. **Understand pallet first:** Read Rust pallet code if needed
|
||||
2. **Test on local node:** Before testnet
|
||||
3. **Handle errors properly:** Extract dispatchError correctly
|
||||
4. **Update Supabase:** If creating indexable data
|
||||
5. **Monitor events:** Use WebSocketContext for real-time updates
|
||||
|
||||
### When Deploying
|
||||
|
||||
1. **Never deploy without testing**
|
||||
2. **Check validator status first:** Ensure blockchain is healthy
|
||||
3. **Deploy during low-traffic hours:** If possible
|
||||
4. **Monitor logs after deploy:** Watch for errors
|
||||
5. **Have rollback plan:** Keep previous build
|
||||
|
||||
---
|
||||
|
||||
## 📞 Getting Help
|
||||
|
||||
### Documentation Files
|
||||
|
||||
- `README.md` - Project overview
|
||||
- `CLAUDE_README_KRITIK.md` - Critical operational guidelines (Turkish)
|
||||
- `PRODUCTION_READINESS.md` - Production status report
|
||||
- `web/SECURITY.md` - Security policies
|
||||
- `web/mimari.txt` - Detailed system architecture (Turkish)
|
||||
|
||||
### VPS Access
|
||||
|
||||
- **IP:** 37.60.230.9
|
||||
- **SSH:** `ssh pezkuwi-vps` (alias assumed configured)
|
||||
- **Web Root:** `/var/www/pezkuwichain/web/dist/`
|
||||
- **Nginx Config:** `/etc/nginx/sites-available/pezkuwichain.io`
|
||||
|
||||
---
|
||||
|
||||
## ✅ Quick Reference Checklist
|
||||
|
||||
**Starting a new feature:**
|
||||
- [ ] Create feature branch
|
||||
- [ ] Read relevant shared libraries
|
||||
- [ ] Check existing similar features
|
||||
- [ ] Plan component structure
|
||||
- [ ] Add i18n keys for all languages
|
||||
|
||||
**Before committing:**
|
||||
- [ ] Run `npm run lint`
|
||||
- [ ] Remove console.logs
|
||||
- [ ] Check no `.env` changes
|
||||
- [ ] Test in browser
|
||||
- [ ] Write clear commit message
|
||||
|
||||
**Before deploying:**
|
||||
- [ ] Test production build locally
|
||||
- [ ] Verify environment variables
|
||||
- [ ] Check blockchain connection
|
||||
- [ ] Monitor validator status
|
||||
- [ ] Plan rollback strategy
|
||||
|
||||
**After deploying:**
|
||||
- [ ] Test live site
|
||||
- [ ] Check browser console
|
||||
- [ ] Monitor error logs
|
||||
- [ ] Verify blockchain transactions work
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-11-17
|
||||
**Maintained By:** PezkuwiChain Development Team
|
||||
**Production Status:** 95% Complete - Beta Testnet Active
|
||||
+10
-23
@@ -1,34 +1,21 @@
|
||||
{
|
||||
"name": "pezkuwi-kyc-backend",
|
||||
"name": "pezkuwi-indexer-service",
|
||||
"version": "1.0.0",
|
||||
"description": "KYC Approval Council Backend",
|
||||
"description": "Simple Transaction Indexer for Pezkuwi Chain",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/index.js",
|
||||
"start": "node src/index.js",
|
||||
"lint": "eslint 'src/**/*.js' --fix"
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pezkuwi/keyring": "^12.5.1",
|
||||
"@pezkuwi/util-crypto": "^12.5.1",
|
||||
"@supabase/supabase-js": "^2.83.0",
|
||||
"@pezkuwi/api": "^16.5.9",
|
||||
"@pezkuwi/util": "^14.0.11",
|
||||
"@pezkuwi/util-crypto": "^14.0.11",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"pino": "^10.1.0",
|
||||
"pino-http": "^11.0.0",
|
||||
"pino-pretty": "^13.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pezkuwi/api": "^16.5.2",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-standard": "^17.1.0",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-n": "^16.6.2",
|
||||
"eslint-plugin-promise": "^6.6.0",
|
||||
"jest": "^30.2.0",
|
||||
"nodemon": "^3.0.2",
|
||||
"supertest": "^7.1.4"
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7"
|
||||
}
|
||||
}
|
||||
|
||||
+132
-5
@@ -1,7 +1,134 @@
|
||||
import { app, logger } from './server.js'
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { open } from 'sqlite';
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { ApiPromise, WsProvider } from '@pezkuwi/api';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
const PORT = process.env.PORT || 3001
|
||||
dotenv.config();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`🚀 KYC Council Backend running on port ${PORT}`)
|
||||
})
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3001;
|
||||
const WS_ENDPOINT = process.env.WS_ENDPOINT || 'wss://rpc.pezkuwichain.io';
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Initialize Database
|
||||
async function initDb() {
|
||||
const db = await open({
|
||||
filename: './transactions.db',
|
||||
driver: sqlite3.Database
|
||||
});
|
||||
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS transfers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hash TEXT UNIQUE,
|
||||
sender TEXT,
|
||||
receiver TEXT,
|
||||
amount TEXT,
|
||||
asset_id INTEGER DEFAULT NULL,
|
||||
symbol TEXT,
|
||||
block_number INTEGER,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
// Start Indexing
|
||||
async function startIndexer(db) {
|
||||
console.log(`Connecting to Pezkuwi Node: ${WS_ENDPOINT}`);
|
||||
const provider = new WsProvider(WS_ENDPOINT);
|
||||
const api = await ApiPromise.create({ provider });
|
||||
|
||||
console.log('Connected! Listening for new blocks...');
|
||||
|
||||
api.rpc.chain.subscribeNewHeads(async (header) => {
|
||||
const blockNumber = header.number.toNumber();
|
||||
const blockHash = await api.rpc.chain.getBlockHash(blockNumber);
|
||||
const signedBlock = await api.rpc.chain.getBlock(blockHash);
|
||||
|
||||
signedBlock.block.extrinsics.forEach(async (ex) => {
|
||||
const { method: { method, section }, signer } = ex;
|
||||
|
||||
// 1. Handle Native HEZ Transfers
|
||||
if (section === 'balances' && (method === 'transfer' || method === 'transferKeepAlive')) {
|
||||
const [dest, value] = ex.method.args;
|
||||
await saveTransfer(db, {
|
||||
hash: ex.hash.toHex(),
|
||||
sender: signer.toString(),
|
||||
receiver: dest.toString(),
|
||||
amount: value.toString(),
|
||||
asset_id: null,
|
||||
symbol: 'HEZ',
|
||||
block_number: blockNumber
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Handle Asset Transfers (PEZ, USDT)
|
||||
if (section === 'assets' && method === 'transfer') {
|
||||
const [id, dest, value] = ex.method.args;
|
||||
const assetId = id.toNumber();
|
||||
const symbol = assetId === 1 ? 'PEZ' : assetId === 1000 ? 'USDT' : `ASSET-${assetId}`;
|
||||
|
||||
await saveTransfer(db, {
|
||||
hash: ex.hash.toHex(),
|
||||
sender: signer.toString(),
|
||||
receiver: dest.toString(),
|
||||
amount: value.toString(),
|
||||
asset_id: assetId,
|
||||
symbol: symbol,
|
||||
block_number: blockNumber
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function saveTransfer(db, tx) {
|
||||
try {
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO transfers (hash, sender, receiver, amount, asset_id, symbol, block_number)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[tx.hash, tx.sender, tx.receiver, tx.amount, tx.asset_id, tx.symbol, tx.block_number]
|
||||
);
|
||||
console.log(`Indexed ${tx.symbol} Transfer: ${tx.hash.slice(0, 10)}...`);
|
||||
} catch (err) {
|
||||
console.error('DB Insert Error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// API Routes
|
||||
async function startServer(db) {
|
||||
app.get('/api/history/:address', async (req, res) => {
|
||||
const { address } = req.params;
|
||||
try {
|
||||
const history = await db.all(
|
||||
`SELECT * FROM transfers
|
||||
WHERE sender = ? OR receiver = ?
|
||||
ORDER BY block_number DESC LIMIT 50`,
|
||||
[address, address]
|
||||
);
|
||||
res.json(history);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/stats', async (req, res) => {
|
||||
const stats = await db.get('SELECT COUNT(*) as total FROM transfers');
|
||||
res.json(stats);
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Indexer API running at http://localhost:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Launch
|
||||
const db = await initDb();
|
||||
startIndexer(db);
|
||||
startServer(db);
|
||||
|
||||
@@ -39,3 +39,8 @@ yarn-error.*
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
-- ==================================================
|
||||
-- Schema Compatibility Fix
|
||||
-- ==================================================
|
||||
-- This SQL adds missing columns to existing tables
|
||||
-- to make them compatible with mobile app
|
||||
-- ==================================================
|
||||
|
||||
-- 1. Add missing columns to forum_discussions
|
||||
ALTER TABLE forum_discussions
|
||||
ADD COLUMN IF NOT EXISTS likes INTEGER DEFAULT 0;
|
||||
|
||||
-- Update existing rows
|
||||
UPDATE forum_discussions SET likes = 0 WHERE likes IS NULL;
|
||||
|
||||
-- 2. Add missing columns to forum_replies
|
||||
ALTER TABLE forum_replies
|
||||
ADD COLUMN IF NOT EXISTS likes INTEGER DEFAULT 0;
|
||||
|
||||
-- Update existing rows
|
||||
UPDATE forum_replies SET likes = 0 WHERE likes IS NULL;
|
||||
|
||||
-- 3. Fix notifications table
|
||||
-- Check if user_id exists and rename to user_address
|
||||
DO $$
|
||||
BEGIN
|
||||
-- If user_id exists, rename it to user_address
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'notifications'
|
||||
AND column_name = 'user_id'
|
||||
) THEN
|
||||
ALTER TABLE notifications RENAME COLUMN user_id TO user_address;
|
||||
END IF;
|
||||
|
||||
-- If user_address still doesn't exist, add it
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'notifications'
|
||||
AND column_name = 'user_address'
|
||||
) THEN
|
||||
ALTER TABLE notifications ADD COLUMN user_address VARCHAR(100);
|
||||
END IF;
|
||||
|
||||
-- Add other missing columns
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'notifications'
|
||||
AND column_name = 'type'
|
||||
) THEN
|
||||
ALTER TABLE notifications ADD COLUMN type VARCHAR(20) DEFAULT 'system';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'notifications'
|
||||
AND column_name = 'title'
|
||||
) THEN
|
||||
ALTER TABLE notifications ADD COLUMN title VARCHAR(200);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'notifications'
|
||||
AND column_name = 'read'
|
||||
) THEN
|
||||
ALTER TABLE notifications ADD COLUMN read BOOLEAN DEFAULT FALSE;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'notifications'
|
||||
AND column_name = 'metadata'
|
||||
) THEN
|
||||
ALTER TABLE notifications ADD COLUMN metadata JSONB;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ==================================================
|
||||
-- Verify Fix
|
||||
-- ==================================================
|
||||
|
||||
-- Check forum_discussions
|
||||
SELECT 'forum_discussions' as table_name,
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'forum_discussions'
|
||||
AND column_name IN ('likes', 'created_at', 'updated_at')
|
||||
ORDER BY ordinal_position;
|
||||
|
||||
-- Check forum_replies
|
||||
SELECT 'forum_replies' as table_name,
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'forum_replies'
|
||||
AND column_name IN ('likes', 'created_at')
|
||||
ORDER BY ordinal_position;
|
||||
|
||||
-- Check notifications
|
||||
SELECT 'notifications' as table_name,
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'notifications'
|
||||
AND column_name IN ('user_address', 'type', 'title', 'message', 'read', 'metadata', 'created_at')
|
||||
ORDER BY ordinal_position;
|
||||
|
||||
-- ==================================================
|
||||
-- SUCCESS MESSAGE
|
||||
-- ==================================================
|
||||
|
||||
SELECT '✅ Schema compatibility fix complete!' as status;
|
||||
SELECT 'Run check_schema_compatibility.cjs again to verify' as next_step;
|
||||
@@ -0,0 +1,276 @@
|
||||
-- ==================================================
|
||||
-- Pezkuwi Mobile App - Supabase Database Schema
|
||||
-- ==================================================
|
||||
-- This schema creates all tables needed for web2 features:
|
||||
-- - Forum (discussions, categories, replies)
|
||||
-- - P2P Platform (ads, trades)
|
||||
-- - Notifications
|
||||
-- - Referrals
|
||||
-- ==================================================
|
||||
|
||||
-- Enable UUID extension
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ==================================================
|
||||
-- FORUM TABLES
|
||||
-- ==================================================
|
||||
|
||||
-- Forum Categories
|
||||
CREATE TABLE IF NOT EXISTS forum_categories (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
icon VARCHAR(50),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Forum Discussions (Threads)
|
||||
CREATE TABLE IF NOT EXISTS forum_discussions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
category_id UUID NOT NULL REFERENCES forum_categories(id) ON DELETE CASCADE,
|
||||
author_address VARCHAR(100) NOT NULL,
|
||||
author_name VARCHAR(100),
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
likes INTEGER DEFAULT 0,
|
||||
replies_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Forum Replies
|
||||
CREATE TABLE IF NOT EXISTS forum_replies (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
discussion_id UUID NOT NULL REFERENCES forum_discussions(id) ON DELETE CASCADE,
|
||||
author_address VARCHAR(100) NOT NULL,
|
||||
author_name VARCHAR(100),
|
||||
content TEXT NOT NULL,
|
||||
likes INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ==================================================
|
||||
-- P2P PLATFORM TABLES
|
||||
-- ==================================================
|
||||
|
||||
-- P2P Ads
|
||||
CREATE TABLE IF NOT EXISTS p2p_ads (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_address VARCHAR(100) NOT NULL,
|
||||
type VARCHAR(10) NOT NULL CHECK (type IN ('buy', 'sell')),
|
||||
merchant_name VARCHAR(100) NOT NULL,
|
||||
rating DECIMAL(3,2) DEFAULT 0.00 CHECK (rating >= 0 AND rating <= 5),
|
||||
trades_count INTEGER DEFAULT 0,
|
||||
price DECIMAL(18,2) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'USD',
|
||||
amount VARCHAR(50) NOT NULL,
|
||||
min_limit VARCHAR(50) NOT NULL,
|
||||
max_limit VARCHAR(50) NOT NULL,
|
||||
payment_methods TEXT[] NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'completed')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- P2P Trades
|
||||
CREATE TABLE IF NOT EXISTS p2p_trades (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
ad_id UUID NOT NULL REFERENCES p2p_ads(id) ON DELETE CASCADE,
|
||||
buyer_address VARCHAR(100) NOT NULL,
|
||||
seller_address VARCHAR(100) NOT NULL,
|
||||
amount VARCHAR(50) NOT NULL,
|
||||
price DECIMAL(18,2) NOT NULL,
|
||||
total DECIMAL(18,2) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'confirmed', 'disputed', 'completed', 'cancelled')),
|
||||
payment_method VARCHAR(100) NOT NULL,
|
||||
escrow_address VARCHAR(100),
|
||||
chat_messages JSONB DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ==================================================
|
||||
-- NOTIFICATIONS TABLE
|
||||
-- ==================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_address VARCHAR(100) NOT NULL,
|
||||
type VARCHAR(20) NOT NULL CHECK (type IN ('transaction', 'governance', 'p2p', 'referral', 'system')),
|
||||
title VARCHAR(200) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
read BOOLEAN DEFAULT FALSE,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ==================================================
|
||||
-- REFERRALS TABLE
|
||||
-- ==================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS referrals (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
referrer_address VARCHAR(100) NOT NULL,
|
||||
referee_address VARCHAR(100) NOT NULL UNIQUE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'completed')),
|
||||
earnings DECIMAL(18,2) DEFAULT 0.00,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(referrer_address, referee_address)
|
||||
);
|
||||
|
||||
-- ==================================================
|
||||
-- INDEXES FOR PERFORMANCE
|
||||
-- ==================================================
|
||||
|
||||
-- Forum indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_forum_discussions_category ON forum_discussions(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_forum_discussions_author ON forum_discussions(author_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_forum_discussions_created ON forum_discussions(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_forum_replies_discussion ON forum_replies(discussion_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_forum_replies_author ON forum_replies(author_address);
|
||||
|
||||
-- P2P indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_p2p_ads_user ON p2p_ads(user_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_p2p_ads_type ON p2p_ads(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_p2p_ads_status ON p2p_ads(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_p2p_ads_created ON p2p_ads(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_p2p_trades_ad ON p2p_trades(ad_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_p2p_trades_buyer ON p2p_trades(buyer_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_p2p_trades_seller ON p2p_trades(seller_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_p2p_trades_status ON p2p_trades(status);
|
||||
|
||||
-- Notifications indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications(created_at DESC);
|
||||
|
||||
-- Referrals indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_referrals_referrer ON referrals(referrer_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_referrals_referee ON referrals(referee_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_referrals_status ON referrals(status);
|
||||
|
||||
-- ==================================================
|
||||
-- ROW LEVEL SECURITY (RLS) POLICIES
|
||||
-- ==================================================
|
||||
|
||||
-- Enable RLS on all tables
|
||||
ALTER TABLE forum_categories ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE forum_discussions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE forum_replies ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE p2p_ads ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE p2p_trades ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE referrals ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Forum policies (public read, authenticated write)
|
||||
CREATE POLICY "Forum categories are viewable by everyone" ON forum_categories FOR SELECT USING (true);
|
||||
CREATE POLICY "Forum discussions are viewable by everyone" ON forum_discussions FOR SELECT USING (true);
|
||||
CREATE POLICY "Forum discussions can be created by anyone" ON forum_discussions FOR INSERT WITH CHECK (true);
|
||||
CREATE POLICY "Forum discussions can be updated by author" ON forum_discussions FOR UPDATE USING (author_address = current_setting('request.jwt.claims', true)::json->>'address');
|
||||
CREATE POLICY "Forum replies are viewable by everyone" ON forum_replies FOR SELECT USING (true);
|
||||
CREATE POLICY "Forum replies can be created by anyone" ON forum_replies FOR INSERT WITH CHECK (true);
|
||||
|
||||
-- P2P policies (users can only see active ads and their own trades)
|
||||
CREATE POLICY "P2P ads are viewable by everyone" ON p2p_ads FOR SELECT USING (status = 'active' OR user_address = current_setting('request.jwt.claims', true)::json->>'address');
|
||||
CREATE POLICY "P2P ads can be created by anyone" ON p2p_ads FOR INSERT WITH CHECK (true);
|
||||
CREATE POLICY "P2P ads can be updated by owner" ON p2p_ads FOR UPDATE USING (user_address = current_setting('request.jwt.claims', true)::json->>'address');
|
||||
CREATE POLICY "P2P trades are viewable by participants" ON p2p_trades FOR SELECT USING (
|
||||
buyer_address = current_setting('request.jwt.claims', true)::json->>'address' OR
|
||||
seller_address = current_setting('request.jwt.claims', true)::json->>'address'
|
||||
);
|
||||
CREATE POLICY "P2P trades can be created by anyone" ON p2p_trades FOR INSERT WITH CHECK (true);
|
||||
CREATE POLICY "P2P trades can be updated by participants" ON p2p_trades FOR UPDATE USING (
|
||||
buyer_address = current_setting('request.jwt.claims', true)::json->>'address' OR
|
||||
seller_address = current_setting('request.jwt.claims', true)::json->>'address'
|
||||
);
|
||||
|
||||
-- Notifications policies (users can only see their own)
|
||||
CREATE POLICY "Users can view their own notifications" ON notifications FOR SELECT USING (user_address = current_setting('request.jwt.claims', true)::json->>'address');
|
||||
CREATE POLICY "Users can update their own notifications" ON notifications FOR UPDATE USING (user_address = current_setting('request.jwt.claims', true)::json->>'address');
|
||||
|
||||
-- Referrals policies (users can see their own referrals)
|
||||
CREATE POLICY "Users can view their own referrals" ON referrals FOR SELECT USING (
|
||||
referrer_address = current_setting('request.jwt.claims', true)::json->>'address' OR
|
||||
referee_address = current_setting('request.jwt.claims', true)::json->>'address'
|
||||
);
|
||||
CREATE POLICY "Referrals can be created by anyone" ON referrals FOR INSERT WITH CHECK (true);
|
||||
|
||||
-- ==================================================
|
||||
-- SAMPLE DATA FOR TESTING
|
||||
-- ==================================================
|
||||
|
||||
-- Insert sample forum categories
|
||||
INSERT INTO forum_categories (name, description, icon) VALUES
|
||||
('General', 'General discussions about PezkuwiChain', '💬'),
|
||||
('Governance', 'Proposals, voting, and governance topics', '🏛️'),
|
||||
('Technical', 'Technical discussions and development', '💻'),
|
||||
('Trading', 'P2P trading and market discussions', '📈'),
|
||||
('Support', 'Get help and support', '❓')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- ==================================================
|
||||
-- FUNCTIONS AND TRIGGERS
|
||||
-- ==================================================
|
||||
|
||||
-- Function to update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Triggers for updated_at
|
||||
CREATE TRIGGER update_forum_discussions_updated_at BEFORE UPDATE ON forum_discussions FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_p2p_ads_updated_at BEFORE UPDATE ON p2p_ads FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_p2p_trades_updated_at BEFORE UPDATE ON p2p_trades FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_referrals_updated_at BEFORE UPDATE ON referrals FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Function to increment replies_count
|
||||
CREATE OR REPLACE FUNCTION increment_replies_count()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE forum_discussions
|
||||
SET replies_count = replies_count + 1
|
||||
WHERE id = NEW.discussion_id;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Trigger for replies count
|
||||
CREATE TRIGGER increment_forum_replies_count
|
||||
AFTER INSERT ON forum_replies
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION increment_replies_count();
|
||||
|
||||
-- ==================================================
|
||||
-- GRANT PERMISSIONS
|
||||
-- ==================================================
|
||||
|
||||
-- Grant usage on all tables to anon and authenticated users
|
||||
GRANT USAGE ON SCHEMA public TO anon, authenticated;
|
||||
GRANT ALL ON ALL TABLES IN SCHEMA public TO anon, authenticated;
|
||||
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO anon, authenticated;
|
||||
|
||||
-- ==================================================
|
||||
-- SCHEMA COMPLETE
|
||||
-- ==================================================
|
||||
|
||||
-- Verify table creation
|
||||
SELECT
|
||||
'forum_categories' as table_name, COUNT(*) as row_count FROM forum_categories
|
||||
UNION ALL
|
||||
SELECT 'forum_discussions', COUNT(*) FROM forum_discussions
|
||||
UNION ALL
|
||||
SELECT 'forum_replies', COUNT(*) FROM forum_replies
|
||||
UNION ALL
|
||||
SELECT 'p2p_ads', COUNT(*) FROM p2p_ads
|
||||
UNION ALL
|
||||
SELECT 'p2p_trades', COUNT(*) FROM p2p_trades
|
||||
UNION ALL
|
||||
SELECT 'notifications', COUNT(*) FROM notifications
|
||||
UNION ALL
|
||||
SELECT 'referrals', COUNT(*) FROM referrals;
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "mobile",
|
||||
"slug": "mobile",
|
||||
"name": "Pezkuwi Wallet",
|
||||
"slug": "pezkuwi-wallet",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
@@ -16,6 +16,7 @@
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"package": "io.pezkuwichain.wallet",
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 274 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 274 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Check Existing Supabase Tables
|
||||
*
|
||||
* This script connects to Supabase and lists all existing tables
|
||||
* to check for conflicts with new schema
|
||||
*/
|
||||
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
|
||||
const supabaseUrl = 'https://vsyrpfiwhjvahofxwytr.supabase.co';
|
||||
const supabaseKey = 'REDACTED_SUPABASE_ANON_KEY';
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
// Tables we want to create
|
||||
const newTables = [
|
||||
'forum_categories',
|
||||
'forum_discussions',
|
||||
'forum_replies',
|
||||
'p2p_ads',
|
||||
'p2p_trades',
|
||||
'notifications',
|
||||
'referrals'
|
||||
];
|
||||
|
||||
async function checkTables() {
|
||||
console.log('🔍 Checking existing Supabase tables...\n');
|
||||
|
||||
try {
|
||||
// Get list of all tables using information_schema
|
||||
const { data, error } = await supabase
|
||||
.from('information_schema.tables')
|
||||
.select('table_name')
|
||||
.eq('table_schema', 'public')
|
||||
.eq('table_type', 'BASE TABLE');
|
||||
|
||||
if (error) {
|
||||
// If we can't query information_schema, try a different approach
|
||||
console.log('⚠️ Cannot access information_schema with anon key');
|
||||
console.log('ℹ️ This is normal - anon key has limited permissions');
|
||||
console.log('\n📋 Tables we will create:\n');
|
||||
newTables.forEach(table => {
|
||||
console.log(` - ${table}`);
|
||||
});
|
||||
console.log('\n✅ Safe to proceed: Using CREATE TABLE IF NOT EXISTS');
|
||||
console.log(' → Existing tables will NOT be affected');
|
||||
console.log(' → Only missing tables will be created\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTables = data.map(row => row.table_name);
|
||||
|
||||
console.log(`📊 Found ${existingTables.length} existing tables in Supabase:\n`);
|
||||
existingTables.forEach(table => {
|
||||
console.log(` - ${table}`);
|
||||
});
|
||||
|
||||
console.log('\n📋 Tables we want to create:\n');
|
||||
|
||||
const conflicts = [];
|
||||
const newToCreate = [];
|
||||
|
||||
newTables.forEach(table => {
|
||||
if (existingTables.includes(table)) {
|
||||
conflicts.push(table);
|
||||
console.log(` ⚠️ ${table} (ALREADY EXISTS)`);
|
||||
} else {
|
||||
newToCreate.push(table);
|
||||
console.log(` ✅ ${table} (WILL BE CREATED)`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
console.log('\n⚠️ CONFLICT ANALYSIS:\n');
|
||||
console.log(` ${conflicts.length} table(s) already exist:`);
|
||||
conflicts.forEach(table => {
|
||||
console.log(` - ${table}`);
|
||||
});
|
||||
console.log('\n✅ SAFE TO PROCEED:');
|
||||
console.log(' → SQL uses "CREATE TABLE IF NOT EXISTS"');
|
||||
console.log(' → Existing tables will be SKIPPED');
|
||||
console.log(' → No data will be modified or deleted');
|
||||
console.log(' → Only missing tables will be created\n');
|
||||
}
|
||||
|
||||
if (newToCreate.length > 0) {
|
||||
console.log(`\n📝 ${newToCreate.length} new table(s) will be created:`);
|
||||
newToCreate.forEach(table => {
|
||||
console.log(` - ${table}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('\n🎯 RECOMMENDATION:\n');
|
||||
|
||||
if (conflicts.length === 0) {
|
||||
console.log(' ✅ No conflicts found');
|
||||
console.log(' ✅ Safe to run SUPABASE_SCHEMA.sql');
|
||||
console.log(' ✅ All 7 tables will be created\n');
|
||||
} else if (newToCreate.length === 0) {
|
||||
console.log(' ℹ️ All tables already exist');
|
||||
console.log(' ℹ️ You can safely run the SQL (it will skip existing tables)');
|
||||
console.log(' ℹ️ Or you can skip running it entirely\n');
|
||||
} else {
|
||||
console.log(' ⚠️ Some tables exist, some don\'t');
|
||||
console.log(' ✅ Safe to run SUPABASE_SCHEMA.sql');
|
||||
console.log(` ✅ Will create ${newToCreate.length} missing tables`);
|
||||
console.log(` ℹ️ Will skip ${conflicts.length} existing tables\n`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error.message);
|
||||
console.log('\nℹ️ If you see permission errors, this is expected with anon key');
|
||||
console.log('✅ Proceed with SQL execution - it\'s safe!\n');
|
||||
}
|
||||
}
|
||||
|
||||
checkTables().then(() => {
|
||||
console.log('✅ Analysis complete\n');
|
||||
process.exit(0);
|
||||
}).catch(err => {
|
||||
console.error('❌ Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Check Schema Compatibility
|
||||
*/
|
||||
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
|
||||
const supabaseUrl = 'https://vsyrpfiwhjvahofxwytr.supabase.co';
|
||||
const supabaseServiceKey = 'sb_secret_oXy8Diay2J_u8dKPZLxdcQ_hq69Mrb6';
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Expected schemas for mobile app
|
||||
const expectedSchemas = {
|
||||
forum_categories: ['id', 'name', 'description', 'icon', 'created_at'],
|
||||
forum_discussions: ['id', 'category_id', 'author_address', 'author_name', 'title', 'content', 'likes', 'replies_count', 'created_at', 'updated_at'],
|
||||
forum_replies: ['id', 'discussion_id', 'author_address', 'author_name', 'content', 'likes', 'created_at'],
|
||||
notifications: ['id', 'user_address', 'type', 'title', 'message', 'read', 'metadata', 'created_at'],
|
||||
};
|
||||
|
||||
async function checkSchema(tableName, expectedColumns) {
|
||||
console.log(`\n🔍 Checking ${tableName}...`);
|
||||
|
||||
try {
|
||||
// Try to select from table with expected columns
|
||||
const selectQuery = expectedColumns.map(col => `${col}`).join(', ');
|
||||
const { data, error } = await supabase
|
||||
.from(tableName)
|
||||
.select(selectQuery)
|
||||
.limit(1);
|
||||
|
||||
if (error) {
|
||||
console.log(` ❌ ERROR: ${error.message}`);
|
||||
console.log(` ⚠️ Schema might be incompatible`);
|
||||
return { compatible: false, error: error.message };
|
||||
}
|
||||
|
||||
console.log(` ✅ COMPATIBLE - All expected columns found`);
|
||||
return { compatible: true };
|
||||
|
||||
} catch (e) {
|
||||
console.log(` ❌ ERROR: ${e.message}`);
|
||||
return { compatible: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAllSchemas() {
|
||||
console.log('=' .repeat(70));
|
||||
console.log('📋 SCHEMA COMPATIBILITY CHECK');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
const results = {};
|
||||
|
||||
for (const [tableName, columns] of Object.entries(expectedSchemas)) {
|
||||
results[tableName] = await checkSchema(tableName, columns);
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log('📊 SUMMARY:');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
const compatible = Object.entries(results).filter(([_, r]) => r.compatible);
|
||||
const incompatible = Object.entries(results).filter(([_, r]) => !r.compatible);
|
||||
|
||||
if (compatible.length > 0) {
|
||||
console.log(`\n✅ ${compatible.length} table(s) are COMPATIBLE:\n`);
|
||||
compatible.forEach(([table]) => {
|
||||
console.log(` ✅ ${table}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (incompatible.length > 0) {
|
||||
console.log(`\n⚠️ ${incompatible.length} table(s) have ISSUES:\n`);
|
||||
incompatible.forEach(([table, result]) => {
|
||||
console.log(` ❌ ${table}`);
|
||||
console.log(` Error: ${result.error}`);
|
||||
});
|
||||
|
||||
console.log('\n🔧 RECOMMENDED ACTIONS:\n');
|
||||
incompatible.forEach(([table]) => {
|
||||
console.log(` ${table}:`);
|
||||
console.log(` 1. Check column names and types`);
|
||||
console.log(` 2. Add missing columns with ALTER TABLE`);
|
||||
console.log(` 3. Or drop and recreate (data will be lost!)\n`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('='.repeat(70));
|
||||
console.log('\n🎯 FINAL RECOMMENDATION:\n');
|
||||
|
||||
if (incompatible.length === 0) {
|
||||
console.log(' ✅ All existing tables are compatible');
|
||||
console.log(' ✅ Safe to run SUPABASE_SCHEMA.sql');
|
||||
console.log(' ✅ Only missing tables will be created\n');
|
||||
} else {
|
||||
console.log(' ⚠️ Some tables have schema issues');
|
||||
console.log(' ⚠️ Fix schemas before running mobile app');
|
||||
console.log(' ✅ You can still run SQL (it will skip existing tables)\n');
|
||||
}
|
||||
}
|
||||
|
||||
checkAllSchemas().then(() => {
|
||||
console.log('✅ Schema check complete\n');
|
||||
process.exit(0);
|
||||
}).catch(err => {
|
||||
console.error('❌ Fatal error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Check Existing Supabase Tables with Admin Access
|
||||
*/
|
||||
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
|
||||
const supabaseUrl = 'https://vsyrpfiwhjvahofxwytr.supabase.co';
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_KEY || 'sb_secret_oXy8Diay2J_u8dKPZLxdcQ_hq69Mrb6';
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false
|
||||
}
|
||||
});
|
||||
|
||||
const newTables = [
|
||||
'forum_categories',
|
||||
'forum_discussions',
|
||||
'forum_replies',
|
||||
'p2p_ads',
|
||||
'p2p_trades',
|
||||
'notifications',
|
||||
'referrals'
|
||||
];
|
||||
|
||||
async function checkTables() {
|
||||
console.log('🔍 Fetching existing tables with admin access...\n');
|
||||
|
||||
try {
|
||||
// Use RPC to query information_schema
|
||||
const { data, error } = await supabase.rpc('get_table_list', {});
|
||||
|
||||
if (error) {
|
||||
// Fallback: Direct query
|
||||
const query = `
|
||||
SELECT
|
||||
table_name,
|
||||
(SELECT COUNT(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = t.table_name
|
||||
AND table_schema = 'public') as column_count
|
||||
FROM information_schema.tables t
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name;
|
||||
`;
|
||||
|
||||
const { data: tableData, error: queryError } = await supabase
|
||||
.from('_placeholder')
|
||||
.select();
|
||||
|
||||
if (queryError) {
|
||||
console.log('⚠️ Cannot query information_schema directly');
|
||||
console.log('ℹ️ Will check for conflicts manually\n');
|
||||
await checkConflictsManually();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If we got data, process it
|
||||
if (data && Array.isArray(data)) {
|
||||
const existingTables = data.map(row => row.table_name);
|
||||
displayAnalysis(existingTables);
|
||||
} else {
|
||||
await checkConflictsManually();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log('⚠️ Error:', error.message);
|
||||
console.log('ℹ️ Checking conflicts manually...\n');
|
||||
await checkConflictsManually();
|
||||
}
|
||||
}
|
||||
|
||||
async function checkConflictsManually() {
|
||||
console.log('📋 Checking each table individually...\n');
|
||||
|
||||
const results = {
|
||||
existing: [],
|
||||
missing: [],
|
||||
errors: []
|
||||
};
|
||||
|
||||
for (const tableName of newTables) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from(tableName)
|
||||
.select('*')
|
||||
.limit(0);
|
||||
|
||||
if (error) {
|
||||
if (error.message.includes('does not exist') || error.code === '42P01') {
|
||||
results.missing.push(tableName);
|
||||
console.log(` ✅ ${tableName} - NOT FOUND (will be created)`);
|
||||
} else {
|
||||
results.errors.push({ table: tableName, error: error.message });
|
||||
console.log(` ⚠️ ${tableName} - ERROR: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
results.existing.push(tableName);
|
||||
console.log(` ⚠️ ${tableName} - ALREADY EXISTS`);
|
||||
}
|
||||
} catch (e) {
|
||||
results.errors.push({ table: tableName, error: e.message });
|
||||
console.log(` ❌ ${tableName} - ERROR: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log('\n📊 CONFLICT ANALYSIS:\n');
|
||||
|
||||
if (results.existing.length > 0) {
|
||||
console.log(`⚠️ ${results.existing.length} table(s) ALREADY EXIST:\n`);
|
||||
results.existing.forEach(table => {
|
||||
console.log(` - ${table}`);
|
||||
});
|
||||
console.log('\n ℹ️ These tables will be SKIPPED');
|
||||
console.log(' ℹ️ Existing data will NOT be modified\n');
|
||||
}
|
||||
|
||||
if (results.missing.length > 0) {
|
||||
console.log(`✅ ${results.missing.length} table(s) WILL BE CREATED:\n`);
|
||||
results.missing.forEach(table => {
|
||||
console.log(` - ${table}`);
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (results.errors.length > 0) {
|
||||
console.log(`⚠️ ${results.errors.length} error(s) encountered:\n`);
|
||||
results.errors.forEach(({ table, error }) => {
|
||||
console.log(` - ${table}: ${error}`);
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log('='.repeat(70));
|
||||
console.log('\n🎯 RECOMMENDATION:\n');
|
||||
|
||||
if (results.missing.length === newTables.length) {
|
||||
console.log(' ✅ NO CONFLICTS - All tables are new');
|
||||
console.log(' ✅ Safe to run SUPABASE_SCHEMA.sql');
|
||||
console.log(' ✅ All 7 tables will be created\n');
|
||||
} else if (results.existing.length === newTables.length) {
|
||||
console.log(' ℹ️ ALL TABLES ALREADY EXIST');
|
||||
console.log(' ℹ️ SQL will skip all tables (no changes)');
|
||||
console.log(' ⚠️ Check if schemas match mobile app expectations\n');
|
||||
} else {
|
||||
console.log(' ⚠️ PARTIAL CONFLICT');
|
||||
console.log(` ✅ ${results.missing.length} tables will be created`);
|
||||
console.log(` ℹ️ ${results.existing.length} tables will be skipped`);
|
||||
console.log(' ✅ Safe to run SUPABASE_SCHEMA.sql\n');
|
||||
}
|
||||
}
|
||||
|
||||
function displayAnalysis(existingTables) {
|
||||
console.log(`📊 Found ${existingTables.length} existing tables:\n`);
|
||||
|
||||
existingTables.forEach(table => {
|
||||
const isConflict = newTables.includes(table);
|
||||
console.log(` ${isConflict ? '⚠️ ' : ' '} ${table}${isConflict ? ' (CONFLICT)' : ''}`);
|
||||
});
|
||||
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log('\n📋 New tables to create:\n');
|
||||
|
||||
const conflicts = [];
|
||||
const newToCreate = [];
|
||||
|
||||
newTables.forEach(table => {
|
||||
if (existingTables.includes(table)) {
|
||||
conflicts.push(table);
|
||||
console.log(` ⚠️ ${table} (ALREADY EXISTS - will be skipped)`);
|
||||
} else {
|
||||
newToCreate.push(table);
|
||||
console.log(` ✅ ${table} (WILL BE CREATED)`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log('\n🎯 SUMMARY:\n');
|
||||
console.log(` Total existing tables: ${existingTables.length}`);
|
||||
console.log(` Conflicting tables: ${conflicts.length}`);
|
||||
console.log(` New tables to create: ${newToCreate.length}`);
|
||||
console.log(` After SQL: ${existingTables.length + newToCreate.length} total tables\n`);
|
||||
|
||||
console.log('='.repeat(70));
|
||||
console.log('\n✅ RECOMMENDATION:\n');
|
||||
|
||||
if (conflicts.length === 0) {
|
||||
console.log(' ✅ NO CONFLICTS');
|
||||
console.log(' ✅ Safe to run SUPABASE_SCHEMA.sql\n');
|
||||
} else {
|
||||
console.log(' ⚠️ Some tables already exist');
|
||||
console.log(' ✅ Safe to run SQL (will skip existing tables)');
|
||||
console.log(' ℹ️ Check schemas of existing tables for compatibility\n');
|
||||
}
|
||||
}
|
||||
|
||||
checkTables().then(() => {
|
||||
console.log('✅ Analysis complete\n');
|
||||
process.exit(0);
|
||||
}).catch(err => {
|
||||
console.error('❌ Fatal error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,343 +0,0 @@
|
||||
# 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:
|
||||
<Text style={styles.balanceAmount}>0.00 HEZ</Text>
|
||||
|
||||
// Satır 98-101: Total Staked
|
||||
<Text style={styles.statValue}>0.00</Text>
|
||||
|
||||
// Satır 103-106: Rewards
|
||||
<Text style={styles.statValue}>0.00</Text>
|
||||
```
|
||||
|
||||
**Gerekli Değişiklik:**
|
||||
```typescript
|
||||
// ✅ OLMASI GEREKEN:
|
||||
import { useBalance } from '@pezkuwi/shared/hooks/blockchain/useBalance';
|
||||
|
||||
const { balance, isLoading, error } = useBalance(api, userAddress);
|
||||
|
||||
<Text style={styles.balanceAmount}>
|
||||
{isLoading ? 'Loading...' : formatBalance(balance.free)} HEZ
|
||||
</Text>
|
||||
```
|
||||
|
||||
#### Active Proposals Card (Lines 133-142)
|
||||
```typescript
|
||||
// ❌ ŞU AN HARDCODED:
|
||||
<Text style={styles.proposalsCount}>0</Text>
|
||||
```
|
||||
|
||||
**Gerekli Değişiklik:**
|
||||
```typescript
|
||||
// ✅ OLMASI GEREKEN:
|
||||
import { useProposals } from '@pezkuwi/shared/hooks/blockchain/useProposals';
|
||||
|
||||
const { proposals, isLoading } = useProposals(api);
|
||||
|
||||
<Text style={styles.proposalsCount}>
|
||||
{isLoading ? '...' : proposals.length}
|
||||
</Text>
|
||||
```
|
||||
|
||||
### 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<ApiPromise | null>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(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<Balance>({
|
||||
free: '0',
|
||||
reserved: '0',
|
||||
frozen: '0'
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(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
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 13.2.0"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Execute SQL Files on Supabase
|
||||
*
|
||||
* This script runs SQL files directly on Supabase database
|
||||
*/
|
||||
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const supabaseUrl = 'https://vsyrpfiwhjvahofxwytr.supabase.co';
|
||||
const supabaseServiceKey = 'sb_secret_oXy8Diay2J_u8dKPZLxdcQ_hq69Mrb6';
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false
|
||||
}
|
||||
});
|
||||
|
||||
async function executeSQLFile(filename) {
|
||||
console.log(`\n${'='.repeat(70)}`);
|
||||
console.log(`📄 Executing: ${filename}`);
|
||||
console.log('='.repeat(70));
|
||||
|
||||
try {
|
||||
const sqlContent = fs.readFileSync(path.join(__dirname, filename), 'utf8');
|
||||
|
||||
// Split SQL by semicolons (simple parser)
|
||||
const statements = sqlContent
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0 && !s.startsWith('--') && !s.match(/^\/\*/));
|
||||
|
||||
console.log(`\n📊 Found ${statements.length} SQL statements\n`);
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
const statement = statements[i];
|
||||
|
||||
// Skip comments and empty statements
|
||||
if (statement.startsWith('--') || statement.length < 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Show first 80 chars of statement
|
||||
const preview = statement.substring(0, 80).replace(/\s+/g, ' ');
|
||||
process.stdout.write(` [${i + 1}/${statements.length}] ${preview}...`);
|
||||
|
||||
try {
|
||||
// Execute using RPC query function (if available)
|
||||
// Note: This requires a custom RPC function in Supabase
|
||||
// Alternative: Use REST API directly
|
||||
|
||||
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/exec_sql`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'apikey': supabaseServiceKey,
|
||||
'Authorization': `Bearer ${supabaseServiceKey}`
|
||||
},
|
||||
body: JSON.stringify({ sql: statement })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log(' ✅');
|
||||
successCount++;
|
||||
} else {
|
||||
const error = await response.text();
|
||||
console.log(` ❌ ${error}`);
|
||||
errorCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(` ❌ ${error.message}`);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${'='.repeat(70)}`);
|
||||
console.log(`📊 Results: ${successCount} succeeded, ${errorCount} failed`);
|
||||
console.log('='.repeat(70));
|
||||
|
||||
return { success: successCount, errors: errorCount };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error reading file: ${error.message}`);
|
||||
return { success: 0, errors: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\n🚀 Starting SQL Execution...\n');
|
||||
|
||||
// Step 1: Fix schema compatibility
|
||||
console.log('📝 Step 1: Fixing schema compatibility...');
|
||||
const result1 = await executeSQLFile('FIX_SCHEMA_COMPATIBILITY.sql');
|
||||
|
||||
// Step 2: Create missing tables
|
||||
console.log('\n📝 Step 2: Creating missing tables...');
|
||||
const result2 = await executeSQLFile('SUPABASE_SCHEMA.sql');
|
||||
|
||||
// Summary
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log('🎉 EXECUTION SUMMARY');
|
||||
console.log('='.repeat(70));
|
||||
console.log(`\nStep 1 (Compatibility Fix):`);
|
||||
console.log(` ✅ Success: ${result1.success}`);
|
||||
console.log(` ❌ Errors: ${result1.errors}`);
|
||||
console.log(`\nStep 2 (Create Tables):`);
|
||||
console.log(` ✅ Success: ${result2.success}`);
|
||||
console.log(` ❌ Errors: ${result2.errors}`);
|
||||
console.log('\n' + '='.repeat(70));
|
||||
|
||||
if (result1.errors === 0 && result2.errors === 0) {
|
||||
console.log('\n✅ ALL OPERATIONS COMPLETED SUCCESSFULLY!\n');
|
||||
console.log('📝 Next step: Run verification script');
|
||||
console.log(' node check_schema_compatibility.cjs\n');
|
||||
} else {
|
||||
console.log('\n⚠️ Some operations failed. Check errors above.\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Alternative: Use Management API
|
||||
async function executeViaManagementAPI() {
|
||||
console.log('\n📝 Attempting to execute via Management API...\n');
|
||||
|
||||
// Supabase doesn't have a public SQL execution API
|
||||
// Best option is to use Supabase Dashboard SQL Editor
|
||||
console.log('⚠️ Supabase JS SDK does not support direct SQL execution');
|
||||
console.log('ℹ️ SQL must be executed via Supabase Dashboard\n');
|
||||
console.log('📋 Instructions:');
|
||||
console.log(' 1. Go to: https://app.supabase.com');
|
||||
console.log(' 2. Select project: vsyrpfiwhjvahofxwytr');
|
||||
console.log(' 3. Open: SQL Editor (left menu)');
|
||||
console.log(' 4. Copy contents of: FIX_SCHEMA_COMPATIBILITY.sql');
|
||||
console.log(' 5. Paste and click: Run');
|
||||
console.log(' 6. Repeat for: SUPABASE_SCHEMA.sql\n');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we can execute SQL programmatically
|
||||
executeViaManagementAPI().then(canExecute => {
|
||||
if (!canExecute) {
|
||||
console.log('⚠️ Manual execution required via Supabase Dashboard\n');
|
||||
process.exit(1);
|
||||
} else {
|
||||
main().then(() => process.exit(0));
|
||||
}
|
||||
});
|
||||
+39
-1
@@ -1,6 +1,44 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
// CRITICAL: Import crypto polyfill FIRST before anything else
|
||||
console.log('🚀 [INDEX] Starting app initialization...');
|
||||
console.log('📦 [INDEX] Loading react-native-get-random-values...');
|
||||
import 'react-native-get-random-values';
|
||||
console.log('✅ [INDEX] react-native-get-random-values loaded');
|
||||
|
||||
// React Native polyfills for @pezkuwi packages
|
||||
console.log('📦 [INDEX] Loading URL polyfill...');
|
||||
import 'react-native-url-polyfill/auto';
|
||||
console.log('✅ [INDEX] URL polyfill loaded');
|
||||
|
||||
console.log('📦 [INDEX] Setting up Buffer...');
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
// Global polyfills for Polkadot.js
|
||||
// @ts-ignore
|
||||
global.Buffer = Buffer;
|
||||
console.log('✅ [INDEX] Buffer configured');
|
||||
|
||||
// TextEncoder/TextDecoder polyfill
|
||||
console.log('📦 [INDEX] Setting up TextEncoder/TextDecoder...');
|
||||
if (typeof global.TextEncoder === 'undefined') {
|
||||
const { TextEncoder, TextDecoder } = require('text-encoding');
|
||||
// @ts-ignore
|
||||
global.TextEncoder = TextEncoder;
|
||||
// @ts-ignore
|
||||
global.TextDecoder = TextDecoder;
|
||||
console.log('✅ [INDEX] TextEncoder/TextDecoder configured');
|
||||
} else {
|
||||
console.log('ℹ️ [INDEX] TextEncoder/TextDecoder already available');
|
||||
}
|
||||
|
||||
console.log('📦 [INDEX] Loading Expo...');
|
||||
import { registerRootComponent } from 'expo';
|
||||
console.log('✅ [INDEX] Expo loaded');
|
||||
|
||||
console.log('📦 [INDEX] Loading App component...');
|
||||
import App from './App';
|
||||
console.log('✅ [INDEX] App component loaded');
|
||||
|
||||
console.log('🎯 [INDEX] All imports successful, registering root component...');
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
|
||||
@@ -3,11 +3,10 @@ module.exports = {
|
||||
setupFiles: ['<rootDir>/jest.setup.before.cjs'],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.cjs'],
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|@polkadot/.*|@babel/runtime)',
|
||||
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|@polkadot/.*|@pezkuwi/.*|@babel/runtime)',
|
||||
'!../shared/.*',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^@pezkuwi/(.*)$': '<rootDir>/../shared/$1',
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'react-native-gesture-handler': '<rootDir>/__mocks__/react-native-gesture-handler.js',
|
||||
'react-native-reanimated': '<rootDir>/__mocks__/react-native-reanimated.js',
|
||||
|
||||
+72
-7
@@ -69,6 +69,74 @@ jest.mock('@polkadot/api', () => ({
|
||||
WsProvider: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock @pezkuwi packages (aliases for @polkadot packages)
|
||||
jest.mock('@pezkuwi/api', () => ({
|
||||
ApiPromise: {
|
||||
create: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
isReady: Promise.resolve(true),
|
||||
query: {
|
||||
treasury: {
|
||||
treasury: jest.fn(() => Promise.resolve({ toString: () => '1000000000000000' })),
|
||||
proposals: {
|
||||
entries: jest.fn(() => Promise.resolve([])),
|
||||
},
|
||||
},
|
||||
democracy: {
|
||||
referendumInfoOf: {
|
||||
entries: jest.fn(() => Promise.resolve([])),
|
||||
},
|
||||
},
|
||||
dynamicCommissionCollective: {
|
||||
proposals: jest.fn(() => Promise.resolve([])),
|
||||
voting: jest.fn(() => Promise.resolve({ isSome: false })),
|
||||
},
|
||||
},
|
||||
tx: {},
|
||||
rpc: {},
|
||||
disconnect: jest.fn(),
|
||||
})
|
||||
),
|
||||
},
|
||||
WsProvider: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@pezkuwi/keyring', () => ({
|
||||
Keyring: jest.fn().mockImplementation(() => ({
|
||||
addFromUri: jest.fn((mnemonic, meta, type) => ({
|
||||
address: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY',
|
||||
meta: meta || {},
|
||||
type: type || 'sr25519',
|
||||
publicKey: new Uint8Array(32),
|
||||
sign: jest.fn(),
|
||||
verify: jest.fn(),
|
||||
})),
|
||||
addFromMnemonic: jest.fn((mnemonic, meta, type) => ({
|
||||
address: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY',
|
||||
meta: meta || {},
|
||||
type: type || 'sr25519',
|
||||
publicKey: new Uint8Array(32),
|
||||
sign: jest.fn(),
|
||||
verify: jest.fn(),
|
||||
})),
|
||||
getPairs: jest.fn(() => []),
|
||||
getPair: jest.fn((address) => ({
|
||||
address: address,
|
||||
meta: {},
|
||||
type: 'sr25519',
|
||||
publicKey: new Uint8Array(32),
|
||||
sign: jest.fn(),
|
||||
verify: jest.fn(),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@pezkuwi/util-crypto', () => ({
|
||||
cryptoWaitReady: jest.fn(() => Promise.resolve(true)),
|
||||
mnemonicGenerate: jest.fn(() => 'test test test test test test test test test test test junk'),
|
||||
mnemonicValidate: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
// Mock Supabase
|
||||
jest.mock('./src/lib/supabase', () => ({
|
||||
supabase: {
|
||||
@@ -98,17 +166,12 @@ jest.mock('./src/lib/supabase', () => ({
|
||||
}));
|
||||
|
||||
// Mock shared blockchain utilities
|
||||
jest.mock('../shared/blockchain/polkadot', () => ({
|
||||
jest.mock('../shared/blockchain', () => ({
|
||||
PEZKUWI_NETWORK: {
|
||||
name: 'Pezkuwi',
|
||||
endpoint: 'wss://beta-rpc.pezkuwi.art',
|
||||
endpoint: 'wss://rpc.pezkuwichain.io:9944',
|
||||
chainId: 'pezkuwi',
|
||||
},
|
||||
BLOCKCHAIN_ENDPOINTS: {
|
||||
mainnet: 'wss://mainnet.pezkuwichain.io',
|
||||
testnet: 'wss://ws.pezkuwichain.io',
|
||||
local: 'ws://127.0.0.1:9944',
|
||||
},
|
||||
DEFAULT_ENDPOINT: 'ws://127.0.0.1:9944',
|
||||
getExplorerUrl: jest.fn((txHash) => `https://explorer.pezkuwichain.app/tx/${txHash}`),
|
||||
}));
|
||||
@@ -205,6 +268,8 @@ jest.mock('i18next', () => ({
|
||||
isInitialized: true,
|
||||
}));
|
||||
|
||||
// Note: Alert is mocked in individual test files where needed
|
||||
|
||||
// Silence console warnings in tests
|
||||
global.console = {
|
||||
...console,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const path = require('path');
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// ============================================
|
||||
// WORKSPACE CONFIGURATION
|
||||
// ============================================
|
||||
|
||||
const projectRoot = __dirname;
|
||||
|
||||
// Use default watchFolders (no custom configuration)
|
||||
|
||||
// ============================================
|
||||
// CUSTOM MODULE RESOLUTION
|
||||
// ============================================
|
||||
// DISABLED: Custom resolver causes empty-module.js resolution issues
|
||||
// Using npm packages directly instead
|
||||
|
||||
// ============================================
|
||||
// FILE EXTENSIONS
|
||||
// ============================================
|
||||
|
||||
config.resolver.sourceExts = [
|
||||
'expo.ts',
|
||||
'expo.tsx',
|
||||
'expo.js',
|
||||
'expo.jsx',
|
||||
'ts',
|
||||
'tsx',
|
||||
'js',
|
||||
'jsx',
|
||||
'json',
|
||||
'wasm',
|
||||
'svg',
|
||||
'cjs',
|
||||
'mjs',
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// NODE POLYFILLS
|
||||
// ============================================
|
||||
|
||||
// Polyfills will be resolved from project's own node_modules
|
||||
|
||||
module.exports = config;
|
||||
@@ -1,71 +0,0 @@
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const path = require('path');
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// Monorepo support: Watch and resolve modules from parent directory
|
||||
const projectRoot = __dirname;
|
||||
const workspaceRoot = path.resolve(projectRoot, '..');
|
||||
|
||||
// Watch all files in the monorepo
|
||||
config.watchFolders = [workspaceRoot];
|
||||
|
||||
// Let Metro resolve modules from the workspace root
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, 'node_modules'),
|
||||
path.resolve(workspaceRoot, 'node_modules'),
|
||||
];
|
||||
|
||||
// Enable symlinks for shared library
|
||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
// Handle @pezkuwi/* imports (shared library)
|
||||
if (moduleName.startsWith('@pezkuwi/')) {
|
||||
const sharedPath = moduleName.replace('@pezkuwi/', '');
|
||||
const sharedDir = path.resolve(workspaceRoot, 'shared', sharedPath);
|
||||
|
||||
// Try .ts extension first, then .tsx, then .js
|
||||
const extensions = ['.ts', '.tsx', '.js', '.json'];
|
||||
for (const ext of extensions) {
|
||||
const filePath = sharedDir + ext;
|
||||
if (require('fs').existsSync(filePath)) {
|
||||
return {
|
||||
filePath,
|
||||
type: 'sourceFile',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try index files
|
||||
for (const ext of extensions) {
|
||||
const indexPath = path.join(sharedDir, `index${ext}`);
|
||||
if (require('fs').existsSync(indexPath)) {
|
||||
return {
|
||||
filePath: indexPath,
|
||||
type: 'sourceFile',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the default resolver
|
||||
return context.resolveRequest(context, moduleName, platform);
|
||||
};
|
||||
|
||||
// Ensure all file extensions are resolved
|
||||
config.resolver.sourceExts = [
|
||||
'expo.ts',
|
||||
'expo.tsx',
|
||||
'expo.js',
|
||||
'expo.jsx',
|
||||
'ts',
|
||||
'tsx',
|
||||
'js',
|
||||
'jsx',
|
||||
'json',
|
||||
'wasm',
|
||||
'svg',
|
||||
];
|
||||
|
||||
module.exports = config;
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
# Wrapper to run node with Yarn PnP support
|
||||
cd /home/mamostehp/pwap/mobile
|
||||
exec yarn node "$@"
|
||||
Generated
-18585
File diff suppressed because it is too large
Load Diff
+63
-10
@@ -5,8 +5,24 @@
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"dev": "cp .env.development .env && expo start --clear",
|
||||
"dev:tunnel": "cp .env.development .env && expo start --tunnel --clear",
|
||||
"dev:android": "cp .env.development .env && expo run:android",
|
||||
"dev:ios": "cp .env.development .env && expo run:ios",
|
||||
"dev:web": "cp .env.development .env && expo start --web",
|
||||
"prod": "cp .env.production .env && expo start --no-dev --minify",
|
||||
"build:dev": "cp .env.development .env && eas build --profile development",
|
||||
"build:preview": "cp .env.production .env && eas build --profile preview",
|
||||
"build:prod": "cp .env.production .env && eas build --profile production",
|
||||
"build:en": "EXPO_PUBLIC_DEFAULT_LANGUAGE=en eas build --platform android --profile production",
|
||||
"build:tr": "EXPO_PUBLIC_DEFAULT_LANGUAGE=tr eas build --platform android --profile production",
|
||||
"build:kmr": "EXPO_PUBLIC_DEFAULT_LANGUAGE=kmr eas build --platform android --profile production",
|
||||
"build:ckb": "EXPO_PUBLIC_DEFAULT_LANGUAGE=ckb eas build --platform android --profile production",
|
||||
"build:ar": "EXPO_PUBLIC_DEFAULT_LANGUAGE=ar eas build --platform android --profile production",
|
||||
"build:fa": "EXPO_PUBLIC_DEFAULT_LANGUAGE=fa eas build --platform android --profile production",
|
||||
"build:all": "npm run build:en && npm run build:tr && npm run build:kmr && npm run build:ckb && npm run build:ar && npm run build:fa",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -15,36 +31,73 @@
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pezkuwi/api": "^16.5.2",
|
||||
"@pezkuwi/keyring": "^14.0.5",
|
||||
"@pezkuwi/util": "^14.0.5",
|
||||
"@pezkuwi/util-crypto": "^14.0.5",
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@pezkuwi/api": "16.5.9",
|
||||
"@pezkuwi/keyring": "14.0.11",
|
||||
"@pezkuwi/types": "16.5.9",
|
||||
"@pezkuwi/util": "14.0.11",
|
||||
"@pezkuwi/util-crypto": "14.0.11",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-native-picker/picker": "^2.9.2",
|
||||
"@react-navigation/bottom-tabs": "^7.8.5",
|
||||
"@react-navigation/native": "^7.1.20",
|
||||
"@react-navigation/stack": "^7.6.4",
|
||||
"@supabase/supabase-js": "^2.90.1",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "~54.0.23",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-linear-gradient": "^15.0.7",
|
||||
"expo-local-authentication": "^17.0.7",
|
||||
"expo-secure-store": "^15.0.7",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"i18next": "^25.6.2",
|
||||
"invariant": "^2.2.4",
|
||||
"metro-runtime": "^0.81.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "^16.3.3",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-get-random-values": "~1.11.0",
|
||||
"react-native-qrcode-svg": "^6.3.11",
|
||||
"react-native-safe-area-context": "^5.6.2",
|
||||
"react-native-screens": "^4.18.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-url-polyfill": "^3.0.0",
|
||||
"react-native-vector-icons": "^10.3.0"
|
||||
"react-native-vector-icons": "^10.3.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webview": "13.15.0",
|
||||
"react-refresh": "^0.14.2",
|
||||
"text-encoding": "^0.7.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@pezkuwi/api-augment": "16.5.9",
|
||||
"@pezkuwi/api-base": "16.5.9",
|
||||
"@pezkuwi/api-derive": "16.5.9",
|
||||
"@pezkuwi/rpc-augment": "16.5.9",
|
||||
"@pezkuwi/rpc-core": "16.5.9",
|
||||
"@pezkuwi/rpc-provider": "16.5.9",
|
||||
"@pezkuwi/types": "16.5.9",
|
||||
"@pezkuwi/types-augment": "16.5.9",
|
||||
"@pezkuwi/types-codec": "16.5.9",
|
||||
"@pezkuwi/types-create": "16.5.9",
|
||||
"@pezkuwi/types-known": "16.5.9",
|
||||
"@pezkuwi/networks": "14.0.11",
|
||||
"@pezkuwi/keyring": "14.0.11",
|
||||
"@pezkuwi/util": "14.0.11",
|
||||
"@pezkuwi/util-crypto": "14.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@expo/ngrok": "^4.1.0",
|
||||
"@testing-library/jest-native": "^5.4.3",
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/invariant": "^2",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/react": "~19.1.0",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/text-encoding": "^0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.47.0",
|
||||
"@typescript-eslint/parser": "^8.47.0",
|
||||
"babel-preset-expo": "~54.0.9",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Supabase Database Connection
|
||||
DB_HOST="db.vsyrpfiwhjvahofxwytr.supabase.co"
|
||||
DB_PORT="5432"
|
||||
DB_NAME="postgres"
|
||||
DB_USER="postgres"
|
||||
DB_PASS="SqM210305yBkB@#nm90"
|
||||
|
||||
CONNECTION_STRING="postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
||||
|
||||
echo "======================================================================"
|
||||
echo "🚀 Executing SQL Scripts on Supabase"
|
||||
echo "======================================================================"
|
||||
|
||||
# Check if psql is available
|
||||
if ! command -v psql &> /dev/null; then
|
||||
echo ""
|
||||
echo "❌ psql not found. Installing PostgreSQL client..."
|
||||
echo ""
|
||||
|
||||
# Try to install psql
|
||||
if command -v apt-get &> /dev/null; then
|
||||
sudo apt-get update && sudo apt-get install -y postgresql-client
|
||||
elif command -v yum &> /dev/null; then
|
||||
sudo yum install -y postgresql
|
||||
else
|
||||
echo "❌ Cannot install PostgreSQL client automatically"
|
||||
echo ""
|
||||
echo "📋 Manual Setup Required:"
|
||||
echo " Go to: https://app.supabase.com/project/vsyrpfiwhjvahofxwytr/sql"
|
||||
echo " Run the SQL from: QUICK_SETUP_GUIDE.md"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📝 Step 1: Fixing schema compatibility..."
|
||||
echo "======================================================================"
|
||||
|
||||
psql "${CONNECTION_STRING}" -f FIX_SCHEMA_COMPATIBILITY.sql
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Schema compatibility fix completed!"
|
||||
else
|
||||
echo "❌ Error in schema fix"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📝 Step 2: Creating missing tables..."
|
||||
echo "======================================================================"
|
||||
|
||||
psql "${CONNECTION_STRING}" -f SUPABASE_SCHEMA.sql
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Table creation completed!"
|
||||
else
|
||||
echo "❌ Error in table creation"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "======================================================================"
|
||||
echo "🎉 ALL SQL SCRIPTS EXECUTED SUCCESSFULLY!"
|
||||
echo "======================================================================"
|
||||
echo ""
|
||||
echo "📝 Next step: Verify schema compatibility"
|
||||
echo " node check_schema_compatibility.cjs"
|
||||
echo ""
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Governance Integration Tests
|
||||
*
|
||||
* End-to-end tests for governance features
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, waitFor, fireEvent } from '@testing-library/react-native';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import TreasuryScreen from '../../screens/governance/TreasuryScreen';
|
||||
import ProposalsScreen from '../../screens/governance/ProposalsScreen';
|
||||
import ElectionsScreen from '../../screens/governance/ElectionsScreen';
|
||||
import { PezkuwiProvider } from '../../contexts/PezkuwiContext';
|
||||
import { ApiPromise, WsProvider } from '@polkadot/api';
|
||||
|
||||
// Integration tests use real blockchain connection
|
||||
describe('Governance Integration Tests', () => {
|
||||
let api: ApiPromise;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Connect to local zombinet
|
||||
const wsProvider = new WsProvider('ws://127.0.0.1:9944');
|
||||
api = await ApiPromise.create({ provider: wsProvider });
|
||||
}, 30000); // 30 second timeout for blockchain connection
|
||||
|
||||
afterAll(async () => {
|
||||
await api?.disconnect();
|
||||
});
|
||||
|
||||
describe('Treasury Integration', () => {
|
||||
it('should fetch real treasury balance from blockchain', async () => {
|
||||
const { getByText } = render(
|
||||
<NavigationContainer>
|
||||
<TreasuryScreen />
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
// Wait for blockchain data to load
|
||||
await waitFor(
|
||||
() => {
|
||||
// Treasury balance should be displayed (even if 0)
|
||||
expect(getByText(/HEZ/i)).toBeTruthy();
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle real blockchain connection errors', async () => {
|
||||
// Temporarily disconnect
|
||||
await api.disconnect();
|
||||
|
||||
const { getByText } = render(
|
||||
<NavigationContainer>
|
||||
<TreasuryScreen />
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show error or empty state
|
||||
expect(
|
||||
getByText(/No proposals found/i) || getByText(/Error/i)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
// Reconnect for other tests
|
||||
const wsProvider = new WsProvider('ws://127.0.0.1:9944');
|
||||
api = await ApiPromise.create({ provider: wsProvider });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Proposals Integration', () => {
|
||||
it('should fetch real referenda from democracy pallet', async () => {
|
||||
const { getByText, queryByText } = render(
|
||||
<NavigationContainer>
|
||||
<ProposalsScreen />
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
// Should either show referenda or empty state
|
||||
expect(
|
||||
queryByText(/Referendum/i) || queryByText(/No proposals found/i)
|
||||
).toBeTruthy();
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should display real vote counts', async () => {
|
||||
const referenda = await api.query.democracy.referendumInfoOf.entries();
|
||||
|
||||
if (referenda.length > 0) {
|
||||
const { getByText } = render(
|
||||
<NavigationContainer>
|
||||
<ProposalsScreen />
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
// Should show vote percentages
|
||||
expect(getByText(/%/)).toBeTruthy();
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Elections Integration', () => {
|
||||
it('should fetch real commission proposals', async () => {
|
||||
const { queryByText } = render(
|
||||
<NavigationContainer>
|
||||
<ElectionsScreen />
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
// Should either show elections or empty state
|
||||
expect(
|
||||
queryByText(/Election/i) || queryByText(/No elections available/i)
|
||||
).toBeTruthy();
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cross-Feature Integration', () => {
|
||||
it('should maintain blockchain connection across screens', async () => {
|
||||
// Test that API connection is shared
|
||||
const treasuryBalance = await api.query.treasury?.treasury();
|
||||
const referenda = await api.query.democracy.referendumInfoOf.entries();
|
||||
const proposals = await api.query.dynamicCommissionCollective.proposals();
|
||||
|
||||
// All queries should succeed without creating new connections
|
||||
expect(treasuryBalance).toBeDefined();
|
||||
expect(referenda).toBeDefined();
|
||||
expect(proposals).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle simultaneous data fetching', async () => {
|
||||
// Render all governance screens at once
|
||||
const treasury = render(
|
||||
<NavigationContainer>
|
||||
<TreasuryScreen />
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
const proposals = render(
|
||||
<NavigationContainer>
|
||||
<ProposalsScreen />
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
const elections = render(
|
||||
<NavigationContainer>
|
||||
<ElectionsScreen />
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
// All should load without conflicts
|
||||
await Promise.all([
|
||||
waitFor(() => expect(treasury.queryByText(/Treasury/i)).toBeTruthy(), {
|
||||
timeout: 10000,
|
||||
}),
|
||||
waitFor(() => expect(proposals.queryByText(/Proposals/i)).toBeTruthy(), {
|
||||
timeout: 10000,
|
||||
}),
|
||||
waitFor(() => expect(elections.queryByText(/Elections/i)).toBeTruthy(), {
|
||||
timeout: 10000,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-time Updates', () => {
|
||||
it('should receive blockchain updates', async () => {
|
||||
const { rerender } = render(
|
||||
<NavigationContainer>
|
||||
<TreasuryScreen />
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
// Subscribe to balance changes
|
||||
const unsubscribe = await api.query.treasury.treasury((balance: any) => {
|
||||
// Balance updates should trigger rerender
|
||||
rerender(
|
||||
<NavigationContainer>
|
||||
<TreasuryScreen />
|
||||
</NavigationContainer>
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for subscription to be active
|
||||
await waitFor(() => {
|
||||
expect(unsubscribe).toBeDefined();
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe('Performance', () => {
|
||||
it('should load treasury data within 5 seconds', async () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const { getByText } = render(
|
||||
<NavigationContainer>
|
||||
<TreasuryScreen />
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(/Treasury/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
const loadTime = Date.now() - startTime;
|
||||
expect(loadTime).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
it('should handle rapid screen transitions', async () => {
|
||||
const screens = [TreasuryScreen, ProposalsScreen, ElectionsScreen];
|
||||
|
||||
for (const Screen of screens) {
|
||||
const { unmount } = render(
|
||||
<NavigationContainer>
|
||||
<Screen />
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Screen should render
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
// Quickly unmount and move to next screen
|
||||
unmount();
|
||||
}
|
||||
|
||||
// No memory leaks or crashes
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { render, RenderOptions } from '@testing-library/react-native';
|
||||
|
||||
// Mock all contexts with simple implementations
|
||||
const MockAuthProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
const MockPolkadotProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
const MockPezkuwiProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
const MockLanguageProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
const MockBiometricAuthProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
|
||||
@@ -11,13 +11,13 @@ const MockBiometricAuthProvider = ({ children }: { children: React.ReactNode })
|
||||
const AllTheProviders = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<MockAuthProvider>
|
||||
<MockPolkadotProvider>
|
||||
<MockPezkuwiProvider>
|
||||
<MockLanguageProvider>
|
||||
<MockBiometricAuthProvider>
|
||||
{children}
|
||||
</MockBiometricAuthProvider>
|
||||
</MockLanguageProvider>
|
||||
</MockPolkadotProvider>
|
||||
</MockPezkuwiProvider>
|
||||
</MockAuthProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Image,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
// Avatar pool - Kurdish/Middle Eastern themed avatars
|
||||
const AVATAR_POOL = [
|
||||
{ id: 'avatar1', emoji: '👨🏻', label: 'Man 1' },
|
||||
{ id: 'avatar2', emoji: '👨🏼', label: 'Man 2' },
|
||||
{ id: 'avatar3', emoji: '👨🏽', label: 'Man 3' },
|
||||
{ id: 'avatar4', emoji: '👨🏾', label: 'Man 4' },
|
||||
{ id: 'avatar5', emoji: '👩🏻', label: 'Woman 1' },
|
||||
{ id: 'avatar6', emoji: '👩🏼', label: 'Woman 2' },
|
||||
{ id: 'avatar7', emoji: '👩🏽', label: 'Woman 3' },
|
||||
{ id: 'avatar8', emoji: '👩🏾', label: 'Woman 4' },
|
||||
{ id: 'avatar9', emoji: '🧔🏻', label: 'Beard 1' },
|
||||
{ id: 'avatar10', emoji: '🧔🏼', label: 'Beard 2' },
|
||||
{ id: 'avatar11', emoji: '🧔🏽', label: 'Beard 3' },
|
||||
{ id: 'avatar12', emoji: '🧔🏾', label: 'Beard 4' },
|
||||
{ id: 'avatar13', emoji: '👳🏻♂️', label: 'Turban 1' },
|
||||
{ id: 'avatar14', emoji: '👳🏼♂️', label: 'Turban 2' },
|
||||
{ id: 'avatar15', emoji: '👳🏽♂️', label: 'Turban 3' },
|
||||
{ id: 'avatar16', emoji: '🧕🏻', label: 'Hijab 1' },
|
||||
{ id: 'avatar17', emoji: '🧕🏼', label: 'Hijab 2' },
|
||||
{ id: 'avatar18', emoji: '🧕🏽', label: 'Hijab 3' },
|
||||
{ id: 'avatar19', emoji: '👴🏻', label: 'Elder 1' },
|
||||
{ id: 'avatar20', emoji: '👴🏼', label: 'Elder 2' },
|
||||
{ id: 'avatar21', emoji: '👵🏻', label: 'Elder Woman 1' },
|
||||
{ id: 'avatar22', emoji: '👵🏼', label: 'Elder Woman 2' },
|
||||
{ id: 'avatar23', emoji: '👦🏻', label: 'Boy 1' },
|
||||
{ id: 'avatar24', emoji: '👦🏼', label: 'Boy 2' },
|
||||
{ id: 'avatar25', emoji: '👧🏻', label: 'Girl 1' },
|
||||
{ id: 'avatar26', emoji: '👧🏼', label: 'Girl 2' },
|
||||
];
|
||||
|
||||
interface AvatarPickerModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
currentAvatar?: string;
|
||||
onAvatarSelected?: (avatarUrl: string) => void;
|
||||
}
|
||||
|
||||
const AvatarPickerModal: React.FC<AvatarPickerModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
currentAvatar,
|
||||
onAvatarSelected,
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const [selectedAvatar, setSelectedAvatar] = useState<string | null>(currentAvatar || null);
|
||||
const [uploadedImageUri, setUploadedImageUri] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleAvatarSelect = (avatarId: string) => {
|
||||
setSelectedAvatar(avatarId);
|
||||
setUploadedImageUri(null); // Clear uploaded image when selecting from pool
|
||||
};
|
||||
|
||||
const requestPermissions = async () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert(
|
||||
'Permission Required',
|
||||
'Sorry, we need camera roll permissions to upload your photo!'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handlePickImage = async () => {
|
||||
const hasPermission = await requestPermissions();
|
||||
if (!hasPermission) return;
|
||||
|
||||
try {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: 'images',
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
setIsUploading(true);
|
||||
const imageUri = result.assets[0].uri;
|
||||
|
||||
if (__DEV__) console.log('[AvatarPicker] Uploading image:', imageUri);
|
||||
|
||||
// Upload to Supabase Storage
|
||||
const uploadedUrl = await uploadImageToSupabase(imageUri);
|
||||
|
||||
setIsUploading(false);
|
||||
|
||||
if (uploadedUrl) {
|
||||
if (__DEV__) console.log('[AvatarPicker] Upload successful:', uploadedUrl);
|
||||
setUploadedImageUri(uploadedUrl);
|
||||
setSelectedAvatar(null); // Clear emoji selection
|
||||
Alert.alert('Success', 'Photo uploaded successfully!');
|
||||
} else {
|
||||
if (__DEV__) console.error('[AvatarPicker] Upload failed: no URL returned');
|
||||
Alert.alert('Upload Failed', 'Could not upload your photo. Please check your internet connection and try again.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setIsUploading(false);
|
||||
if (__DEV__) console.error('[AvatarPicker] Error picking image:', error);
|
||||
Alert.alert('Error', 'Failed to pick image. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const uploadImageToSupabase = async (imageUri: string): Promise<string | null> => {
|
||||
if (!user) {
|
||||
if (__DEV__) console.error('[AvatarPicker] No user found');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (__DEV__) console.log('[AvatarPicker] Fetching image blob...');
|
||||
// Convert image URI to blob for web, or use file for native
|
||||
const response = await fetch(imageUri);
|
||||
const blob = await response.blob();
|
||||
if (__DEV__) console.log('[AvatarPicker] Blob size:', blob.size, 'bytes');
|
||||
|
||||
// Generate unique filename
|
||||
const fileExt = imageUri.split('.').pop()?.toLowerCase() || 'jpg';
|
||||
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
|
||||
const filePath = `avatars/${fileName}`;
|
||||
if (__DEV__) console.log('[AvatarPicker] Uploading to:', filePath);
|
||||
|
||||
// Upload to Supabase Storage
|
||||
const { data: uploadData, error: uploadError } = await supabase.storage
|
||||
.from('profiles')
|
||||
.upload(filePath, blob, {
|
||||
contentType: `image/${fileExt}`,
|
||||
upsert: false,
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
if (__DEV__) console.error('[AvatarPicker] Upload error:', uploadError);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (__DEV__) console.log('[AvatarPicker] Upload successful:', uploadData);
|
||||
|
||||
// Get public URL
|
||||
const { data } = supabase.storage
|
||||
.from('profiles')
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
if (__DEV__) console.log('[AvatarPicker] Public URL:', data.publicUrl);
|
||||
|
||||
return data.publicUrl;
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('[AvatarPicker] Error uploading to Supabase:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const avatarToSave = uploadedImageUri || selectedAvatar;
|
||||
|
||||
if (!avatarToSave || !user) {
|
||||
Alert.alert('Error', 'Please select an avatar or upload a photo');
|
||||
return;
|
||||
}
|
||||
|
||||
if (__DEV__) console.log('[AvatarPicker] Saving avatar:', avatarToSave);
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
// Update avatar in Supabase profiles table
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.update({ avatar_url: avatarToSave })
|
||||
.eq('id', user.id)
|
||||
.select();
|
||||
|
||||
if (error) {
|
||||
if (__DEV__) console.error('[AvatarPicker] Save error:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (__DEV__) console.log('[AvatarPicker] Avatar saved successfully:', data);
|
||||
|
||||
Alert.alert('Success', 'Avatar updated successfully!');
|
||||
|
||||
if (onAvatarSelected) {
|
||||
onAvatarSelected(avatarToSave);
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('[AvatarPicker] Error updating avatar:', error);
|
||||
Alert.alert('Error', 'Failed to update avatar. Please try again.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContainer}>
|
||||
{/* Header */}
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Choose Your Avatar</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<Text style={styles.closeButtonText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Upload Photo Button */}
|
||||
<View style={styles.uploadSection}>
|
||||
<TouchableOpacity
|
||||
style={[styles.uploadButton, isUploading && styles.uploadButtonDisabled]}
|
||||
onPress={handlePickImage}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<ActivityIndicator color={KurdistanColors.spi} size="small" />
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.uploadButtonIcon}>📷</Text>
|
||||
<Text style={styles.uploadButtonText}>Upload Your Photo</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Uploaded Image Preview */}
|
||||
{uploadedImageUri && (
|
||||
<View style={styles.uploadedPreview}>
|
||||
<Image source={{ uri: uploadedImageUri }} style={styles.uploadedImage} />
|
||||
<TouchableOpacity
|
||||
style={styles.removeUploadButton}
|
||||
onPress={() => setUploadedImageUri(null)}
|
||||
>
|
||||
<Text style={styles.removeUploadText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Divider */}
|
||||
<View style={styles.divider}>
|
||||
<View style={styles.dividerLine} />
|
||||
<Text style={styles.dividerText}>OR CHOOSE FROM POOL</Text>
|
||||
<View style={styles.dividerLine} />
|
||||
</View>
|
||||
|
||||
{/* Avatar Grid */}
|
||||
<ScrollView style={styles.avatarScroll} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.avatarGrid}>
|
||||
{AVATAR_POOL.map((avatar) => (
|
||||
<TouchableOpacity
|
||||
key={avatar.id}
|
||||
style={[
|
||||
styles.avatarOption,
|
||||
selectedAvatar === avatar.id && styles.avatarOptionSelected,
|
||||
]}
|
||||
onPress={() => handleAvatarSelect(avatar.id)}
|
||||
>
|
||||
<Text style={styles.avatarEmoji}>{avatar.emoji}</Text>
|
||||
{selectedAvatar === avatar.id && (
|
||||
<View style={styles.selectedBadge}>
|
||||
<Text style={styles.selectedBadgeText}>✓</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<View style={styles.modalFooter}>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={onClose}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, isSaving && styles.saveButtonDisabled]}
|
||||
onPress={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<ActivityIndicator color={KurdistanColors.spi} size="small" />
|
||||
) : (
|
||||
<Text style={styles.saveButtonText}>Save Avatar</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContainer: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
maxHeight: '80%',
|
||||
paddingBottom: 20,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E0E0E0',
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
closeButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F0F0F0',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
closeButtonText: {
|
||||
fontSize: 18,
|
||||
color: '#666',
|
||||
},
|
||||
avatarScroll: {
|
||||
padding: 20,
|
||||
},
|
||||
avatarGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
avatarOption: {
|
||||
width: '22%',
|
||||
aspectRatio: 1,
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
borderWidth: 3,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
avatarOptionSelected: {
|
||||
borderColor: KurdistanColors.kesk,
|
||||
backgroundColor: '#E8F5E9',
|
||||
},
|
||||
avatarEmoji: {
|
||||
fontSize: 36,
|
||||
},
|
||||
selectedBadge: {
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: -4,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
selectedBadgeText: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.spi,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
modalFooter: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 16,
|
||||
gap: 12,
|
||||
},
|
||||
cancelButton: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#F0F0F0',
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
},
|
||||
saveButton: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
alignItems: 'center',
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
saveButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
},
|
||||
uploadSection: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E0E0E0',
|
||||
},
|
||||
uploadButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 12,
|
||||
gap: 8,
|
||||
},
|
||||
uploadButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
uploadButtonIcon: {
|
||||
fontSize: 20,
|
||||
},
|
||||
uploadButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.spi,
|
||||
},
|
||||
uploadedPreview: {
|
||||
marginTop: 12,
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
},
|
||||
uploadedImage: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
borderWidth: 3,
|
||||
borderColor: KurdistanColors.kesk,
|
||||
},
|
||||
removeUploadButton: {
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: '38%',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: KurdistanColors.sor,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
boxShadow: '0px 2px 3px rgba(0, 0, 0, 0.3)',
|
||||
elevation: 4,
|
||||
},
|
||||
removeUploadText: {
|
||||
color: KurdistanColors.spi,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
divider: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: '#E0E0E0',
|
||||
},
|
||||
dividerText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: '#999',
|
||||
marginHorizontal: 12,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
export default AvatarPickerModal;
|
||||
@@ -0,0 +1,515 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Image,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
// Avatar pool - Kurdish/Middle Eastern themed avatars
|
||||
const AVATAR_POOL = [
|
||||
{ id: 'avatar1', emoji: '👨🏻', label: 'Man 1' },
|
||||
{ id: 'avatar2', emoji: '👨🏼', label: 'Man 2' },
|
||||
{ id: 'avatar3', emoji: '👨🏽', label: 'Man 3' },
|
||||
{ id: 'avatar4', emoji: '👨🏾', label: 'Man 4' },
|
||||
{ id: 'avatar5', emoji: '👩🏻', label: 'Woman 1' },
|
||||
{ id: 'avatar6', emoji: '👩🏼', label: 'Woman 2' },
|
||||
{ id: 'avatar7', emoji: '👩🏽', label: 'Woman 3' },
|
||||
{ id: 'avatar8', emoji: '👩🏾', label: 'Woman 4' },
|
||||
{ id: 'avatar9', emoji: '🧔🏻', label: 'Beard 1' },
|
||||
{ id: 'avatar10', emoji: '🧔🏼', label: 'Beard 2' },
|
||||
{ id: 'avatar11', emoji: '🧔🏽', label: 'Beard 3' },
|
||||
{ id: 'avatar12', emoji: '🧔🏾', label: 'Beard 4' },
|
||||
{ id: 'avatar13', emoji: '👳🏻♂️', label: 'Turban 1' },
|
||||
{ id: 'avatar14', emoji: '👳🏼♂️', label: 'Turban 2' },
|
||||
{ id: 'avatar15', emoji: '👳🏽♂️', label: 'Turban 3' },
|
||||
{ id: 'avatar16', emoji: '🧕🏻', label: 'Hijab 1' },
|
||||
{ id: 'avatar17', emoji: '🧕🏼', label: 'Hijab 2' },
|
||||
{ id: 'avatar18', emoji: '🧕🏽', label: 'Hijab 3' },
|
||||
{ id: 'avatar19', emoji: '👴🏻', label: 'Elder 1' },
|
||||
{ id: 'avatar20', emoji: '👴🏼', label: 'Elder 2' },
|
||||
{ id: 'avatar21', emoji: '👵🏻', label: 'Elder Woman 1' },
|
||||
{ id: 'avatar22', emoji: '👵🏼', label: 'Elder Woman 2' },
|
||||
{ id: 'avatar23', emoji: '👦🏻', label: 'Boy 1' },
|
||||
{ id: 'avatar24', emoji: '👦🏼', label: 'Boy 2' },
|
||||
{ id: 'avatar25', emoji: '👧🏻', label: 'Girl 1' },
|
||||
{ id: 'avatar26', emoji: '👧🏼', label: 'Girl 2' },
|
||||
];
|
||||
|
||||
interface AvatarPickerModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
currentAvatar?: string;
|
||||
onAvatarSelected?: (avatarUrl: string) => void;
|
||||
}
|
||||
|
||||
const AvatarPickerModal: React.FC<AvatarPickerModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
currentAvatar,
|
||||
onAvatarSelected,
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const [selectedAvatar, setSelectedAvatar] = useState<string | null>(currentAvatar || null);
|
||||
const [uploadedImageUri, setUploadedImageUri] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleAvatarSelect = (avatarId: string) => {
|
||||
setSelectedAvatar(avatarId);
|
||||
setUploadedImageUri(null); // Clear uploaded image when selecting from pool
|
||||
};
|
||||
|
||||
const requestPermissions = async () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert(
|
||||
'Permission Required',
|
||||
'Sorry, we need camera roll permissions to upload your photo!'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handlePickImage = async () => {
|
||||
const hasPermission = await requestPermissions();
|
||||
if (!hasPermission) return;
|
||||
|
||||
try {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: 'images',
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
setIsUploading(true);
|
||||
const imageUri = result.assets[0].uri;
|
||||
|
||||
if (__DEV__) console.log('[AvatarPicker] Uploading image:', imageUri);
|
||||
|
||||
// Upload to Supabase Storage
|
||||
const uploadedUrl = await uploadImageToSupabase(imageUri);
|
||||
|
||||
setIsUploading(false);
|
||||
|
||||
if (uploadedUrl) {
|
||||
if (__DEV__) console.log('[AvatarPicker] Upload successful:', uploadedUrl);
|
||||
setUploadedImageUri(uploadedUrl);
|
||||
setSelectedAvatar(null); // Clear emoji selection
|
||||
Alert.alert('Success', 'Photo uploaded successfully!');
|
||||
} else {
|
||||
if (__DEV__) console.error('[AvatarPicker] Upload failed: no URL returned');
|
||||
Alert.alert('Upload Failed', 'Could not upload your photo. Please check your internet connection and try again.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setIsUploading(false);
|
||||
if (__DEV__) console.error('[AvatarPicker] Error picking image:', error);
|
||||
Alert.alert('Error', 'Failed to pick image. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const uploadImageToSupabase = async (imageUri: string): Promise<string | null> => {
|
||||
if (!user) {
|
||||
if (__DEV__) console.error('[AvatarPicker] No user found');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (__DEV__) console.log('[AvatarPicker] Fetching image blob...');
|
||||
// Convert image URI to blob for web, or use file for native
|
||||
const response = await fetch(imageUri);
|
||||
const blob = await response.blob();
|
||||
if (__DEV__) console.log('[AvatarPicker] Blob size:', blob.size, 'bytes');
|
||||
|
||||
// Generate unique filename
|
||||
const fileExt = imageUri.split('.').pop()?.toLowerCase() || 'jpg';
|
||||
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
|
||||
const filePath = `avatars/${fileName}`;
|
||||
if (__DEV__) console.log('[AvatarPicker] Uploading to:', filePath);
|
||||
|
||||
// Upload to Supabase Storage
|
||||
const { data: uploadData, error: uploadError } = await supabase.storage
|
||||
.from('profiles')
|
||||
.upload(filePath, blob, {
|
||||
contentType: `image/${fileExt}`,
|
||||
upsert: false,
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
if (__DEV__) console.error('[AvatarPicker] Upload error:', uploadError);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (__DEV__) console.log('[AvatarPicker] Upload successful:', uploadData);
|
||||
|
||||
// Get public URL
|
||||
const { data } = supabase.storage
|
||||
.from('profiles')
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
if (__DEV__) console.log('[AvatarPicker] Public URL:', data.publicUrl);
|
||||
|
||||
return data.publicUrl;
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('[AvatarPicker] Error uploading to Supabase:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const avatarToSave = uploadedImageUri || selectedAvatar;
|
||||
|
||||
if (!avatarToSave || !user) {
|
||||
Alert.alert('Error', 'Please select an avatar or upload a photo');
|
||||
return;
|
||||
}
|
||||
|
||||
if (__DEV__) console.log('[AvatarPicker] Saving avatar:', avatarToSave);
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
// Update avatar in Supabase profiles table
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.update({ avatar_url: avatarToSave })
|
||||
.eq('id', user.id)
|
||||
.select();
|
||||
|
||||
if (error) {
|
||||
if (__DEV__) console.error('[AvatarPicker] Save error:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (__DEV__) console.log('[AvatarPicker] Avatar saved successfully:', data);
|
||||
|
||||
Alert.alert('Success', 'Avatar updated successfully!');
|
||||
|
||||
if (onAvatarSelected) {
|
||||
onAvatarSelected(avatarToSave);
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('[AvatarPicker] Error updating avatar:', error);
|
||||
Alert.alert('Error', 'Failed to update avatar. Please try again.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContainer}>
|
||||
{/* Header */}
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Choose Your Avatar</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<Text style={styles.closeButtonText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Upload Photo Button */}
|
||||
<View style={styles.uploadSection}>
|
||||
<TouchableOpacity
|
||||
style={[styles.uploadButton, isUploading && styles.uploadButtonDisabled]}
|
||||
onPress={handlePickImage}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<ActivityIndicator color={KurdistanColors.spi} size="small" />
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.uploadButtonIcon}>📷</Text>
|
||||
<Text style={styles.uploadButtonText}>Upload Your Photo</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Uploaded Image Preview */}
|
||||
{uploadedImageUri && (
|
||||
<View style={styles.uploadedPreview}>
|
||||
<Image source={{ uri: uploadedImageUri }} style={styles.uploadedImage} />
|
||||
<TouchableOpacity
|
||||
style={styles.removeUploadButton}
|
||||
onPress={() => setUploadedImageUri(null)}
|
||||
>
|
||||
<Text style={styles.removeUploadText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Divider */}
|
||||
<View style={styles.divider}>
|
||||
<View style={styles.dividerLine} />
|
||||
<Text style={styles.dividerText}>OR CHOOSE FROM POOL</Text>
|
||||
<View style={styles.dividerLine} />
|
||||
</View>
|
||||
|
||||
{/* Avatar Grid */}
|
||||
<ScrollView style={styles.avatarScroll} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.avatarGrid}>
|
||||
{AVATAR_POOL.map((avatar) => (
|
||||
<TouchableOpacity
|
||||
key={avatar.id}
|
||||
style={[
|
||||
styles.avatarOption,
|
||||
selectedAvatar === avatar.id && styles.avatarOptionSelected,
|
||||
]}
|
||||
onPress={() => handleAvatarSelect(avatar.id)}
|
||||
>
|
||||
<Text style={styles.avatarEmoji}>{avatar.emoji}</Text>
|
||||
{selectedAvatar === avatar.id && (
|
||||
<View style={styles.selectedBadge}>
|
||||
<Text style={styles.selectedBadgeText}>✓</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<View style={styles.modalFooter}>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={onClose}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, isSaving && styles.saveButtonDisabled]}
|
||||
onPress={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<ActivityIndicator color={KurdistanColors.spi} size="small" />
|
||||
) : (
|
||||
<Text style={styles.saveButtonText}>Save Avatar</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContainer: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
maxHeight: '80%',
|
||||
paddingBottom: 20,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E0E0E0',
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
closeButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F0F0F0',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
closeButtonText: {
|
||||
fontSize: 18,
|
||||
color: '#666',
|
||||
},
|
||||
avatarScroll: {
|
||||
padding: 20,
|
||||
},
|
||||
avatarGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
avatarOption: {
|
||||
width: '22%',
|
||||
aspectRatio: 1,
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
borderWidth: 3,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
avatarOptionSelected: {
|
||||
borderColor: KurdistanColors.kesk,
|
||||
backgroundColor: '#E8F5E9',
|
||||
},
|
||||
avatarEmoji: {
|
||||
fontSize: 36,
|
||||
},
|
||||
selectedBadge: {
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: -4,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
selectedBadgeText: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.spi,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
modalFooter: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 16,
|
||||
gap: 12,
|
||||
},
|
||||
cancelButton: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#F0F0F0',
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
},
|
||||
saveButton: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
alignItems: 'center',
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
saveButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
},
|
||||
uploadSection: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E0E0E0',
|
||||
},
|
||||
uploadButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 12,
|
||||
gap: 8,
|
||||
},
|
||||
uploadButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
uploadButtonIcon: {
|
||||
fontSize: 20,
|
||||
},
|
||||
uploadButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.spi,
|
||||
},
|
||||
uploadedPreview: {
|
||||
marginTop: 12,
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
},
|
||||
uploadedImage: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
borderWidth: 3,
|
||||
borderColor: KurdistanColors.kesk,
|
||||
},
|
||||
removeUploadButton: {
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: '38%',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: KurdistanColors.sor,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 3,
|
||||
elevation: 4,
|
||||
},
|
||||
removeUploadText: {
|
||||
color: KurdistanColors.spi,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
divider: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: '#E0E0E0',
|
||||
},
|
||||
dividerText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: '#999',
|
||||
marginHorizontal: 12,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
export default AvatarPickerModal;
|
||||
@@ -66,10 +66,7 @@ const styles = StyleSheet.create({
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
elevation: 3,
|
||||
},
|
||||
row: {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { TokenIcon } from './TokenIcon';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
|
||||
interface BalanceCardProps {
|
||||
symbol: string;
|
||||
name: string;
|
||||
balance: string;
|
||||
value?: string;
|
||||
change?: string;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
export const BalanceCard: React.FC<BalanceCardProps> = ({
|
||||
symbol,
|
||||
name,
|
||||
balance,
|
||||
value,
|
||||
change,
|
||||
onPress,
|
||||
}) => {
|
||||
const changeValue = parseFloat(change || '0');
|
||||
const isPositive = changeValue >= 0;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.container}
|
||||
onPress={onPress}
|
||||
disabled={!onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.row}>
|
||||
<TokenIcon symbol={symbol} size={40} />
|
||||
<View style={styles.info}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.symbol}>{symbol}</Text>
|
||||
<Text style={styles.balance}>{balance}</Text>
|
||||
</View>
|
||||
<View style={styles.detailsRow}>
|
||||
<Text style={styles.name}>{name}</Text>
|
||||
{value && <Text style={styles.value}>{value}</Text>}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{change && (
|
||||
<View style={styles.changeContainer}>
|
||||
<Text
|
||||
style={[
|
||||
styles.change,
|
||||
{ color: isPositive ? KurdistanColors.kesk : KurdistanColors.sor },
|
||||
]}
|
||||
>
|
||||
{isPositive ? '+' : ''}
|
||||
{change}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
info: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
},
|
||||
nameRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
symbol: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
},
|
||||
balance: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#000',
|
||||
},
|
||||
detailsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
name: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
value: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
changeContainer: {
|
||||
marginTop: 8,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
change: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -130,10 +130,7 @@ const styles = StyleSheet.create({
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
paddingBottom: 34, // Safe area
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
boxShadow: '0px -4px 12px rgba(0, 0, 0, 0.15)',
|
||||
elevation: 20,
|
||||
},
|
||||
handleContainer: {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
Animated,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
PanResponder,
|
||||
} from 'react-native';
|
||||
import { AppColors } from '../theme/colors';
|
||||
|
||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
interface BottomSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
height?: number;
|
||||
showHandle?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modern Bottom Sheet Component
|
||||
* Swipe to dismiss, smooth animations
|
||||
*/
|
||||
export const BottomSheet: React.FC<BottomSheetProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
height = SCREEN_HEIGHT * 0.6,
|
||||
showHandle = true,
|
||||
}) => {
|
||||
const translateY = useRef(new Animated.Value(height)).current;
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: (_, gestureState) => {
|
||||
return gestureState.dy > 5;
|
||||
},
|
||||
onPanResponderMove: (_, gestureState) => {
|
||||
if (gestureState.dy > 0) {
|
||||
translateY.setValue(gestureState.dy);
|
||||
}
|
||||
},
|
||||
onPanResponderRelease: (_, gestureState) => {
|
||||
if (gestureState.dy > 100) {
|
||||
closeSheet();
|
||||
} else {
|
||||
Animated.spring(translateY, {
|
||||
toValue: 0,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}
|
||||
},
|
||||
})
|
||||
).current;
|
||||
|
||||
const openSheet = React.useCallback(() => {
|
||||
Animated.spring(translateY, {
|
||||
toValue: 0,
|
||||
useNativeDriver: true,
|
||||
damping: 20,
|
||||
}).start();
|
||||
}, [translateY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
openSheet();
|
||||
}
|
||||
}, [visible, openSheet]);
|
||||
|
||||
const closeSheet = () => {
|
||||
Animated.timing(translateY, {
|
||||
toValue: height,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
onClose();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={closeSheet}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={closeSheet} />
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ height, transform: [{ translateY }] },
|
||||
]}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
{showHandle && (
|
||||
<View style={styles.handleContainer}>
|
||||
<View style={styles.handle} />
|
||||
</View>
|
||||
)}
|
||||
{title && (
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.content}>{children}</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: AppColors.surface,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
paddingBottom: 34, // Safe area
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
elevation: 20,
|
||||
},
|
||||
handleContainer: {
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
handle: {
|
||||
width: 40,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: AppColors.border,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: AppColors.border,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: AppColors.text,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
});
|
||||
@@ -95,18 +95,12 @@ const styles = StyleSheet.create({
|
||||
// Variants
|
||||
primary: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
shadowColor: KurdistanColors.kesk,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
boxShadow: '0px 4px 8px rgba(0, 128, 0, 0.3)',
|
||||
elevation: 4,
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: KurdistanColors.zer,
|
||||
shadowColor: KurdistanColors.zer,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 6,
|
||||
boxShadow: '0px 4px 6px rgba(255, 215, 0, 0.2)',
|
||||
elevation: 3,
|
||||
},
|
||||
outline: {
|
||||
@@ -119,10 +113,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: KurdistanColors.sor,
|
||||
shadowColor: KurdistanColors.sor,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
boxShadow: '0px 4px 8px rgba(255, 0, 0, 0.3)',
|
||||
elevation: 4,
|
||||
},
|
||||
// Sizes
|
||||
@@ -146,7 +137,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
shadowOpacity: 0,
|
||||
boxShadow: 'none',
|
||||
elevation: 0,
|
||||
},
|
||||
pressed: {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
ViewStyle,
|
||||
TextStyle,
|
||||
} from 'react-native';
|
||||
import { AppColors, KurdistanColors } from '../theme/colors';
|
||||
|
||||
interface ButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
fullWidth?: boolean;
|
||||
style?: ViewStyle;
|
||||
textStyle?: TextStyle;
|
||||
icon?: React.ReactNode;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modern Button Component
|
||||
* Uses Kurdistan colors for primary branding
|
||||
*/
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
fullWidth = false,
|
||||
style,
|
||||
textStyle,
|
||||
icon,
|
||||
testID,
|
||||
}) => {
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
const buttonStyle = [
|
||||
styles.base,
|
||||
styles[variant],
|
||||
styles[`${size}Size`],
|
||||
fullWidth && styles.fullWidth,
|
||||
isDisabled && styles.disabled,
|
||||
style,
|
||||
];
|
||||
|
||||
const textStyles = [
|
||||
styles.text,
|
||||
styles[`${variant}Text`],
|
||||
styles[`${size}Text`],
|
||||
isDisabled && styles.disabledText,
|
||||
textStyle,
|
||||
];
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
onPress={onPress}
|
||||
disabled={isDisabled}
|
||||
style={({ pressed }) => [
|
||||
...buttonStyle,
|
||||
pressed && !isDisabled && styles.pressed,
|
||||
]}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator
|
||||
color={variant === 'primary' || variant === 'danger' ? '#FFFFFF' : AppColors.primary}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{icon}
|
||||
<Text style={textStyles}>{title}</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
// Variants
|
||||
primary: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
shadowColor: KurdistanColors.kesk,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: KurdistanColors.zer,
|
||||
shadowColor: KurdistanColors.zer,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 6,
|
||||
elevation: 3,
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
borderColor: KurdistanColors.kesk,
|
||||
},
|
||||
ghost: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: KurdistanColors.sor,
|
||||
shadowColor: KurdistanColors.sor,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
// Sizes
|
||||
smallSize: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
},
|
||||
mediumSize: {
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
},
|
||||
largeSize: {
|
||||
paddingHorizontal: 32,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 16,
|
||||
},
|
||||
fullWidth: {
|
||||
width: '100%',
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
shadowOpacity: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.8,
|
||||
transform: [{ scale: 0.97 }],
|
||||
},
|
||||
// Text styles
|
||||
text: {
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
primaryText: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
secondaryText: {
|
||||
color: '#000000',
|
||||
},
|
||||
outlineText: {
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
ghostText: {
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
dangerText: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
smallText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
mediumText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
largeText: {
|
||||
fontSize: 18,
|
||||
},
|
||||
disabledText: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
});
|
||||
@@ -72,21 +72,18 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 12,
|
||||
},
|
||||
elevated: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
elevation: 4,
|
||||
},
|
||||
outlined: {
|
||||
borderWidth: 1,
|
||||
borderColor: AppColors.border,
|
||||
shadowOpacity: 0,
|
||||
boxShadow: 'none',
|
||||
elevation: 0,
|
||||
},
|
||||
filled: {
|
||||
backgroundColor: AppColors.background,
|
||||
shadowOpacity: 0,
|
||||
boxShadow: 'none',
|
||||
elevation: 0,
|
||||
},
|
||||
pressed: {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, ViewStyle, Pressable, Text } from 'react-native';
|
||||
import { AppColors } from '../theme/colors';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
style?: ViewStyle;
|
||||
onPress?: () => void;
|
||||
variant?: 'elevated' | 'outlined' | 'filled';
|
||||
testID?: string;
|
||||
elevation?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modern Card Component
|
||||
* Inspired by Material Design 3 and Kurdistan aesthetics
|
||||
*/
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
children,
|
||||
title,
|
||||
style,
|
||||
onPress,
|
||||
variant = 'elevated',
|
||||
testID,
|
||||
elevation,
|
||||
}) => {
|
||||
const cardStyle = [
|
||||
styles.card,
|
||||
variant === 'elevated' && styles.elevated,
|
||||
variant === 'outlined' && styles.outlined,
|
||||
variant === 'filled' && styles.filled,
|
||||
elevation && { elevation },
|
||||
style,
|
||||
];
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{title && <Text style={styles.title}>{title}</Text>}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [
|
||||
...cardStyle,
|
||||
pressed && styles.pressed,
|
||||
]}
|
||||
>
|
||||
{content}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
return <View testID={testID} style={cardStyle}>{content}</View>;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
backgroundColor: AppColors.surface,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: AppColors.text,
|
||||
marginBottom: 12,
|
||||
},
|
||||
elevated: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
outlined: {
|
||||
borderWidth: 1,
|
||||
borderColor: AppColors.border,
|
||||
shadowOpacity: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
filled: {
|
||||
backgroundColor: AppColors.background,
|
||||
shadowOpacity: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.7,
|
||||
transform: [{ scale: 0.98 }],
|
||||
},
|
||||
});
|
||||
@@ -115,10 +115,7 @@ const styles = StyleSheet.create({
|
||||
inputContainerFocused: {
|
||||
borderColor: KurdistanColors.kesk,
|
||||
borderWidth: 2,
|
||||
shadowColor: KurdistanColors.kesk,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
boxShadow: '0px 2px 4px rgba(0, 128, 0, 0.1)',
|
||||
elevation: 2,
|
||||
},
|
||||
inputContainerError: {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
TextInput,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInputProps,
|
||||
Pressable,
|
||||
} from 'react-native';
|
||||
import { AppColors, KurdistanColors } from '../theme/colors';
|
||||
|
||||
interface InputProps extends TextInputProps {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
onRightIconPress?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modern Input Component
|
||||
* Floating label, validation states, icons
|
||||
*/
|
||||
export const Input: React.FC<InputProps> = ({
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
onRightIconPress,
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const hasValue = props.value && props.value.length > 0;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{label && (
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
(isFocused || hasValue) && styles.labelFocused,
|
||||
error && styles.labelError,
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
styles.inputContainer,
|
||||
isFocused && styles.inputContainerFocused,
|
||||
error && styles.inputContainerError,
|
||||
]}
|
||||
>
|
||||
{leftIcon && <View style={styles.leftIcon}>{leftIcon}</View>}
|
||||
<TextInput
|
||||
{...props}
|
||||
editable={props.editable !== undefined ? props.editable : !props.disabled}
|
||||
style={[styles.input, leftIcon && styles.inputWithLeftIcon, style]}
|
||||
onFocus={(e) => {
|
||||
setIsFocused(true);
|
||||
props.onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setIsFocused(false);
|
||||
props.onBlur?.(e);
|
||||
}}
|
||||
placeholderTextColor={AppColors.textSecondary}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<Pressable onPress={onRightIconPress} style={styles.rightIcon}>
|
||||
{rightIcon}
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
{(error || helperText) && (
|
||||
<Text style={[styles.helperText, error && styles.errorText]}>
|
||||
{error || helperText}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: AppColors.textSecondary,
|
||||
marginBottom: 8,
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
labelFocused: {
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
labelError: {
|
||||
color: KurdistanColors.sor,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: AppColors.surface,
|
||||
borderWidth: 1.5,
|
||||
borderColor: AppColors.border,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
minHeight: 52,
|
||||
},
|
||||
inputContainerFocused: {
|
||||
borderColor: KurdistanColors.kesk,
|
||||
borderWidth: 2,
|
||||
shadowColor: KurdistanColors.kesk,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
inputContainerError: {
|
||||
borderColor: KurdistanColors.sor,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: AppColors.text,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
inputWithLeftIcon: {
|
||||
marginLeft: 12,
|
||||
},
|
||||
leftIcon: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
rightIcon: {
|
||||
padding: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
helperText: {
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
marginTop: 4,
|
||||
marginLeft: 16,
|
||||
},
|
||||
errorText: {
|
||||
color: KurdistanColors.sor,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import { supabaseHelpers } from '../lib/supabase';
|
||||
|
||||
interface NotificationBellProps {
|
||||
onPress: () => void;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
export const NotificationBell: React.FC<NotificationBellProps> = ({ onPress, style }) => {
|
||||
const { selectedAccount, api, isApiReady } = usePezkuwi();
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady || !selectedAccount) {
|
||||
setUnreadCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch unread notification count from Supabase
|
||||
const fetchUnreadCount = async () => {
|
||||
try {
|
||||
const count = await supabaseHelpers.getUnreadNotificationsCount(selectedAccount.address);
|
||||
setUnreadCount(count);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch unread count:', error);
|
||||
// If tables don't exist yet, set to 0
|
||||
setUnreadCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUnreadCount();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchUnreadCount, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [api, isApiReady, selectedAccount]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} style={[styles.container, style]}>
|
||||
<Text style={styles.bellIcon}>🔔</Text>
|
||||
{unreadCount > 0 && (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{unreadCount > 9 ? '9+' : unreadCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
width: 40,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
bellIcon: {
|
||||
fontSize: 24,
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
backgroundColor: KurdistanColors.sor,
|
||||
borderRadius: 10,
|
||||
minWidth: 20,
|
||||
height: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
badgeText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 11,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,455 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
import { supabaseHelpers } from '../lib/supabase';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: 'transaction' | 'governance' | 'p2p' | 'referral' | 'system';
|
||||
title: string;
|
||||
message: string;
|
||||
read: boolean;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface NotificationCenterModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Notifications are stored in Supabase database
|
||||
|
||||
export const NotificationCenterModal: React.FC<NotificationCenterModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
}) => {
|
||||
const { selectedAccount } = usePezkuwi();
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && selectedAccount) {
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch notifications from Supabase
|
||||
const data = await supabaseHelpers.getUserNotifications(selectedAccount.address);
|
||||
|
||||
// Transform to match component interface
|
||||
const transformed = data.map(n => ({
|
||||
...n,
|
||||
timestamp: n.created_at,
|
||||
}));
|
||||
|
||||
setNotifications(transformed);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch notifications:', error);
|
||||
// If tables don't exist yet, show empty state
|
||||
setNotifications([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchNotifications();
|
||||
}
|
||||
}, [visible, selectedAccount]);
|
||||
|
||||
const handleMarkAsRead = async (notificationId: string) => {
|
||||
try {
|
||||
// Update UI immediately
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === notificationId ? { ...n, read: true } : n))
|
||||
);
|
||||
|
||||
// Update in Supabase
|
||||
await supabaseHelpers.markNotificationAsRead(notificationId);
|
||||
} catch (error) {
|
||||
console.error('Failed to mark notification as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
if (!selectedAccount) return;
|
||||
|
||||
try {
|
||||
// Update UI immediately
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
||||
|
||||
// Update in Supabase
|
||||
await supabaseHelpers.markAllNotificationsAsRead(selectedAccount.address);
|
||||
|
||||
Alert.alert('Success', 'All notifications marked as read');
|
||||
} catch (error) {
|
||||
console.error('Failed to mark all as read:', error);
|
||||
Alert.alert('Error', 'Failed to update notifications');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
Alert.alert(
|
||||
'Clear All Notifications',
|
||||
'Are you sure you want to clear all notifications?',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Clear',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
setNotifications([]);
|
||||
// TODO: Implement delete from Supabase when needed
|
||||
// For now, just clear from UI
|
||||
} catch (error) {
|
||||
console.error('Failed to clear notifications:', error);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const getNotificationIcon = (type: string): string => {
|
||||
const icons: Record<string, string> = {
|
||||
transaction: '💰',
|
||||
governance: '🏛️',
|
||||
p2p: '🤝',
|
||||
referral: '👥',
|
||||
system: '⚙️',
|
||||
};
|
||||
return icons[type] || '📬';
|
||||
};
|
||||
|
||||
const getNotificationColor = (type: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
transaction: KurdistanColors.kesk,
|
||||
governance: '#3B82F6',
|
||||
p2p: '#F59E0B',
|
||||
referral: '#8B5CF6',
|
||||
system: '#6B7280',
|
||||
};
|
||||
return colors[type] || '#666';
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string): string => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
const groupedNotifications = {
|
||||
today: notifications.filter(n => {
|
||||
const date = new Date(n.timestamp);
|
||||
const today = new Date();
|
||||
return date.toDateString() === today.toDateString();
|
||||
}),
|
||||
earlier: notifications.filter(n => {
|
||||
const date = new Date(n.timestamp);
|
||||
const today = new Date();
|
||||
return date.toDateString() !== today.toDateString();
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.headerTitle}>Notifications</Text>
|
||||
{unreadCount > 0 && (
|
||||
<Text style={styles.headerSubtitle}>{unreadCount} unread</Text>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<Text style={styles.closeButtonText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Actions */}
|
||||
{notifications.length > 0 && (
|
||||
<View style={styles.actions}>
|
||||
{unreadCount > 0 && (
|
||||
<TouchableOpacity onPress={handleMarkAllAsRead} style={styles.actionButton}>
|
||||
<Text style={styles.actionButtonText}>Mark all as read</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity onPress={handleClearAll} style={styles.actionButton}>
|
||||
<Text style={[styles.actionButtonText, styles.actionButtonDanger]}>Clear all</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Notifications List */}
|
||||
<ScrollView style={styles.notificationsList} showsVerticalScrollIndicator={false}>
|
||||
{notifications.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyStateIcon}>📬</Text>
|
||||
<Text style={styles.emptyStateText}>No notifications</Text>
|
||||
<Text style={styles.emptyStateSubtext}>You're all caught up!</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{/* Today */}
|
||||
{groupedNotifications.today.length > 0 && (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>Today</Text>
|
||||
{groupedNotifications.today.map((notification) => (
|
||||
<TouchableOpacity
|
||||
key={notification.id}
|
||||
style={[
|
||||
styles.notificationCard,
|
||||
!notification.read && styles.notificationCardUnread,
|
||||
]}
|
||||
onPress={() => handleMarkAsRead(notification.id)}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.notificationIcon,
|
||||
{ backgroundColor: `${getNotificationColor(notification.type)}15` },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.notificationIconText}>
|
||||
{getNotificationIcon(notification.type)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.notificationContent}>
|
||||
<View style={styles.notificationHeader}>
|
||||
<Text style={styles.notificationTitle}>{notification.title}</Text>
|
||||
{!notification.read && <View style={styles.unreadDot} />}
|
||||
</View>
|
||||
<Text style={styles.notificationMessage} numberOfLines={2}>
|
||||
{notification.message}
|
||||
</Text>
|
||||
<Text style={styles.notificationTime}>
|
||||
{formatTimestamp(notification.timestamp)}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Earlier */}
|
||||
{groupedNotifications.earlier.length > 0 && (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>Earlier</Text>
|
||||
{groupedNotifications.earlier.map((notification) => (
|
||||
<TouchableOpacity
|
||||
key={notification.id}
|
||||
style={[
|
||||
styles.notificationCard,
|
||||
!notification.read && styles.notificationCardUnread,
|
||||
]}
|
||||
onPress={() => handleMarkAsRead(notification.id)}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.notificationIcon,
|
||||
{ backgroundColor: `${getNotificationColor(notification.type)}15` },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.notificationIconText}>
|
||||
{getNotificationIcon(notification.type)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.notificationContent}>
|
||||
<View style={styles.notificationHeader}>
|
||||
<Text style={styles.notificationTitle}>{notification.title}</Text>
|
||||
{!notification.read && <View style={styles.unreadDot} />}
|
||||
</View>
|
||||
<Text style={styles.notificationMessage} numberOfLines={2}>
|
||||
{notification.message}
|
||||
</Text>
|
||||
<Text style={styles.notificationTime}>
|
||||
{formatTimestamp(notification.timestamp)}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
maxHeight: '85%',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F0F0F0',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.kesk,
|
||||
marginTop: 2,
|
||||
},
|
||||
closeButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F5F5F5',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
closeButtonText: {
|
||||
fontSize: 18,
|
||||
color: '#666',
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F0F0F0',
|
||||
},
|
||||
actionButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
actionButtonText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: '#666',
|
||||
},
|
||||
actionButtonDanger: {
|
||||
color: '#EF4444',
|
||||
},
|
||||
notificationsList: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#999',
|
||||
marginBottom: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
notificationCard: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#F0F0F0',
|
||||
},
|
||||
notificationCardUnread: {
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderColor: KurdistanColors.kesk,
|
||||
},
|
||||
notificationIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
notificationIconText: {
|
||||
fontSize: 20,
|
||||
},
|
||||
notificationContent: {
|
||||
flex: 1,
|
||||
},
|
||||
notificationHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
notificationTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
flex: 1,
|
||||
},
|
||||
unreadDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
marginLeft: 8,
|
||||
},
|
||||
notificationMessage: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
lineHeight: 18,
|
||||
marginBottom: 4,
|
||||
},
|
||||
notificationTime: {
|
||||
fontSize: 11,
|
||||
color: '#999',
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyStateIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyStateText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyStateSubtext: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,406 @@
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
BackHandler,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
|
||||
// Base URL for the web app
|
||||
const WEB_BASE_URL = 'https://pezkuwichain.io';
|
||||
|
||||
export interface PezkuwiWebViewProps {
|
||||
// The path to load (e.g., '/p2p', '/forum', '/elections')
|
||||
path: string;
|
||||
// Optional title for the header
|
||||
title?: string;
|
||||
// Callback when navigation state changes
|
||||
onNavigationStateChange?: (canGoBack: boolean) => void;
|
||||
}
|
||||
|
||||
interface WebViewMessage {
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
const PezkuwiWebView: React.FC<PezkuwiWebViewProps> = ({
|
||||
path,
|
||||
title,
|
||||
onNavigationStateChange,
|
||||
}) => {
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [canGoBack, setCanGoBack] = useState(false);
|
||||
|
||||
const { selectedAccount, getKeyPair } = usePezkuwi();
|
||||
|
||||
// JavaScript to inject into the WebView
|
||||
// This creates a bridge between the web app and native app
|
||||
const injectedJavaScript = `
|
||||
(function() {
|
||||
// Mark this as mobile app
|
||||
window.PEZKUWI_MOBILE = true;
|
||||
window.PEZKUWI_PLATFORM = '${Platform.OS}';
|
||||
|
||||
// Inject wallet address if connected
|
||||
${selectedAccount ? `window.PEZKUWI_ADDRESS = '${selectedAccount.address}';` : ''}
|
||||
${selectedAccount ? `window.PEZKUWI_ACCOUNT_NAME = '${selectedAccount.meta?.name || 'Mobile Wallet'}';` : ''}
|
||||
|
||||
// Override console.log to send to React Native (for debugging)
|
||||
const originalConsoleLog = console.log;
|
||||
console.log = function(...args) {
|
||||
originalConsoleLog.apply(console, args);
|
||||
window.ReactNativeWebView?.postMessage(JSON.stringify({
|
||||
type: 'CONSOLE_LOG',
|
||||
payload: args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' ')
|
||||
}));
|
||||
};
|
||||
|
||||
// Create native bridge for wallet operations
|
||||
window.PezkuwiNativeBridge = {
|
||||
// Request transaction signing from native wallet
|
||||
signTransaction: function(extrinsicHex, callback) {
|
||||
window.__pendingSignCallback = callback;
|
||||
window.ReactNativeWebView?.postMessage(JSON.stringify({
|
||||
type: 'SIGN_TRANSACTION',
|
||||
payload: { extrinsicHex }
|
||||
}));
|
||||
},
|
||||
|
||||
// Request wallet connection
|
||||
connectWallet: function() {
|
||||
window.ReactNativeWebView?.postMessage(JSON.stringify({
|
||||
type: 'CONNECT_WALLET'
|
||||
}));
|
||||
},
|
||||
|
||||
// Navigate back in native app
|
||||
goBack: function() {
|
||||
window.ReactNativeWebView?.postMessage(JSON.stringify({
|
||||
type: 'GO_BACK'
|
||||
}));
|
||||
},
|
||||
|
||||
// Check if wallet is connected
|
||||
isWalletConnected: function() {
|
||||
return !!window.PEZKUWI_ADDRESS;
|
||||
},
|
||||
|
||||
// Get connected address
|
||||
getAddress: function() {
|
||||
return window.PEZKUWI_ADDRESS || null;
|
||||
}
|
||||
};
|
||||
|
||||
// Notify web app that native bridge is ready
|
||||
window.dispatchEvent(new CustomEvent('pezkuwi-native-ready', {
|
||||
detail: {
|
||||
address: window.PEZKUWI_ADDRESS,
|
||||
platform: window.PEZKUWI_PLATFORM
|
||||
}
|
||||
}));
|
||||
|
||||
true; // Required for injectedJavaScript
|
||||
})();
|
||||
`;
|
||||
|
||||
// Handle messages from WebView
|
||||
const handleMessage = useCallback(async (event: WebViewMessageEvent) => {
|
||||
try {
|
||||
const message: WebViewMessage = JSON.parse(event.nativeEvent.data);
|
||||
|
||||
switch (message.type) {
|
||||
case 'SIGN_TRANSACTION':
|
||||
// Handle transaction signing
|
||||
if (!selectedAccount) {
|
||||
// Send error back to WebView
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
if (window.__pendingSignCallback) {
|
||||
window.__pendingSignCallback(null, 'Wallet not connected');
|
||||
delete window.__pendingSignCallback;
|
||||
}
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { extrinsicHex } = message.payload as { extrinsicHex: string };
|
||||
const keyPair = await getKeyPair(selectedAccount.address);
|
||||
|
||||
if (!keyPair) {
|
||||
throw new Error('Could not retrieve key pair');
|
||||
}
|
||||
|
||||
// Sign the transaction
|
||||
const signature = keyPair.sign(extrinsicHex);
|
||||
const signatureHex = Buffer.from(signature).toString('hex');
|
||||
|
||||
// Send signature back to WebView
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
if (window.__pendingSignCallback) {
|
||||
window.__pendingSignCallback('${signatureHex}', null);
|
||||
delete window.__pendingSignCallback;
|
||||
}
|
||||
`);
|
||||
} catch (signError) {
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
if (window.__pendingSignCallback) {
|
||||
window.__pendingSignCallback(null, '${(signError as Error).message}');
|
||||
delete window.__pendingSignCallback;
|
||||
}
|
||||
`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'CONNECT_WALLET':
|
||||
// Trigger native wallet connection
|
||||
// This would open a modal or navigate to wallet screen
|
||||
if (__DEV__) console.log('WebView requested wallet connection');
|
||||
break;
|
||||
|
||||
case 'GO_BACK':
|
||||
// Handle back navigation from web
|
||||
if (canGoBack && webViewRef.current) {
|
||||
webViewRef.current.goBack();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'CONSOLE_LOG':
|
||||
// Forward console logs from WebView (debug only)
|
||||
if (__DEV__) {
|
||||
console.log('[WebView]:', message.payload);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
if (__DEV__) {
|
||||
console.log('Unknown message type:', message.type);
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
if (__DEV__) {
|
||||
console.error('Failed to parse WebView message:', parseError);
|
||||
}
|
||||
}
|
||||
}, [selectedAccount, getKeyPair, canGoBack]);
|
||||
|
||||
// Handle Android back button
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const onBackPress = () => {
|
||||
if (canGoBack && webViewRef.current) {
|
||||
webViewRef.current.goBack();
|
||||
return true; // Prevent default behavior
|
||||
}
|
||||
return false; // Allow default behavior
|
||||
};
|
||||
|
||||
const subscription = BackHandler.addEventListener('hardwareBackPress', onBackPress);
|
||||
return () => subscription.remove();
|
||||
}, [canGoBack])
|
||||
);
|
||||
|
||||
// Reload the WebView
|
||||
const handleReload = () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
webViewRef.current?.reload();
|
||||
};
|
||||
|
||||
// Go back in WebView history
|
||||
const handleGoBack = () => {
|
||||
if (canGoBack && webViewRef.current) {
|
||||
webViewRef.current.goBack();
|
||||
}
|
||||
};
|
||||
|
||||
// Build the full URL
|
||||
const fullUrl = `${WEB_BASE_URL}${path}`;
|
||||
|
||||
// Error view
|
||||
if (error) {
|
||||
return (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorIcon}>!</Text>
|
||||
<Text style={styles.errorTitle}>Connection Error</Text>
|
||||
<Text style={styles.errorMessage}>{error}</Text>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={handleReload}>
|
||||
<Text style={styles.retryButtonText}>Try Again</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Optional header with back button */}
|
||||
{title && (
|
||||
<View style={styles.header}>
|
||||
{canGoBack && (
|
||||
<TouchableOpacity style={styles.backButton} onPress={handleGoBack}>
|
||||
<Text style={styles.backButtonText}>{'<'}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<Text style={styles.headerTitle}>{title}</Text>
|
||||
<TouchableOpacity style={styles.reloadButton} onPress={handleReload}>
|
||||
<Text style={styles.reloadButtonText}>Reload</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* WebView */}
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={{ uri: fullUrl }}
|
||||
style={styles.webView}
|
||||
injectedJavaScript={injectedJavaScript}
|
||||
onMessage={handleMessage}
|
||||
onLoadStart={() => setLoading(true)}
|
||||
onLoadEnd={() => setLoading(false)}
|
||||
onError={(syntheticEvent) => {
|
||||
const { nativeEvent } = syntheticEvent;
|
||||
setError(nativeEvent.description || 'Failed to load page');
|
||||
setLoading(false);
|
||||
}}
|
||||
onHttpError={(syntheticEvent) => {
|
||||
const { nativeEvent } = syntheticEvent;
|
||||
if (nativeEvent.statusCode >= 400) {
|
||||
setError(`HTTP Error: ${nativeEvent.statusCode}`);
|
||||
}
|
||||
}}
|
||||
onNavigationStateChange={(navState) => {
|
||||
setCanGoBack(navState.canGoBack);
|
||||
onNavigationStateChange?.(navState.canGoBack);
|
||||
}}
|
||||
// Security settings
|
||||
javaScriptEnabled={true}
|
||||
domStorageEnabled={true}
|
||||
sharedCookiesEnabled={true}
|
||||
thirdPartyCookiesEnabled={true}
|
||||
// Performance settings
|
||||
cacheEnabled={true}
|
||||
cacheMode="LOAD_DEFAULT"
|
||||
// UI settings
|
||||
showsHorizontalScrollIndicator={false}
|
||||
showsVerticalScrollIndicator={true}
|
||||
bounces={true}
|
||||
pullToRefreshEnabled={true}
|
||||
// Behavior settings
|
||||
allowsBackForwardNavigationGestures={true}
|
||||
allowsInlineMediaPlayback={true}
|
||||
mediaPlaybackRequiresUserAction={false}
|
||||
// Debugging (dev only)
|
||||
webviewDebuggingEnabled={__DEV__}
|
||||
/>
|
||||
|
||||
{/* Loading overlay */}
|
||||
{loading && (
|
||||
<View style={styles.loadingOverlay}>
|
||||
<ActivityIndicator size="large" color={KurdistanColors.kesk} />
|
||||
<Text style={styles.loadingText}>Loading...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E0E0E0',
|
||||
},
|
||||
headerTitle: {
|
||||
flex: 1,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
textAlign: 'center',
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
marginRight: 8,
|
||||
},
|
||||
backButtonText: {
|
||||
fontSize: 24,
|
||||
color: KurdistanColors.kesk,
|
||||
fontWeight: '600',
|
||||
},
|
||||
reloadButton: {
|
||||
padding: 8,
|
||||
},
|
||||
reloadButtonText: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.kesk,
|
||||
fontWeight: '600',
|
||||
},
|
||||
webView: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 32,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
errorIcon: {
|
||||
fontSize: 48,
|
||||
color: KurdistanColors.sor,
|
||||
marginBottom: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
marginBottom: 8,
|
||||
},
|
||||
errorMessage: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default PezkuwiWebView;
|
||||
@@ -0,0 +1,215 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
} from 'react-native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
|
||||
interface PrivacyPolicyModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PrivacyPolicyModal: React.FC<PrivacyPolicyModalProps> = ({ visible, onClose }) => {
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Privacy Policy</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<Text style={styles.closeButtonText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<Text style={styles.sectionTitle}>Data Minimization Principle</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Pezkuwi collects the MINIMUM data necessary to provide blockchain wallet functionality.
|
||||
We operate on a "your keys, your coins, your responsibility" model.
|
||||
</Text>
|
||||
|
||||
<Text style={styles.sectionTitle}>What Data We Collect</Text>
|
||||
|
||||
<Text style={styles.subsectionTitle}>Stored LOCALLY on Your Device (NOT sent to Pezkuwi servers):</Text>
|
||||
<View style={styles.bulletList}>
|
||||
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Private Keys / Seed Phrase:</Text> Encrypted and stored in device secure storage</Text>
|
||||
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Account Balance:</Text> Cached from blockchain queries</Text>
|
||||
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Transaction History:</Text> Cached from blockchain queries</Text>
|
||||
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Settings:</Text> Language preference, theme, biometric settings</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.subsectionTitle}>Stored on Supabase (Third-party service):</Text>
|
||||
<View style={styles.bulletList}>
|
||||
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Profile Information:</Text> Username, email (if provided), avatar image</Text>
|
||||
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Citizenship Applications:</Text> Application data if you apply for citizenship</Text>
|
||||
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Forum Posts:</Text> Public posts and comments</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.subsectionTitle}>Stored on Blockchain (Public, immutable):</Text>
|
||||
<View style={styles.bulletList}>
|
||||
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Transactions:</Text> All transactions are publicly visible on PezkuwiChain</Text>
|
||||
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Account Address:</Text> Your public address is visible to all</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.subsectionTitle}>Never Collected:</Text>
|
||||
<View style={styles.bulletList}>
|
||||
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Browsing History:</Text> We don't track which screens you visit</Text>
|
||||
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Device Identifiers:</Text> No IMEI, MAC address, or advertising ID collection</Text>
|
||||
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Location Data:</Text> No GPS or location tracking</Text>
|
||||
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Contact Lists:</Text> We don't access your contacts</Text>
|
||||
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Third-party Analytics:</Text> No Google Analytics, Facebook Pixel, or similar trackers</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Why We Need Permissions</Text>
|
||||
|
||||
<Text style={styles.subsectionTitle}>Internet (REQUIRED)</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
• Connect to PezkuwiChain blockchain RPC endpoint{'\n'}
|
||||
• Query balances and transaction history{'\n'}
|
||||
• Submit transactions
|
||||
</Text>
|
||||
|
||||
<Text style={styles.subsectionTitle}>Storage (REQUIRED)</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
• Save encrypted seed phrase locally{'\n'}
|
||||
• Cache account data for offline viewing{'\n'}
|
||||
• Store profile avatar
|
||||
</Text>
|
||||
|
||||
<Text style={styles.subsectionTitle}>Camera (OPTIONAL)</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
• Take profile photos{'\n'}
|
||||
• Scan QR codes for payments{'\n'}
|
||||
• Capture NFT images
|
||||
</Text>
|
||||
|
||||
<Text style={styles.subsectionTitle}>Biometric (OPTIONAL)</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
• Secure authentication for transactions{'\n'}
|
||||
• Protect seed phrase viewing{'\n'}
|
||||
• Alternative to password entry
|
||||
</Text>
|
||||
|
||||
<Text style={styles.subsectionTitle}>Notifications (OPTIONAL)</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
• Alert you to incoming transfers{'\n'}
|
||||
• Notify staking reward claims{'\n'}
|
||||
• Governance proposal notifications
|
||||
</Text>
|
||||
|
||||
<Text style={styles.sectionTitle}>Zero-Knowledge Proofs & Encryption</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Citizenship applications are encrypted using ZK-proofs (Zero-Knowledge Proofs).
|
||||
This means:
|
||||
</Text>
|
||||
<View style={styles.bulletList}>
|
||||
<Text style={styles.bulletItem}>• Your personal data is encrypted before storage</Text>
|
||||
<Text style={styles.bulletItem}>• Only a cryptographic hash is stored on the blockchain</Text>
|
||||
<Text style={styles.bulletItem}>• Your data is uploaded to IPFS (decentralized storage) in encrypted form</Text>
|
||||
<Text style={styles.bulletItem}>• Even if someone accesses the data, they cannot decrypt it without your private key</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Your Data Rights</Text>
|
||||
<View style={styles.bulletList}>
|
||||
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Export Data:</Text> You can export your seed phrase and account data anytime</Text>
|
||||
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Delete Data:</Text> Delete your local data by uninstalling the app</Text>
|
||||
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Supabase Data:</Text> Contact support@pezkuwichain.io to delete profile data</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Contact</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
For privacy concerns: privacy@pezkuwichain.io{'\n'}
|
||||
General support: info@pezkuwichain.io
|
||||
</Text>
|
||||
|
||||
<Text style={styles.footer}>
|
||||
Last updated: {new Date().toLocaleDateString()}{'\n'}
|
||||
© {new Date().getFullYear()} PezkuwiChain
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E5E5E5',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 8,
|
||||
},
|
||||
closeButtonText: {
|
||||
fontSize: 24,
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.kesk,
|
||||
marginTop: 24,
|
||||
marginBottom: 12,
|
||||
},
|
||||
subsectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
paragraph: {
|
||||
fontSize: 14,
|
||||
lineHeight: 22,
|
||||
color: '#333',
|
||||
marginBottom: 12,
|
||||
},
|
||||
bulletList: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
bulletItem: {
|
||||
fontSize: 14,
|
||||
lineHeight: 22,
|
||||
color: '#333',
|
||||
marginBottom: 6,
|
||||
},
|
||||
bold: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
footer: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
marginTop: 32,
|
||||
marginBottom: 32,
|
||||
},
|
||||
});
|
||||
|
||||
export default PrivacyPolicyModal;
|
||||
@@ -0,0 +1,249 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
} from 'react-native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
|
||||
interface TermsOfServiceModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TermsOfServiceModal: React.FC<TermsOfServiceModalProps> = ({ visible, onClose }) => {
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Terms of Service</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<Text style={styles.closeButtonText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<Text style={styles.sectionTitle}>1. Acceptance of Terms</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
By accessing or using the Pezkuwi mobile application ("App"), you agree to be bound by these
|
||||
Terms of Service ("Terms"). If you do not agree to these Terms, do not use the App.
|
||||
</Text>
|
||||
|
||||
<Text style={styles.sectionTitle}>2. Description of Service</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Pezkuwi is a non-custodial blockchain wallet and governance platform that allows users to:
|
||||
</Text>
|
||||
<View style={styles.bulletList}>
|
||||
<Text style={styles.bulletItem}>• Manage blockchain accounts and private keys</Text>
|
||||
<Text style={styles.bulletItem}>• Send and receive cryptocurrency tokens</Text>
|
||||
<Text style={styles.bulletItem}>• Participate in decentralized governance</Text>
|
||||
<Text style={styles.bulletItem}>• Apply for digital citizenship</Text>
|
||||
<Text style={styles.bulletItem}>• Access educational content and earn rewards</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>3. User Responsibilities</Text>
|
||||
|
||||
<Text style={styles.subsectionTitle}>3.1 Account Security</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
You are solely responsible for:
|
||||
</Text>
|
||||
<View style={styles.bulletList}>
|
||||
<Text style={styles.bulletItem}>• Maintaining the confidentiality of your seed phrase and private keys</Text>
|
||||
<Text style={styles.bulletItem}>• All activities that occur under your account</Text>
|
||||
<Text style={styles.bulletItem}>• Securing your device with appropriate passcodes and biometric authentication</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.subsectionTitle}>3.2 Prohibited Activities</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
You agree NOT to:
|
||||
</Text>
|
||||
<View style={styles.bulletList}>
|
||||
<Text style={styles.bulletItem}>• Use the App for any illegal or unauthorized purpose</Text>
|
||||
<Text style={styles.bulletItem}>• Attempt to gain unauthorized access to other users' accounts</Text>
|
||||
<Text style={styles.bulletItem}>• Interfere with or disrupt the App or servers</Text>
|
||||
<Text style={styles.bulletItem}>• Upload malicious code or viruses</Text>
|
||||
<Text style={styles.bulletItem}>• Engage in fraudulent transactions or money laundering</Text>
|
||||
<Text style={styles.bulletItem}>• Create fake identities or impersonate others</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>4. Non-Custodial Nature</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Pezkuwi is a non-custodial wallet. This means:
|
||||
</Text>
|
||||
<View style={styles.bulletList}>
|
||||
<Text style={styles.bulletItem}>• We DO NOT have access to your private keys or funds</Text>
|
||||
<Text style={styles.bulletItem}>• We CANNOT recover your funds if you lose your seed phrase</Text>
|
||||
<Text style={styles.bulletItem}>• We CANNOT reverse transactions or freeze accounts</Text>
|
||||
<Text style={styles.bulletItem}>• You have full control and full responsibility for your assets</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>5. Blockchain Transactions</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
When you submit a transaction:
|
||||
</Text>
|
||||
<View style={styles.bulletList}>
|
||||
<Text style={styles.bulletItem}>• Transactions are irreversible once confirmed on the blockchain</Text>
|
||||
<Text style={styles.bulletItem}>• Transaction fees (gas) are determined by network demand</Text>
|
||||
<Text style={styles.bulletItem}>• We are not responsible for transaction failures due to insufficient fees</Text>
|
||||
<Text style={styles.bulletItem}>• You acknowledge the risks of blockchain technology</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>6. Digital Citizenship</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Citizenship applications:
|
||||
</Text>
|
||||
<View style={styles.bulletList}>
|
||||
<Text style={styles.bulletItem}>• Require KYC (Know Your Customer) verification</Text>
|
||||
<Text style={styles.bulletItem}>• Are subject to approval by governance mechanisms</Text>
|
||||
<Text style={styles.bulletItem}>• Involve storing encrypted personal data on IPFS</Text>
|
||||
<Text style={styles.bulletItem}>• Can be revoked if fraudulent information is detected</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>7. Disclaimer of Warranties</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
THE APP IS PROVIDED "AS IS" WITHOUT WARRANTIES OF ANY KIND. WE DO NOT GUARANTEE:
|
||||
</Text>
|
||||
<View style={styles.bulletList}>
|
||||
<Text style={styles.bulletItem}>• Uninterrupted or error-free service</Text>
|
||||
<Text style={styles.bulletItem}>• Accuracy of displayed data or prices</Text>
|
||||
<Text style={styles.bulletItem}>• Security from unauthorized access or hacking</Text>
|
||||
<Text style={styles.bulletItem}>• Protection from loss of funds due to user error</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>8. Limitation of Liability</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY LAW, PEZKUWI SHALL NOT BE LIABLE FOR:
|
||||
</Text>
|
||||
<View style={styles.bulletList}>
|
||||
<Text style={styles.bulletItem}>• Loss of funds due to forgotten seed phrases</Text>
|
||||
<Text style={styles.bulletItem}>• Unauthorized transactions from compromised devices</Text>
|
||||
<Text style={styles.bulletItem}>• Network congestion or blockchain failures</Text>
|
||||
<Text style={styles.bulletItem}>• Price volatility of cryptocurrencies</Text>
|
||||
<Text style={styles.bulletItem}>• Third-party services (IPFS, Supabase, RPC providers)</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>9. Intellectual Property</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
The Pezkuwi App, including its design, code, and content, is protected by copyright and trademark laws.
|
||||
You may not:
|
||||
</Text>
|
||||
<View style={styles.bulletList}>
|
||||
<Text style={styles.bulletItem}>• Copy, modify, or distribute the App without permission</Text>
|
||||
<Text style={styles.bulletItem}>• Reverse engineer or decompile the App</Text>
|
||||
<Text style={styles.bulletItem}>• Use the Pezkuwi name or logo without authorization</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>10. Governing Law</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
These Terms shall be governed by the laws of decentralized autonomous organizations (DAOs)
|
||||
and international arbitration. Disputes will be resolved through community governance mechanisms
|
||||
when applicable.
|
||||
</Text>
|
||||
|
||||
<Text style={styles.sectionTitle}>11. Changes to Terms</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
We reserve the right to modify these Terms at any time. Changes will be effective upon posting
|
||||
in the App. Your continued use of the App constitutes acceptance of modified Terms.
|
||||
</Text>
|
||||
|
||||
<Text style={styles.sectionTitle}>12. Termination</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
We may terminate or suspend your access to the App at any time for violations of these Terms.
|
||||
You may stop using the App at any time by deleting it from your device.
|
||||
</Text>
|
||||
|
||||
<Text style={styles.sectionTitle}>13. Contact</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
For questions about these Terms:{'\n'}
|
||||
Email: legal@pezkuwichain.io{'\n'}
|
||||
Support: info@pezkuwichain.io{'\n'}
|
||||
Website: https://pezkuwichain.io
|
||||
</Text>
|
||||
|
||||
<Text style={styles.footer}>
|
||||
Last updated: {new Date().toLocaleDateString()}{'\n'}
|
||||
© {new Date().getFullYear()} PezkuwiChain
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E5E5E5',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 8,
|
||||
},
|
||||
closeButtonText: {
|
||||
fontSize: 24,
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.kesk,
|
||||
marginTop: 24,
|
||||
marginBottom: 12,
|
||||
},
|
||||
subsectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
paragraph: {
|
||||
fontSize: 14,
|
||||
lineHeight: 22,
|
||||
color: '#333',
|
||||
marginBottom: 12,
|
||||
},
|
||||
bulletList: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
bulletItem: {
|
||||
fontSize: 14,
|
||||
lineHeight: 22,
|
||||
color: '#333',
|
||||
marginBottom: 6,
|
||||
},
|
||||
footer: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
marginTop: 32,
|
||||
marginBottom: 32,
|
||||
},
|
||||
});
|
||||
|
||||
export default TermsOfServiceModal;
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, FlatList, ActivityIndicator, Alert, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { BottomSheet, Button } from './index'; // Assuming these are exported from index.ts or index.tsx in the same folder
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
import { KurdistanColors, AppColors } from '../theme/colors';
|
||||
|
||||
interface Validator {
|
||||
address: string;
|
||||
commission: number;
|
||||
totalStake: string; // Formatted balance
|
||||
selfStake: string; // Formatted balance
|
||||
nominators: number;
|
||||
// Add other relevant validator info
|
||||
}
|
||||
|
||||
interface ValidatorSelectionSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onConfirmNominations: (validators: string[]) => void;
|
||||
// Add other props like currentNominations if needed
|
||||
}
|
||||
|
||||
export function ValidatorSelectionSheet({
|
||||
visible,
|
||||
onClose,
|
||||
onConfirmNominations,
|
||||
}: ValidatorSelectionSheetProps) {
|
||||
const { api, isApiReady, selectedAccount } = usePezkuwi();
|
||||
const [validators, setValidators] = useState<Validator[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [selectedValidators, setSelectedValidators] = useState<string[]>([]);
|
||||
|
||||
// Fetch real validators from chain
|
||||
useEffect(() => {
|
||||
const fetchValidators = async () => {
|
||||
if (!api || !isApiReady) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const chainValidators: Validator[] = [];
|
||||
// Attempt to fetch from pallet-validator-pool first
|
||||
if (api.query.validatorPool && api.query.validatorPool.validators) {
|
||||
const rawValidators = await api.query.validatorPool.validators();
|
||||
// Assuming rawValidators is a list of validator addresses or objects
|
||||
// This parsing logic will need adjustment based on the exact structure returned
|
||||
for (const rawValidator of rawValidators.toHuman() as any[]) { // Adjust 'any' based on actual type
|
||||
// Placeholder: Assume rawValidator is just an address for now
|
||||
chainValidators.push({
|
||||
address: rawValidator.toString(), // or rawValidator.address if it's an object
|
||||
commission: 0.05, // Placeholder: Fetch actual commission
|
||||
totalStake: '0 HEZ', // Placeholder: Fetch actual stake
|
||||
selfStake: '0 HEZ', // Placeholder: Fetch actual self stake
|
||||
nominators: 0, // Placeholder: Fetch actual nominators
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback to general staking validators if validatorPool pallet is not found/used
|
||||
const rawStakingValidators = await api.query.staking.validators();
|
||||
for (const validatorAddress of rawStakingValidators.keys) {
|
||||
const address = validatorAddress.args[0].toString();
|
||||
// Fetch more details about each validator if needed, e.g., commission, total stake
|
||||
const validatorPrefs = await api.query.staking.validators(address);
|
||||
const commission = validatorPrefs.commission.toNumber() / 10_000_000; // Assuming 10^7 for percentage
|
||||
|
||||
// For simplicity, total stake and nominators are placeholders for now
|
||||
// A more complete implementation would query for detailed exposure
|
||||
chainValidators.push({
|
||||
address: address,
|
||||
commission: commission,
|
||||
totalStake: 'Fetching...',
|
||||
selfStake: 'Fetching...',
|
||||
nominators: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setValidators(chainValidators);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error fetching validators:', error);
|
||||
Alert.alert('Error', 'Failed to fetch validators.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchValidators();
|
||||
}, [api, isApiReady]);
|
||||
|
||||
|
||||
const toggleValidatorSelection = (address: string) => {
|
||||
setSelectedValidators(prev =>
|
||||
prev.includes(address)
|
||||
? prev.filter(item => item !== address)
|
||||
: [...prev, address]
|
||||
);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedValidators.length === 0) {
|
||||
Alert.alert('Selection Required', 'Please select at least one validator.');
|
||||
return;
|
||||
}
|
||||
// Pass selected validators to parent component to initiate transaction
|
||||
onConfirmNominations(selectedValidators);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const renderValidatorItem = ({ item }: { item: Validator }) => (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.validatorItem,
|
||||
selectedValidators.includes(item.address) && styles.selectedValidatorItem,
|
||||
]}
|
||||
onPress={() => toggleValidatorSelection(item.address)}
|
||||
>
|
||||
<View>
|
||||
<Text style={styles.validatorAddress}>
|
||||
{item.address.substring(0, 8)}...{item.address.substring(item.address.length - 6)}
|
||||
</Text>
|
||||
<Text style={styles.validatorDetail}>Commission: {item.commission * 100}%</Text>
|
||||
<Text style={styles.validatorDetail}>Total Stake: {item.totalStake}</Text>
|
||||
<Text style={styles.validatorDetail}>Self Stake: {item.selfStake}</Text>
|
||||
<Text style={styles.validatorDetail}>Nominators: {item.nominators}</Text>
|
||||
</View>
|
||||
{selectedValidators.includes(item.address) && (
|
||||
<View style={styles.selectedIndicator}>
|
||||
<Text style={styles.selectedIndicatorText}>✔</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<BottomSheet visible={visible} onClose={onClose} title="Select Validators">
|
||||
{loading ? (
|
||||
<ActivityIndicator size="large" color={KurdistanColors.kesk} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={validators}
|
||||
keyExtractor={item => item.address}
|
||||
renderItem={renderValidatorItem}
|
||||
style={styles.list}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
title={processing ? 'Confirming...' : 'Confirm Nominations'}
|
||||
onPress={handleConfirm}
|
||||
loading={processing}
|
||||
disabled={processing || selectedValidators.length === 0}
|
||||
fullWidth
|
||||
style={{ marginTop: 20 }}
|
||||
/>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: {
|
||||
maxHeight: 400, // Adjust as needed
|
||||
},
|
||||
validatorItem: {
|
||||
padding: 15,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#eee',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
selectedValidatorItem: {
|
||||
borderColor: KurdistanColors.kesk,
|
||||
borderWidth: 2,
|
||||
},
|
||||
validatorAddress: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
validatorDetail: {
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
selectedIndicator: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
selectedIndicatorText: {
|
||||
color: KurdistanColors.spi,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
@@ -15,3 +15,5 @@ export { AddressDisplay } from './AddressDisplay';
|
||||
export { BalanceCard } from './BalanceCard';
|
||||
export { TokenSelector } from './TokenSelector';
|
||||
export type { Token } from './TokenSelector';
|
||||
export { default as PezkuwiWebView } from './PezkuwiWebView';
|
||||
export type { PezkuwiWebViewProps } from './PezkuwiWebView';
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { KurdistanColors } from '../../theme/colors';
|
||||
import type { StackHeaderProps } from '@react-navigation/stack';
|
||||
|
||||
interface GradientHeaderProps extends StackHeaderProps {
|
||||
subtitle?: string;
|
||||
rightButtons?: React.ReactNode;
|
||||
gradientColors?: [string, string];
|
||||
}
|
||||
|
||||
export const GradientHeader: React.FC<GradientHeaderProps> = ({
|
||||
navigation,
|
||||
options,
|
||||
route,
|
||||
subtitle,
|
||||
rightButtons,
|
||||
gradientColors = [KurdistanColors.kesk, '#008f43'],
|
||||
}) => {
|
||||
const title = options.headerTitle !== undefined
|
||||
? options.headerTitle
|
||||
: options.title !== undefined
|
||||
? options.title
|
||||
: route.name;
|
||||
|
||||
const canGoBack = navigation.canGoBack();
|
||||
|
||||
return (
|
||||
<LinearGradient colors={gradientColors} style={styles.gradientHeader}>
|
||||
<View style={styles.headerContent}>
|
||||
<View style={styles.headerLeft}>
|
||||
{canGoBack && (
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<Text style={styles.backButtonText}>←</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
<Text style={styles.headerTitle}>{title}</Text>
|
||||
{subtitle && <Text style={styles.headerSubtitle}>{subtitle}</Text>}
|
||||
</View>
|
||||
|
||||
<View style={styles.headerRight}>{rightButtons}</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
);
|
||||
};
|
||||
|
||||
interface SimpleHeaderProps extends StackHeaderProps {
|
||||
rightButtons?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SimpleHeader: React.FC<SimpleHeaderProps> = ({
|
||||
navigation,
|
||||
options,
|
||||
route,
|
||||
rightButtons,
|
||||
}) => {
|
||||
const title = options.headerTitle !== undefined
|
||||
? options.headerTitle
|
||||
: options.title !== undefined
|
||||
? options.title
|
||||
: route.name;
|
||||
|
||||
const canGoBack = navigation.canGoBack();
|
||||
|
||||
return (
|
||||
<View style={styles.simpleHeader}>
|
||||
<View style={styles.headerContent}>
|
||||
<View style={styles.headerLeft}>
|
||||
{canGoBack && (
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<Text style={styles.simpleBackButtonText}>←</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
<Text style={styles.simpleHeaderTitle}>{title}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerRight}>{rightButtons}</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const BackButton: React.FC<{ onPress?: () => void; color?: string }> = ({
|
||||
onPress,
|
||||
color = '#FFFFFF',
|
||||
}) => {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} style={styles.backButton}>
|
||||
<Text style={[styles.backButtonText, { color }]}>←</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
interface AppBarHeaderProps extends StackHeaderProps {
|
||||
rightButtons?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AppBarHeader: React.FC<AppBarHeaderProps> = ({
|
||||
navigation,
|
||||
options,
|
||||
route,
|
||||
rightButtons,
|
||||
}) => {
|
||||
const title = options.headerTitle !== undefined
|
||||
? options.headerTitle
|
||||
: options.title !== undefined
|
||||
? options.title
|
||||
: route.name;
|
||||
|
||||
const canGoBack = navigation.canGoBack();
|
||||
|
||||
return (
|
||||
<View style={styles.appBarHeader}>
|
||||
<View style={styles.headerContent}>
|
||||
<View style={styles.headerLeft}>
|
||||
{canGoBack && (
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<Text style={styles.appBarBackButtonText}>←</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
<Text style={styles.appBarTitle}>{title}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerRight}>{rightButtons}</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
gradientHeader: {
|
||||
paddingTop: 50,
|
||||
paddingBottom: 16,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
simpleHeader: {
|
||||
paddingTop: 50,
|
||||
paddingBottom: 16,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E5E5E5',
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
headerLeft: {
|
||||
width: 60,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
headerCenter: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerRight: {
|
||||
width: 60,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
marginTop: 2,
|
||||
},
|
||||
simpleHeaderTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
},
|
||||
backButtonText: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
simpleBackButtonText: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
appBarHeader: {
|
||||
paddingTop: 50,
|
||||
paddingBottom: 16,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
elevation: 4,
|
||||
},
|
||||
appBarTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
appBarBackButtonText: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { KurdistanColors } from '../../theme/colors';
|
||||
import type { StackHeaderProps } from '@react-navigation/stack';
|
||||
|
||||
interface GradientHeaderProps extends StackHeaderProps {
|
||||
subtitle?: string;
|
||||
rightButtons?: React.ReactNode;
|
||||
gradientColors?: [string, string];
|
||||
}
|
||||
|
||||
export const GradientHeader: React.FC<GradientHeaderProps> = ({
|
||||
navigation,
|
||||
options,
|
||||
route,
|
||||
subtitle,
|
||||
rightButtons,
|
||||
gradientColors = [KurdistanColors.kesk, '#008f43'],
|
||||
}) => {
|
||||
const title = options.headerTitle !== undefined
|
||||
? options.headerTitle
|
||||
: options.title !== undefined
|
||||
? options.title
|
||||
: route.name;
|
||||
|
||||
const canGoBack = navigation.canGoBack();
|
||||
|
||||
return (
|
||||
<LinearGradient colors={gradientColors} style={styles.gradientHeader}>
|
||||
<View style={styles.headerContent}>
|
||||
<View style={styles.headerLeft}>
|
||||
{canGoBack && (
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<Text style={styles.backButtonText}>←</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
<Text style={styles.headerTitle}>{title}</Text>
|
||||
{subtitle && <Text style={styles.headerSubtitle}>{subtitle}</Text>}
|
||||
</View>
|
||||
|
||||
<View style={styles.headerRight}>{rightButtons}</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
);
|
||||
};
|
||||
|
||||
interface SimpleHeaderProps extends StackHeaderProps {
|
||||
rightButtons?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SimpleHeader: React.FC<SimpleHeaderProps> = ({
|
||||
navigation,
|
||||
options,
|
||||
route,
|
||||
rightButtons,
|
||||
}) => {
|
||||
const title = options.headerTitle !== undefined
|
||||
? options.headerTitle
|
||||
: options.title !== undefined
|
||||
? options.title
|
||||
: route.name;
|
||||
|
||||
const canGoBack = navigation.canGoBack();
|
||||
|
||||
return (
|
||||
<View style={styles.simpleHeader}>
|
||||
<View style={styles.headerContent}>
|
||||
<View style={styles.headerLeft}>
|
||||
{canGoBack && (
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<Text style={styles.simpleBackButtonText}>←</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
<Text style={styles.simpleHeaderTitle}>{title}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerRight}>{rightButtons}</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const BackButton: React.FC<{ onPress?: () => void; color?: string }> = ({
|
||||
onPress,
|
||||
color = '#FFFFFF',
|
||||
}) => {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} style={styles.backButton}>
|
||||
<Text style={[styles.backButtonText, { color }]}>←</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
interface AppBarHeaderProps extends StackHeaderProps {
|
||||
rightButtons?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AppBarHeader: React.FC<AppBarHeaderProps> = ({
|
||||
navigation,
|
||||
options,
|
||||
route,
|
||||
rightButtons,
|
||||
}) => {
|
||||
const title = options.headerTitle !== undefined
|
||||
? options.headerTitle
|
||||
: options.title !== undefined
|
||||
? options.title
|
||||
: route.name;
|
||||
|
||||
const canGoBack = navigation.canGoBack();
|
||||
|
||||
return (
|
||||
<View style={styles.appBarHeader}>
|
||||
<View style={styles.headerContent}>
|
||||
<View style={styles.headerLeft}>
|
||||
{canGoBack && (
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<Text style={styles.appBarBackButtonText}>←</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
<Text style={styles.appBarTitle}>{title}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerRight}>{rightButtons}</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
gradientHeader: {
|
||||
paddingTop: 50,
|
||||
paddingBottom: 16,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
simpleHeader: {
|
||||
paddingTop: 50,
|
||||
paddingBottom: 16,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E5E5E5',
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
headerLeft: {
|
||||
width: 60,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
headerCenter: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerRight: {
|
||||
width: 60,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
marginTop: 2,
|
||||
},
|
||||
simpleHeaderTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
},
|
||||
backButtonText: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
simpleBackButtonText: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
appBarHeader: {
|
||||
paddingTop: 50,
|
||||
paddingBottom: 16,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
},
|
||||
appBarTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
appBarBackButtonText: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
Share,
|
||||
Clipboard,
|
||||
Alert,
|
||||
Linking,
|
||||
} from 'react-native';
|
||||
import { KurdistanColors } from '../../theme/colors';
|
||||
import { usePezkuwi } from '../../contexts/PezkuwiContext';
|
||||
|
||||
interface InviteModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const InviteModal: React.FC<InviteModalProps> = ({ visible, onClose }) => {
|
||||
const { selectedAccount, api } = usePezkuwi();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [inviteeAddress, setInviteeAddress] = useState('');
|
||||
|
||||
// Generate referral link
|
||||
const referralLink = useMemo(() => {
|
||||
if (!selectedAccount?.address) return '';
|
||||
// TODO: Update with actual app deep link or web URL
|
||||
return `https://pezkuwi.net/be-citizen?ref=${selectedAccount.address}`;
|
||||
}, [selectedAccount?.address]);
|
||||
|
||||
const shareText = useMemo(() => {
|
||||
return `Join me on Digital Kurdistan (PezkuwiChain)! 🏛️\n\nBecome a citizen and get your Welati Tiki NFT.\n\nUse my referral link:\n${referralLink}`;
|
||||
}, [referralLink]);
|
||||
|
||||
const handleCopy = () => {
|
||||
Clipboard.setString(referralLink);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
Alert.alert('Copied!', 'Referral link copied to clipboard');
|
||||
};
|
||||
|
||||
const handleNativeShare = async () => {
|
||||
try {
|
||||
await Share.share({
|
||||
message: shareText,
|
||||
title: 'Join Digital Kurdistan',
|
||||
});
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Share error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSharePlatform = (platform: string) => {
|
||||
const encodedText = encodeURIComponent(shareText);
|
||||
const encodedUrl = encodeURIComponent(referralLink);
|
||||
|
||||
const urls: Record<string, string> = {
|
||||
whatsapp: `whatsapp://send?text=${encodedText}`,
|
||||
telegram: `tg://msg?text=${encodedText}`,
|
||||
twitter: `twitter://post?message=${encodedText}`,
|
||||
email: `mailto:?subject=${encodeURIComponent('Join Digital Kurdistan')}&body=${encodedText}`,
|
||||
};
|
||||
|
||||
if (urls[platform]) {
|
||||
Linking.openURL(urls[platform]).catch(() => {
|
||||
// Fallback to web URL if app not installed
|
||||
const webUrls: Record<string, string> = {
|
||||
whatsapp: `https://wa.me/?text=${encodedText}`,
|
||||
telegram: `https://t.me/share/url?url=${encodedUrl}&text=${encodeURIComponent('Join Digital Kurdistan! 🏛️')}`,
|
||||
twitter: `https://twitter.com/intent/tweet?text=${encodedText}`,
|
||||
};
|
||||
if (webUrls[platform]) {
|
||||
Linking.openURL(webUrls[platform]);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleInitiateReferral = async () => {
|
||||
if (!api || !selectedAccount || !inviteeAddress) {
|
||||
Alert.alert('Error', 'Please enter a valid address');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: Implement on-chain referral initiation
|
||||
// const tx = api.tx.referral.initiateReferral(inviteeAddress);
|
||||
// await tx.signAndSend(selectedAccount.address);
|
||||
Alert.alert('Success', 'Referral initiated successfully!');
|
||||
setInviteeAddress('');
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Initiate referral error:', error);
|
||||
Alert.alert('Error', 'Failed to initiate referral');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
{/* Header */}
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Invite Friends</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<Text style={styles.closeButtonText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.modalBody} showsVerticalScrollIndicator={false}>
|
||||
<Text style={styles.modalDescription}>
|
||||
Share your referral link. When your friends complete KYC, you'll earn trust score points!
|
||||
</Text>
|
||||
|
||||
{/* Referral Link */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>Your Referral Link</Text>
|
||||
<View style={styles.linkContainer}>
|
||||
<TextInput
|
||||
style={styles.linkInput}
|
||||
value={referralLink}
|
||||
editable={false}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.copyButton} onPress={handleCopy}>
|
||||
<Text style={styles.copyButtonIcon}>{copied ? '✓' : '📋'}</Text>
|
||||
<Text style={styles.copyButtonText}>{copied ? 'Copied!' : 'Copy Link'}</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.hint}>
|
||||
Anyone who signs up with this link will be counted as your referral
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Share Options */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>Share via</Text>
|
||||
<View style={styles.shareGrid}>
|
||||
<TouchableOpacity
|
||||
style={styles.shareButton}
|
||||
onPress={handleNativeShare}
|
||||
>
|
||||
<Text style={styles.shareButtonIcon}>📤</Text>
|
||||
<Text style={styles.shareButtonText}>Share</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.shareButton}
|
||||
onPress={() => handleSharePlatform('whatsapp')}
|
||||
>
|
||||
<Text style={styles.shareButtonIcon}>💬</Text>
|
||||
<Text style={styles.shareButtonText}>WhatsApp</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.shareButton}
|
||||
onPress={() => handleSharePlatform('telegram')}
|
||||
>
|
||||
<Text style={styles.shareButtonIcon}>✈️</Text>
|
||||
<Text style={styles.shareButtonText}>Telegram</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.shareButton}
|
||||
onPress={() => handleSharePlatform('twitter')}
|
||||
>
|
||||
<Text style={styles.shareButtonIcon}>🐦</Text>
|
||||
<Text style={styles.shareButtonText}>Twitter</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.shareButton}
|
||||
onPress={() => handleSharePlatform('email')}
|
||||
>
|
||||
<Text style={styles.shareButtonIcon}>📧</Text>
|
||||
<Text style={styles.shareButtonText}>Email</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Advanced: Pre-register */}
|
||||
<View style={[styles.section, styles.advancedSection]}>
|
||||
<Text style={styles.sectionLabel}>Pre-Register a Friend (Advanced)</Text>
|
||||
<Text style={styles.hint}>
|
||||
If you know your friend's wallet address, you can pre-register them on-chain.
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.addressInput}
|
||||
placeholder="Friend's wallet address"
|
||||
placeholderTextColor="#999"
|
||||
value={inviteeAddress}
|
||||
onChangeText={setInviteeAddress}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.initiateButton}
|
||||
onPress={handleInitiateReferral}
|
||||
disabled={!inviteeAddress}
|
||||
>
|
||||
<Text style={styles.initiateButtonText}>Initiate Referral</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Rewards Info */}
|
||||
<View style={styles.rewardsSection}>
|
||||
<Text style={styles.rewardsSectionTitle}>Referral Rewards</Text>
|
||||
<View style={styles.rewardRow}>
|
||||
<Text style={styles.rewardText}>• 1-10 referrals: 10 points each (up to 100)</Text>
|
||||
</View>
|
||||
<View style={styles.rewardRow}>
|
||||
<Text style={styles.rewardText}>• 11-50 referrals: 5 points each (up to 300)</Text>
|
||||
</View>
|
||||
<View style={styles.rewardRow}>
|
||||
<Text style={styles.rewardText}>• 51-100 referrals: 4 points each (up to 500)</Text>
|
||||
</View>
|
||||
<View style={styles.rewardRow}>
|
||||
<Text style={styles.rewardText}>• Maximum: 500 trust score points</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer */}
|
||||
<TouchableOpacity style={styles.doneButton} onPress={onClose}>
|
||||
<Text style={styles.doneButtonText}>Done</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
maxHeight: '90%',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F0F0F0',
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
},
|
||||
closeButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F5F5F5',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
closeButtonText: {
|
||||
fontSize: 18,
|
||||
color: '#666',
|
||||
},
|
||||
modalBody: {
|
||||
padding: 20,
|
||||
},
|
||||
modalDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 20,
|
||||
lineHeight: 20,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 12,
|
||||
},
|
||||
linkContainer: {
|
||||
backgroundColor: '#F5F5F5',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
linkInput: {
|
||||
fontSize: 12,
|
||||
color: '#333',
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
copyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
gap: 8,
|
||||
},
|
||||
copyButtonIcon: {
|
||||
fontSize: 16,
|
||||
},
|
||||
copyButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
hint: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
lineHeight: 16,
|
||||
},
|
||||
shareGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
shareButton: {
|
||||
flex: 1,
|
||||
minWidth: '30%',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
backgroundColor: '#F5F5F5',
|
||||
borderRadius: 12,
|
||||
gap: 4,
|
||||
},
|
||||
shareButtonIcon: {
|
||||
fontSize: 24,
|
||||
},
|
||||
shareButtonText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: '#666',
|
||||
},
|
||||
advancedSection: {
|
||||
backgroundColor: '#E0F2FE',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
addressInput: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
color: '#333',
|
||||
marginTop: 8,
|
||||
marginBottom: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#D1D5DB',
|
||||
},
|
||||
initiateButton: {
|
||||
backgroundColor: '#3B82F6',
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
initiateButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
rewardsSection: {
|
||||
backgroundColor: 'rgba(0, 143, 67, 0.1)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
},
|
||||
rewardsSectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.kesk,
|
||||
marginBottom: 12,
|
||||
},
|
||||
rewardRow: {
|
||||
marginBottom: 6,
|
||||
},
|
||||
rewardText: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
doneButton: {
|
||||
backgroundColor: '#F5F5F5',
|
||||
paddingVertical: 16,
|
||||
marginHorizontal: 20,
|
||||
marginBottom: 20,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
doneButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { KurdistanColors } from '../../theme/colors';
|
||||
import { usePezkuwi } from '../../contexts/PezkuwiContext';
|
||||
|
||||
interface AddTokenModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onTokenAdded?: () => void;
|
||||
}
|
||||
|
||||
export const AddTokenModal: React.FC<AddTokenModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onTokenAdded,
|
||||
}) => {
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
const [assetId, setAssetId] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [tokenMetadata, setTokenMetadata] = useState<{
|
||||
symbol: string;
|
||||
decimals: number;
|
||||
name?: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleFetchMetadata = async () => {
|
||||
if (!api || !isApiReady) {
|
||||
Alert.alert('Error', 'API not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!assetId || isNaN(Number(assetId))) {
|
||||
Alert.alert('Error', 'Please enter a valid asset ID');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const assetIdNum = Number(assetId);
|
||||
|
||||
// Fetch asset metadata
|
||||
const metadataOption = await api.query.assets.metadata(assetIdNum);
|
||||
|
||||
if (metadataOption.isEmpty) {
|
||||
Alert.alert('Error', 'Asset not found');
|
||||
setTokenMetadata(null);
|
||||
} else {
|
||||
const metadata = metadataOption.toJSON() as any;
|
||||
setTokenMetadata({
|
||||
symbol: metadata.symbol || 'UNKNOWN',
|
||||
decimals: metadata.decimals || 12,
|
||||
name: metadata.name || 'Unknown Token',
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch token metadata:', error);
|
||||
Alert.alert('Error', 'Failed to fetch token metadata');
|
||||
setTokenMetadata(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToken = () => {
|
||||
if (!tokenMetadata) {
|
||||
Alert.alert('Error', 'Please fetch token metadata first');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the custom token in AsyncStorage or app state
|
||||
// For now, just show success and call the callback
|
||||
Alert.alert(
|
||||
'Success',
|
||||
`Token ${tokenMetadata.symbol} (ID: ${assetId}) added to your wallet!`,
|
||||
[
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => {
|
||||
handleClose();
|
||||
if (onTokenAdded) onTokenAdded();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAssetId('');
|
||||
setTokenMetadata(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalCard}>
|
||||
<Text style={styles.modalHeader}>Add Custom Token</Text>
|
||||
|
||||
<Text style={styles.instructions}>
|
||||
Enter the asset ID to add a custom token to your wallet
|
||||
</Text>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
style={styles.inputField}
|
||||
placeholder="Asset ID (e.g., 1000)"
|
||||
keyboardType="numeric"
|
||||
value={assetId}
|
||||
onChangeText={setAssetId}
|
||||
editable={!isLoading}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.fetchButton}
|
||||
onPress={handleFetchMetadata}
|
||||
disabled={isLoading || !assetId}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||
) : (
|
||||
<Text style={styles.fetchButtonText}>Fetch</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{tokenMetadata && (
|
||||
<View style={styles.metadataContainer}>
|
||||
<View style={styles.metadataRow}>
|
||||
<Text style={styles.metadataLabel}>Symbol:</Text>
|
||||
<Text style={styles.metadataValue}>{tokenMetadata.symbol}</Text>
|
||||
</View>
|
||||
<View style={styles.metadataRow}>
|
||||
<Text style={styles.metadataLabel}>Name:</Text>
|
||||
<Text style={styles.metadataValue}>{tokenMetadata.name}</Text>
|
||||
</View>
|
||||
<View style={styles.metadataRow}>
|
||||
<Text style={styles.metadataLabel}>Decimals:</Text>
|
||||
<Text style={styles.metadataValue}>{tokenMetadata.decimals}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.modalActions}>
|
||||
<TouchableOpacity style={styles.btnCancel} onPress={handleClose}>
|
||||
<Text style={styles.btnCancelText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.btnConfirm,
|
||||
!tokenMetadata && styles.btnConfirmDisabled,
|
||||
]}
|
||||
onPress={handleAddToken}
|
||||
disabled={!tokenMetadata}
|
||||
>
|
||||
<Text style={styles.btnConfirmText}>Add Token</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
modalCard: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
},
|
||||
modalHeader: {
|
||||
fontSize: 22,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
instructions: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginBottom: 20,
|
||||
},
|
||||
inputField: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
fetchButton: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minWidth: 80,
|
||||
},
|
||||
fetchButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
metadataContainer: {
|
||||
backgroundColor: '#F9F9F9',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
},
|
||||
metadataRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
metadataLabel: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
fontWeight: '500',
|
||||
},
|
||||
metadataValue: {
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
fontWeight: '600',
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
btnCancel: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#EEE',
|
||||
alignItems: 'center',
|
||||
},
|
||||
btnCancelText: {
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
fontWeight: '600',
|
||||
},
|
||||
btnConfirm: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
alignItems: 'center',
|
||||
},
|
||||
btnConfirmDisabled: {
|
||||
backgroundColor: '#CCC',
|
||||
},
|
||||
btnConfirmText: {
|
||||
fontSize: 16,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Environment Configuration
|
||||
*
|
||||
* Centralized access to environment variables from .env files
|
||||
* Use .env.development for dev mode, .env.production for production
|
||||
*/
|
||||
|
||||
import Constants from 'expo-constants';
|
||||
|
||||
export type Environment = 'development' | 'production' | 'staging';
|
||||
|
||||
interface EnvConfig {
|
||||
// Environment
|
||||
env: Environment;
|
||||
isDevelopment: boolean;
|
||||
isProduction: boolean;
|
||||
debug: boolean;
|
||||
|
||||
// Supabase
|
||||
supabaseUrl: string;
|
||||
supabaseAnonKey: string;
|
||||
|
||||
// Blockchain
|
||||
network: string;
|
||||
wsEndpoint: string;
|
||||
chainName: string;
|
||||
tokenSymbol: string;
|
||||
tokenDecimals: number;
|
||||
ss58Format: number;
|
||||
|
||||
// Feature Flags
|
||||
enableTestAccounts: boolean;
|
||||
enableMockData: boolean;
|
||||
enableDebugMenu: boolean;
|
||||
skipBiometric: boolean;
|
||||
|
||||
// API
|
||||
apiUrl: string;
|
||||
explorerUrl: string;
|
||||
}
|
||||
|
||||
// Get value from expo-constants with fallback
|
||||
function getEnvVar(key: string, defaultValue: string = ''): string {
|
||||
return Constants.expoConfig?.extra?.[key] || process.env[key] || defaultValue;
|
||||
}
|
||||
|
||||
function getBoolEnvVar(key: string, defaultValue: boolean = false): boolean {
|
||||
const value = getEnvVar(key, String(defaultValue));
|
||||
return value === 'true' || value === '1';
|
||||
}
|
||||
|
||||
function getNumberEnvVar(key: string, defaultValue: number = 0): number {
|
||||
const value = getEnvVar(key, String(defaultValue));
|
||||
return parseInt(value, 10) || defaultValue;
|
||||
}
|
||||
|
||||
// Parse environment
|
||||
const envString = getEnvVar('EXPO_PUBLIC_ENV', 'development') as Environment;
|
||||
|
||||
export const ENV: EnvConfig = {
|
||||
// Environment
|
||||
env: envString,
|
||||
isDevelopment: envString === 'development',
|
||||
isProduction: envString === 'production',
|
||||
debug: getBoolEnvVar('EXPO_PUBLIC_DEBUG', false),
|
||||
|
||||
// Supabase
|
||||
supabaseUrl: getEnvVar('EXPO_PUBLIC_SUPABASE_URL'),
|
||||
supabaseAnonKey: getEnvVar('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
|
||||
|
||||
// Blockchain
|
||||
network: getEnvVar('EXPO_PUBLIC_NETWORK', 'development'),
|
||||
wsEndpoint: getEnvVar('EXPO_PUBLIC_WS_ENDPOINT', 'wss://beta-rpc.pezkuwichain.io:19944'),
|
||||
chainName: getEnvVar('EXPO_PUBLIC_CHAIN_NAME', 'PezkuwiChain'),
|
||||
tokenSymbol: getEnvVar('EXPO_PUBLIC_CHAIN_TOKEN_SYMBOL', 'HEZ'),
|
||||
tokenDecimals: getNumberEnvVar('EXPO_PUBLIC_CHAIN_TOKEN_DECIMALS', 12),
|
||||
ss58Format: getNumberEnvVar('EXPO_PUBLIC_CHAIN_SS58_FORMAT', 42),
|
||||
|
||||
// Feature Flags
|
||||
enableTestAccounts: getBoolEnvVar('EXPO_PUBLIC_ENABLE_TEST_ACCOUNTS', false),
|
||||
enableMockData: getBoolEnvVar('EXPO_PUBLIC_ENABLE_MOCK_DATA', false),
|
||||
enableDebugMenu: getBoolEnvVar('EXPO_PUBLIC_ENABLE_DEBUG_MENU', false),
|
||||
skipBiometric: getBoolEnvVar('EXPO_PUBLIC_SKIP_BIOMETRIC', false),
|
||||
|
||||
// API
|
||||
apiUrl: getEnvVar('EXPO_PUBLIC_API_URL', 'https://api.pezkuwichain.io'),
|
||||
explorerUrl: getEnvVar('EXPO_PUBLIC_EXPLORER_URL', 'https://explorer.pezkuwichain.io'),
|
||||
};
|
||||
|
||||
// Log environment on startup (dev only)
|
||||
if (ENV.isDevelopment && ENV.debug) {
|
||||
console.log('🔧 Environment Config:', {
|
||||
env: ENV.env,
|
||||
network: ENV.network,
|
||||
wsEndpoint: ENV.wsEndpoint,
|
||||
testAccounts: ENV.enableTestAccounts,
|
||||
mockData: ENV.enableMockData,
|
||||
});
|
||||
}
|
||||
|
||||
export default ENV;
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Configuration Index
|
||||
*
|
||||
* Central export point for all configuration
|
||||
*/
|
||||
|
||||
export { ENV, type Environment } from './environment';
|
||||
export {
|
||||
TEST_ACCOUNTS,
|
||||
getTestAccount,
|
||||
getDefaultTestAccount,
|
||||
isTestAccountsEnabled,
|
||||
getTestAccountAddresses,
|
||||
isTestAccount,
|
||||
type TestAccount,
|
||||
} from './testAccounts';
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Test Accounts for Development
|
||||
*
|
||||
* Pre-funded test accounts (Alice, Bob, Charlie) for Zombienet development
|
||||
* These are well-known test accounts with pre-funded balances
|
||||
*
|
||||
* ⚠️ WARNING: NEVER use these in production! Only for dev/testing.
|
||||
*/
|
||||
|
||||
import ENV from './environment';
|
||||
|
||||
export interface TestAccount {
|
||||
name: string;
|
||||
mnemonic: string;
|
||||
address: string;
|
||||
derivationPath?: string;
|
||||
balance?: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard Substrate test accounts (Alice, Bob, Charlie, etc.)
|
||||
* These have pre-funded balances in dev chains
|
||||
*/
|
||||
export const TEST_ACCOUNTS: TestAccount[] = [
|
||||
{
|
||||
name: 'Alice',
|
||||
mnemonic: 'bottom drive obey lake curtain smoke basket hold race lonely fit walk',
|
||||
address: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', // //Alice
|
||||
description: 'Primary validator - Pre-funded development account',
|
||||
balance: '1,000,000 HEZ',
|
||||
},
|
||||
{
|
||||
name: 'Bob',
|
||||
mnemonic: 'bottom drive obey lake curtain smoke basket hold race lonely fit walk//Bob',
|
||||
address: '5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty', // //Bob
|
||||
description: 'Secondary validator - Pre-funded development account',
|
||||
balance: '1,000,000 HEZ',
|
||||
},
|
||||
{
|
||||
name: 'Charlie',
|
||||
mnemonic: 'bottom drive obey lake curtain smoke basket hold race lonely fit walk//Charlie',
|
||||
address: '5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y', // //Charlie
|
||||
description: 'Test user - Pre-funded development account',
|
||||
balance: '1,000,000 HEZ',
|
||||
},
|
||||
{
|
||||
name: 'Dave',
|
||||
mnemonic: 'bottom drive obey lake curtain smoke basket hold race lonely fit walk//Dave',
|
||||
address: '5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy', // //Dave
|
||||
description: 'Test user - Pre-funded development account',
|
||||
balance: '1,000,000 HEZ',
|
||||
},
|
||||
{
|
||||
name: 'Eve',
|
||||
mnemonic: 'bottom drive obey lake curtain smoke basket hold race lonely fit walk//Eve',
|
||||
address: '5HGjWAeFDfFCWPsjFQdVV2Msvz2XtMktvgocEZcCj68kUMaw', // //Eve
|
||||
description: 'Test user - Pre-funded development account',
|
||||
balance: '1,000,000 HEZ',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get test account by name
|
||||
*/
|
||||
export function getTestAccount(name: string): TestAccount | undefined {
|
||||
return TEST_ACCOUNTS.find(acc => acc.name.toLowerCase() === name.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if test accounts are enabled
|
||||
*/
|
||||
export function isTestAccountsEnabled(): boolean {
|
||||
return ENV.enableTestAccounts && ENV.isDevelopment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default test account (Alice)
|
||||
*/
|
||||
export function getDefaultTestAccount(): TestAccount {
|
||||
return TEST_ACCOUNTS[0]; // Alice
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all test account addresses
|
||||
*/
|
||||
export function getTestAccountAddresses(): string[] {
|
||||
return TEST_ACCOUNTS.map(acc => acc.address);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if address is a test account
|
||||
*/
|
||||
export function isTestAccount(address: string): boolean {
|
||||
return getTestAccountAddresses().includes(address);
|
||||
}
|
||||
|
||||
export default TEST_ACCOUNTS;
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { I18nManager } from 'react-native';
|
||||
import { saveLanguage, getCurrentLanguage, isRTL, LANGUAGE_KEY, languages } from '../i18n';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { isRTL, languages } from '../i18n';
|
||||
import i18n from '../i18n';
|
||||
|
||||
// Language is set at build time via environment variable
|
||||
const BUILD_LANGUAGE = process.env.EXPO_PUBLIC_DEFAULT_LANGUAGE || 'en';
|
||||
|
||||
interface Language {
|
||||
code: string;
|
||||
@@ -12,7 +15,6 @@ interface Language {
|
||||
|
||||
interface LanguageContextType {
|
||||
currentLanguage: string;
|
||||
changeLanguage: (languageCode: string) => Promise<void>;
|
||||
isRTL: boolean;
|
||||
hasSelectedLanguage: boolean;
|
||||
availableLanguages: Language[];
|
||||
@@ -21,52 +23,30 @@ interface LanguageContextType {
|
||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||
|
||||
export const LanguageProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [currentLanguage, setCurrentLanguage] = useState(getCurrentLanguage());
|
||||
const [hasSelectedLanguage, setHasSelectedLanguage] = useState(false);
|
||||
const [currentIsRTL, setCurrentIsRTL] = useState(isRTL());
|
||||
|
||||
const checkLanguageSelection = React.useCallback(async () => {
|
||||
try {
|
||||
const saved = await AsyncStorage.getItem(LANGUAGE_KEY);
|
||||
setHasSelectedLanguage(!!saved);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Failed to check language selection:', error);
|
||||
}
|
||||
}, []);
|
||||
// Language is fixed at build time - no runtime switching
|
||||
const [currentLanguage] = useState(BUILD_LANGUAGE);
|
||||
const [currentIsRTL] = useState(isRTL(BUILD_LANGUAGE));
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has already selected a language
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
checkLanguageSelection();
|
||||
}, [checkLanguageSelection]);
|
||||
// Initialize i18n with build-time language
|
||||
i18n.changeLanguage(BUILD_LANGUAGE);
|
||||
|
||||
const changeLanguage = async (languageCode: string) => {
|
||||
try {
|
||||
await saveLanguage(languageCode);
|
||||
setCurrentLanguage(languageCode);
|
||||
setHasSelectedLanguage(true);
|
||||
// Set RTL if needed
|
||||
const isRTLLanguage = ['ar', 'ckb', 'fa'].includes(BUILD_LANGUAGE);
|
||||
I18nManager.allowRTL(isRTLLanguage);
|
||||
I18nManager.forceRTL(isRTLLanguage);
|
||||
|
||||
const newIsRTL = isRTL(languageCode);
|
||||
setCurrentIsRTL(newIsRTL);
|
||||
|
||||
// Update RTL layout if needed
|
||||
if (I18nManager.isRTL !== newIsRTL) {
|
||||
// Note: Changing RTL requires app restart in React Native
|
||||
I18nManager.forceRTL(newIsRTL);
|
||||
// You may want to show a message to restart the app
|
||||
if (__DEV__) {
|
||||
console.log(`[LanguageContext] Build language: ${BUILD_LANGUAGE}, RTL: ${isRTLLanguage}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Failed to change language:', error);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider
|
||||
value={{
|
||||
currentLanguage,
|
||||
changeLanguage,
|
||||
isRTL: currentIsRTL,
|
||||
hasSelectedLanguage,
|
||||
hasSelectedLanguage: true, // Always true - language pre-selected at build time
|
||||
availableLanguages: languages,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { Keyring } from '@pezkuwi/keyring';
|
||||
import { KeyringPair } from '@pezkuwi/keyring/types';
|
||||
import { ApiPromise, WsProvider } from '@pezkuwi/api';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { cryptoWaitReady, mnemonicGenerate } from '@pezkuwi/util-crypto';
|
||||
import { ENV } from '../config/environment';
|
||||
|
||||
interface Account {
|
||||
address: string;
|
||||
name: string;
|
||||
meta?: {
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type NetworkType = 'pezkuwi' | 'dicle' | 'zagros' | 'bizinikiwi';
|
||||
|
||||
export interface NetworkConfig {
|
||||
name: string;
|
||||
displayName: string;
|
||||
rpcEndpoint: string;
|
||||
ss58Format: number;
|
||||
type: 'mainnet' | 'testnet' | 'canary';
|
||||
}
|
||||
|
||||
export const NETWORKS: Record<NetworkType, NetworkConfig> = {
|
||||
pezkuwi: {
|
||||
name: 'pezkuwi',
|
||||
displayName: 'Pezkuwi Mainnet',
|
||||
rpcEndpoint: 'wss://rpc-mainnet.pezkuwichain.io:9944',
|
||||
ss58Format: 42,
|
||||
type: 'mainnet',
|
||||
},
|
||||
dicle: {
|
||||
name: 'dicle',
|
||||
displayName: 'Dicle Testnet',
|
||||
rpcEndpoint: 'wss://rpc-dicle.pezkuwichain.io:9944',
|
||||
ss58Format: 2,
|
||||
type: 'testnet',
|
||||
},
|
||||
zagros: {
|
||||
name: 'zagros',
|
||||
displayName: 'Zagros Canary',
|
||||
rpcEndpoint: 'wss://rpc-zagros.pezkuwichain.io:9944',
|
||||
ss58Format: 42,
|
||||
type: 'canary',
|
||||
},
|
||||
bizinikiwi: {
|
||||
name: 'bizinikiwi',
|
||||
displayName: 'Bizinikiwi Testnet (Beta)',
|
||||
rpcEndpoint: ENV.wsEndpoint || 'wss://rpc.pezkuwichain.io:9944',
|
||||
ss58Format: 42,
|
||||
type: 'testnet',
|
||||
},
|
||||
};
|
||||
|
||||
interface PezkuwiContextType {
|
||||
// Chain state
|
||||
api: ApiPromise | null;
|
||||
isApiReady: boolean;
|
||||
// Keyring state
|
||||
isReady: boolean;
|
||||
accounts: Account[];
|
||||
selectedAccount: Account | null;
|
||||
setSelectedAccount: (account: Account | null) => void;
|
||||
// Network management
|
||||
currentNetwork: NetworkType;
|
||||
switchNetwork: (network: NetworkType) => Promise<void>;
|
||||
// Wallet operations
|
||||
connectWallet: () => Promise<void>;
|
||||
disconnectWallet: () => void;
|
||||
createWallet: (name: string, mnemonic?: string) => Promise<{ address: string; mnemonic: string }>;
|
||||
importWallet: (name: string, mnemonic: string) => Promise<{ address: string }>;
|
||||
getKeyPair: (address: string) => Promise<KeyringPair | null>;
|
||||
signMessage: (address: string, message: string) => Promise<string | null>;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const PezkuwiContext = createContext<PezkuwiContextType | undefined>(undefined);
|
||||
|
||||
const WALLET_STORAGE_KEY = '@pezkuwi_wallets';
|
||||
const SELECTED_ACCOUNT_KEY = '@pezkuwi_selected_account';
|
||||
const SELECTED_NETWORK_KEY = '@pezkuwi_selected_network';
|
||||
|
||||
interface PezkuwiProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const PezkuwiProvider: React.FC<PezkuwiProviderProps> = ({ children }) => {
|
||||
const [api, setApi] = useState<ApiPromise | null>(null);
|
||||
const [isApiReady, setIsApiReady] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
|
||||
const [currentNetwork, setCurrentNetwork] = useState<NetworkType>('bizinikiwi');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [keyring, setKeyring] = useState<Keyring | null>(null);
|
||||
|
||||
// Load saved network on mount
|
||||
useEffect(() => {
|
||||
const loadNetwork = async () => {
|
||||
try {
|
||||
const savedNetwork = await AsyncStorage.getItem(SELECTED_NETWORK_KEY);
|
||||
if (savedNetwork && savedNetwork in NETWORKS) {
|
||||
setCurrentNetwork(savedNetwork as NetworkType);
|
||||
}
|
||||
} catch (err) {
|
||||
if (__DEV__) console.error('[Pezkuwi] Failed to load network:', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadNetwork();
|
||||
}, []);
|
||||
|
||||
// Initialize blockchain connection
|
||||
useEffect(() => {
|
||||
let retryTimeout: NodeJS.Timeout;
|
||||
let isSubscribed = true;
|
||||
|
||||
const initApi = async () => {
|
||||
try {
|
||||
console.log('🔗 [Pezkuwi] Starting API initialization...');
|
||||
setIsApiReady(false);
|
||||
setError(null); // Clear previous errors
|
||||
|
||||
const networkConfig = NETWORKS[currentNetwork];
|
||||
console.log(`🌐 [Pezkuwi] Connecting to ${networkConfig.displayName} at ${networkConfig.rpcEndpoint}`);
|
||||
|
||||
const provider = new WsProvider(networkConfig.rpcEndpoint);
|
||||
console.log('📡 [Pezkuwi] WsProvider created, creating API...');
|
||||
const newApi = await ApiPromise.create({ provider });
|
||||
console.log('✅ [Pezkuwi] API created successfully');
|
||||
|
||||
if (isSubscribed) {
|
||||
setApi(newApi);
|
||||
setIsApiReady(true);
|
||||
setError(null); // Clear any previous errors
|
||||
console.log('✅ [Pezkuwi] Connected to', networkConfig.displayName);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ [Pezkuwi] Failed to connect to blockchain:', err);
|
||||
console.error('❌ [Pezkuwi] Error details:', JSON.stringify(err, null, 2));
|
||||
|
||||
if (isSubscribed) {
|
||||
setError('Failed to connect to blockchain. Check your internet connection.');
|
||||
setIsApiReady(false); // ✅ FIX: Don't set ready on error
|
||||
setApi(null); // ✅ FIX: Clear API on error
|
||||
|
||||
// Retry connection after 5 seconds
|
||||
console.log('🔄 [Pezkuwi] Will retry connection in 5 seconds...');
|
||||
retryTimeout = setTimeout(() => {
|
||||
if (isSubscribed) {
|
||||
console.log('🔄 [Pezkuwi] Retrying blockchain connection...');
|
||||
initApi();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initApi();
|
||||
|
||||
// Cleanup on network change or unmount
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
if (retryTimeout) {
|
||||
clearTimeout(retryTimeout);
|
||||
}
|
||||
if (api) {
|
||||
api.disconnect();
|
||||
}
|
||||
};
|
||||
}, [currentNetwork]);
|
||||
|
||||
// Initialize crypto and keyring
|
||||
useEffect(() => {
|
||||
const initCrypto = async () => {
|
||||
try {
|
||||
console.log('🔐 [Pezkuwi] Starting crypto initialization...');
|
||||
console.log('⏳ [Pezkuwi] Waiting for crypto libraries...');
|
||||
|
||||
await cryptoWaitReady();
|
||||
console.log('✅ [Pezkuwi] Crypto wait ready completed');
|
||||
|
||||
const networkConfig = NETWORKS[currentNetwork];
|
||||
console.log(`🌐 [Pezkuwi] Creating keyring for ${networkConfig.displayName}`);
|
||||
|
||||
const kr = new Keyring({ type: 'sr25519', ss58Format: networkConfig.ss58Format });
|
||||
setKeyring(kr);
|
||||
setIsReady(true);
|
||||
console.log('✅ [Pezkuwi] Crypto libraries initialized successfully');
|
||||
} catch (err) {
|
||||
console.error('❌ [Pezkuwi] Failed to initialize crypto:', err);
|
||||
console.error('❌ [Pezkuwi] Error details:', JSON.stringify(err, null, 2));
|
||||
setError('Failed to initialize crypto libraries');
|
||||
// Still set ready to allow app to work without crypto
|
||||
setIsReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
initCrypto();
|
||||
}, [currentNetwork]);
|
||||
|
||||
// Load stored accounts on mount
|
||||
useEffect(() => {
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(WALLET_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const wallets = JSON.parse(stored);
|
||||
setAccounts(wallets);
|
||||
|
||||
// Load selected account
|
||||
const selectedAddr = await AsyncStorage.getItem(SELECTED_ACCOUNT_KEY);
|
||||
if (selectedAddr) {
|
||||
const account = wallets.find((w: Account) => w.address === selectedAddr);
|
||||
if (account) {
|
||||
setSelectedAccount(account);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (__DEV__) console.error('[Pezkuwi] Failed to load accounts:', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadAccounts();
|
||||
}, []);
|
||||
|
||||
// Create a new wallet
|
||||
const createWallet = async (
|
||||
name: string,
|
||||
mnemonic?: string
|
||||
): Promise<{ address: string; mnemonic: string }> => {
|
||||
if (!keyring) {
|
||||
throw new Error('Keyring not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate or use provided mnemonic
|
||||
const mnemonicPhrase = mnemonic || mnemonicGenerate(12);
|
||||
|
||||
// Create account from mnemonic
|
||||
const pair = keyring.addFromMnemonic(mnemonicPhrase, { name });
|
||||
|
||||
const newAccount: Account = {
|
||||
address: pair.address,
|
||||
name,
|
||||
meta: { name },
|
||||
};
|
||||
|
||||
// Store account (address only, not the seed!)
|
||||
const updatedAccounts = [...accounts, newAccount];
|
||||
setAccounts(updatedAccounts);
|
||||
await AsyncStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify(updatedAccounts));
|
||||
|
||||
// SECURITY: Store encrypted seed in SecureStore (hardware-backed storage)
|
||||
const seedKey = `pezkuwi_seed_${pair.address}`;
|
||||
await SecureStore.setItemAsync(seedKey, mnemonicPhrase);
|
||||
|
||||
if (__DEV__) console.log('[Pezkuwi] Wallet created:', pair.address);
|
||||
|
||||
return {
|
||||
address: pair.address,
|
||||
mnemonic: mnemonicPhrase,
|
||||
};
|
||||
} catch (err) {
|
||||
if (__DEV__) console.error('[Pezkuwi] Failed to create wallet:', err);
|
||||
throw new Error('Failed to create wallet');
|
||||
}
|
||||
};
|
||||
|
||||
// Import existing wallet from mnemonic
|
||||
const importWallet = async (
|
||||
name: string,
|
||||
mnemonic: string
|
||||
): Promise<{ address: string }> => {
|
||||
if (!keyring) {
|
||||
throw new Error('Keyring not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Create account from mnemonic
|
||||
const pair = keyring.addFromMnemonic(mnemonic.trim(), { name });
|
||||
|
||||
// Check if account already exists
|
||||
if (accounts.some(a => a.address === pair.address)) {
|
||||
throw new Error('Wallet already exists');
|
||||
}
|
||||
|
||||
const newAccount: Account = {
|
||||
address: pair.address,
|
||||
name,
|
||||
meta: { name },
|
||||
};
|
||||
|
||||
// Store account
|
||||
const updatedAccounts = [...accounts, newAccount];
|
||||
setAccounts(updatedAccounts);
|
||||
await AsyncStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify(updatedAccounts));
|
||||
|
||||
// Store seed securely
|
||||
const seedKey = `pezkuwi_seed_${pair.address}`;
|
||||
await SecureStore.setItemAsync(seedKey, mnemonic.trim());
|
||||
|
||||
if (__DEV__) console.log('[Pezkuwi] Wallet imported:', pair.address);
|
||||
|
||||
return { address: pair.address };
|
||||
} catch (err) {
|
||||
if (__DEV__) console.error('[Pezkuwi] Failed to import wallet:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Get keypair for signing transactions
|
||||
const getKeyPair = async (address: string): Promise<KeyringPair | null> => {
|
||||
if (!keyring) {
|
||||
throw new Error('Keyring not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// SECURITY: Load seed from SecureStore (encrypted storage)
|
||||
const seedKey = `pezkuwi_seed_${address}`;
|
||||
const mnemonic = await SecureStore.getItemAsync(seedKey);
|
||||
|
||||
if (!mnemonic) {
|
||||
if (__DEV__) console.error('[Pezkuwi] No seed found for address:', address);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Recreate keypair from mnemonic
|
||||
const pair = keyring.addFromMnemonic(mnemonic);
|
||||
return pair;
|
||||
} catch (err) {
|
||||
if (__DEV__) console.error('[Pezkuwi] Failed to get keypair:', err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Sign a message with the keypair
|
||||
const signMessage = async (address: string, message: string): Promise<string | null> => {
|
||||
try {
|
||||
const pair = await getKeyPair(address);
|
||||
if (!pair) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sign the message
|
||||
const signature = pair.sign(message);
|
||||
// Convert to hex string
|
||||
const signatureHex = Buffer.from(signature).toString('hex');
|
||||
return signatureHex;
|
||||
} catch (err) {
|
||||
if (__DEV__) console.error('[Pezkuwi] Failed to sign message:', err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Connect wallet (load existing accounts)
|
||||
const connectWallet = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
if (accounts.length === 0) {
|
||||
setError('No wallets found. Please create a wallet first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-select first account if none selected
|
||||
if (!selectedAccount && accounts.length > 0) {
|
||||
setSelectedAccount(accounts[0]);
|
||||
await AsyncStorage.setItem(SELECTED_ACCOUNT_KEY, accounts[0].address);
|
||||
}
|
||||
|
||||
if (__DEV__) console.log(`[Pezkuwi] Connected with ${accounts.length} account(s)`);
|
||||
} catch (err) {
|
||||
if (__DEV__) console.error('[Pezkuwi] Wallet connection failed:', err);
|
||||
setError('Failed to connect wallet');
|
||||
}
|
||||
};
|
||||
|
||||
// Disconnect wallet
|
||||
const disconnectWallet = () => {
|
||||
setSelectedAccount(null);
|
||||
AsyncStorage.removeItem(SELECTED_ACCOUNT_KEY);
|
||||
if (__DEV__) console.log('[Pezkuwi] Wallet disconnected');
|
||||
};
|
||||
|
||||
// Switch network
|
||||
const switchNetwork = async (network: NetworkType) => {
|
||||
try {
|
||||
if (network === currentNetwork) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (__DEV__) console.log('[Pezkuwi] Switching to network:', NETWORKS[network].displayName);
|
||||
|
||||
// Save network preference
|
||||
await AsyncStorage.setItem(SELECTED_NETWORK_KEY, network);
|
||||
|
||||
// Update state (will trigger useEffect to reconnect)
|
||||
setCurrentNetwork(network);
|
||||
setIsApiReady(false);
|
||||
|
||||
if (__DEV__) console.log('[Pezkuwi] Network switched successfully');
|
||||
} catch (err) {
|
||||
if (__DEV__) console.error('[Pezkuwi] Failed to switch network:', err);
|
||||
setError('Failed to switch network');
|
||||
}
|
||||
};
|
||||
|
||||
// Update selected account storage when it changes
|
||||
useEffect(() => {
|
||||
if (selectedAccount) {
|
||||
AsyncStorage.setItem(SELECTED_ACCOUNT_KEY, selectedAccount.address);
|
||||
}
|
||||
}, [selectedAccount]);
|
||||
|
||||
const value: PezkuwiContextType = {
|
||||
api,
|
||||
isApiReady,
|
||||
isReady,
|
||||
accounts,
|
||||
selectedAccount,
|
||||
setSelectedAccount,
|
||||
currentNetwork,
|
||||
switchNetwork,
|
||||
connectWallet,
|
||||
disconnectWallet,
|
||||
createWallet,
|
||||
importWallet,
|
||||
getKeyPair,
|
||||
signMessage,
|
||||
error,
|
||||
};
|
||||
|
||||
return <PezkuwiContext.Provider value={value}>{children}</PezkuwiContext.Provider>;
|
||||
};
|
||||
|
||||
// Hook to use Pezkuwi context
|
||||
export const usePezkuwi = (): PezkuwiContextType => {
|
||||
const context = useContext(PezkuwiContext);
|
||||
if (!context) {
|
||||
throw new Error('usePezkuwi must be used within PezkuwiProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,269 +0,0 @@
|
||||
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { ApiPromise, WsProvider } from '@pezkuwi/api';
|
||||
import { Keyring } from '@pezkuwi/keyring';
|
||||
import { KeyringPair } from '@pezkuwi/keyring/types';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { cryptoWaitReady } from '@pezkuwi/util-crypto';
|
||||
import { DEFAULT_ENDPOINT } from '../../../shared/blockchain/polkadot';
|
||||
|
||||
interface Account {
|
||||
address: string;
|
||||
name: string;
|
||||
meta?: {
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PolkadotContextType {
|
||||
api: ApiPromise | null;
|
||||
isApiReady: boolean;
|
||||
isConnected: boolean;
|
||||
accounts: Account[];
|
||||
selectedAccount: Account | null;
|
||||
setSelectedAccount: (account: Account | null) => void;
|
||||
connectWallet: () => Promise<void>;
|
||||
disconnectWallet: () => void;
|
||||
createWallet: (name: string, mnemonic?: string) => Promise<{ address: string; mnemonic: string }>;
|
||||
getKeyPair: (address: string) => Promise<KeyringPair | null>;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const PolkadotContext = createContext<PolkadotContextType | undefined>(undefined);
|
||||
|
||||
const WALLET_STORAGE_KEY = '@pezkuwi_wallets';
|
||||
const SELECTED_ACCOUNT_KEY = '@pezkuwi_selected_account';
|
||||
|
||||
interface PolkadotProviderProps {
|
||||
children: ReactNode;
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
export const PolkadotProvider: React.FC<PolkadotProviderProps> = ({
|
||||
children,
|
||||
endpoint = DEFAULT_ENDPOINT, // Beta testnet RPC from shared config
|
||||
}) => {
|
||||
const [api, setApi] = useState<ApiPromise | null>(null);
|
||||
const [isApiReady, setIsApiReady] = useState(false);
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [keyring, setKeyring] = useState<Keyring | null>(null);
|
||||
|
||||
// Initialize crypto and keyring
|
||||
useEffect(() => {
|
||||
const initCrypto = async () => {
|
||||
try {
|
||||
await cryptoWaitReady();
|
||||
const kr = new Keyring({ type: 'sr25519' });
|
||||
setKeyring(kr);
|
||||
if (__DEV__) console.warn('✅ Crypto libraries initialized');
|
||||
} catch (err) {
|
||||
if (__DEV__) console.error('❌ Failed to initialize crypto:', err);
|
||||
setError('Failed to initialize crypto libraries');
|
||||
}
|
||||
};
|
||||
|
||||
initCrypto();
|
||||
}, []);
|
||||
|
||||
// Initialize Polkadot API
|
||||
useEffect(() => {
|
||||
const initApi = async () => {
|
||||
try {
|
||||
if (__DEV__) console.warn('🔗 Connecting to Pezkuwi node:', endpoint);
|
||||
|
||||
const provider = new WsProvider(endpoint);
|
||||
const apiInstance = await ApiPromise.create({ provider });
|
||||
|
||||
await apiInstance.isReady;
|
||||
|
||||
setApi(apiInstance);
|
||||
setIsApiReady(true);
|
||||
setError(null);
|
||||
|
||||
if (__DEV__) console.warn('✅ Connected to Pezkuwi node');
|
||||
|
||||
// Get chain info
|
||||
const [chain, nodeName, nodeVersion] = await Promise.all([
|
||||
apiInstance.rpc.system.chain(),
|
||||
apiInstance.rpc.system.name(),
|
||||
apiInstance.rpc.system.version(),
|
||||
]);
|
||||
|
||||
if (__DEV__) {
|
||||
console.warn(`📡 Chain: ${chain}`);
|
||||
console.warn(`🖥️ Node: ${nodeName} v${nodeVersion}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (__DEV__) console.error('❌ Failed to connect to node:', err);
|
||||
setError(`Failed to connect to node: ${endpoint}`);
|
||||
setIsApiReady(false);
|
||||
}
|
||||
};
|
||||
|
||||
initApi();
|
||||
|
||||
return () => {
|
||||
if (api) {
|
||||
api.disconnect();
|
||||
}
|
||||
};
|
||||
}, [endpoint, api]);
|
||||
|
||||
// Load stored accounts on mount
|
||||
useEffect(() => {
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(WALLET_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const wallets = JSON.parse(stored);
|
||||
setAccounts(wallets);
|
||||
|
||||
// Load selected account
|
||||
const selectedAddr = await AsyncStorage.getItem(SELECTED_ACCOUNT_KEY);
|
||||
if (selectedAddr) {
|
||||
const account = wallets.find((w: Account) => w.address === selectedAddr);
|
||||
if (account) {
|
||||
setSelectedAccount(account);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (__DEV__) console.error('Failed to load accounts:', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadAccounts();
|
||||
}, []);
|
||||
|
||||
// Create a new wallet
|
||||
const createWallet = async (
|
||||
name: string,
|
||||
mnemonic?: string
|
||||
): Promise<{ address: string; mnemonic: string }> => {
|
||||
if (!keyring) {
|
||||
throw new Error('Keyring not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate or use provided mnemonic
|
||||
const mnemonicPhrase = mnemonic || Keyring.prototype.generateMnemonic();
|
||||
|
||||
// Create account from mnemonic
|
||||
const pair = keyring.addFromMnemonic(mnemonicPhrase, { name });
|
||||
|
||||
const newAccount: Account = {
|
||||
address: pair.address,
|
||||
name,
|
||||
meta: { name },
|
||||
};
|
||||
|
||||
// Store account (address only, not the seed!)
|
||||
const updatedAccounts = [...accounts, newAccount];
|
||||
setAccounts(updatedAccounts);
|
||||
await AsyncStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify(updatedAccounts));
|
||||
|
||||
// SECURITY: Store encrypted seed in SecureStore (encrypted hardware-backed storage)
|
||||
const seedKey = `pezkuwi_seed_${pair.address}`;
|
||||
await SecureStore.setItemAsync(seedKey, mnemonicPhrase);
|
||||
|
||||
if (__DEV__) console.warn('✅ Wallet created:', pair.address);
|
||||
|
||||
return {
|
||||
address: pair.address,
|
||||
mnemonic: mnemonicPhrase,
|
||||
};
|
||||
} catch (err) {
|
||||
if (__DEV__) console.error('❌ Failed to create wallet:', err);
|
||||
throw new Error('Failed to create wallet');
|
||||
}
|
||||
};
|
||||
|
||||
// Get keypair for signing transactions
|
||||
const getKeyPair = async (address: string): Promise<KeyringPair | null> => {
|
||||
if (!keyring) {
|
||||
throw new Error('Keyring not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// SECURITY: Load seed from SecureStore (encrypted storage)
|
||||
const seedKey = `pezkuwi_seed_${address}`;
|
||||
const mnemonic = await SecureStore.getItemAsync(seedKey);
|
||||
|
||||
if (!mnemonic) {
|
||||
if (__DEV__) console.error('No seed found for address:', address);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Recreate keypair from mnemonic
|
||||
const pair = keyring.addFromMnemonic(mnemonic);
|
||||
return pair;
|
||||
} catch (err) {
|
||||
if (__DEV__) console.error('Failed to get keypair:', err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Connect wallet (load existing accounts)
|
||||
const connectWallet = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
if (accounts.length === 0) {
|
||||
setError('No wallets found. Please create a wallet first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-select first account if none selected
|
||||
if (!selectedAccount && accounts.length > 0) {
|
||||
setSelectedAccount(accounts[0]);
|
||||
await AsyncStorage.setItem(SELECTED_ACCOUNT_KEY, accounts[0].address);
|
||||
}
|
||||
|
||||
if (__DEV__) console.warn(`✅ Connected with ${accounts.length} account(s)`);
|
||||
} catch (err) {
|
||||
if (__DEV__) console.error('❌ Wallet connection failed:', err);
|
||||
setError('Failed to connect wallet');
|
||||
}
|
||||
};
|
||||
|
||||
// Disconnect wallet
|
||||
const disconnectWallet = () => {
|
||||
setSelectedAccount(null);
|
||||
AsyncStorage.removeItem(SELECTED_ACCOUNT_KEY);
|
||||
if (__DEV__) console.warn('🔌 Wallet disconnected');
|
||||
};
|
||||
|
||||
// Update selected account storage when it changes
|
||||
useEffect(() => {
|
||||
if (selectedAccount) {
|
||||
AsyncStorage.setItem(SELECTED_ACCOUNT_KEY, selectedAccount.address);
|
||||
}
|
||||
}, [selectedAccount]);
|
||||
|
||||
const value: PolkadotContextType = {
|
||||
api,
|
||||
isApiReady,
|
||||
isConnected: isApiReady,
|
||||
accounts,
|
||||
selectedAccount,
|
||||
setSelectedAccount,
|
||||
connectWallet,
|
||||
disconnectWallet,
|
||||
createWallet,
|
||||
getKeyPair,
|
||||
error,
|
||||
};
|
||||
|
||||
return <PolkadotContext.Provider value={value}>{children}</PolkadotContext.Provider>;
|
||||
};
|
||||
|
||||
// Hook to use Polkadot context
|
||||
export const usePolkadot = (): PolkadotContextType => {
|
||||
const context = useContext(PolkadotContext);
|
||||
if (!context) {
|
||||
throw new Error('usePolkadot must be used within PolkadotProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
+16
-16
@@ -1,20 +1,20 @@
|
||||
import React from 'react';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react-native';
|
||||
import { PolkadotProvider, usePolkadot } from '../PolkadotContext';
|
||||
import { PezkuwiProvider, usePezkuwi } from './PezkuwiContext';
|
||||
import { ApiPromise } from '@pezkuwi/api';
|
||||
|
||||
// Wrapper for provider
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<PolkadotProvider>{children}</PolkadotProvider>
|
||||
<PezkuwiProvider>{children}</PezkuwiProvider>
|
||||
);
|
||||
|
||||
describe('PolkadotContext', () => {
|
||||
describe('PezkuwiContext', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should provide polkadot context', () => {
|
||||
const { result } = renderHook(() => usePolkadot(), { wrapper });
|
||||
it('should provide pezkuwi context', () => {
|
||||
const { result } = renderHook(() => usePezkuwi(), { wrapper });
|
||||
|
||||
expect(result.current).toBeDefined();
|
||||
expect(result.current.api).toBeNull();
|
||||
@@ -23,7 +23,7 @@ describe('PolkadotContext', () => {
|
||||
});
|
||||
|
||||
it('should initialize API connection', async () => {
|
||||
const { result } = renderHook(() => usePolkadot(), { wrapper });
|
||||
const { result } = renderHook(() => usePezkuwi(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isApiReady).toBe(false); // Mock doesn't complete
|
||||
@@ -31,14 +31,14 @@ describe('PolkadotContext', () => {
|
||||
});
|
||||
|
||||
it('should provide connectWallet function', () => {
|
||||
const { result } = renderHook(() => usePolkadot(), { wrapper });
|
||||
const { result } = renderHook(() => usePezkuwi(), { wrapper });
|
||||
|
||||
expect(result.current.connectWallet).toBeDefined();
|
||||
expect(typeof result.current.connectWallet).toBe('function');
|
||||
});
|
||||
|
||||
it('should handle disconnectWallet', () => {
|
||||
const { result } = renderHook(() => usePolkadot(), { wrapper });
|
||||
const { result } = renderHook(() => usePezkuwi(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.disconnectWallet();
|
||||
@@ -48,14 +48,14 @@ describe('PolkadotContext', () => {
|
||||
});
|
||||
|
||||
it('should provide setSelectedAccount function', () => {
|
||||
const { result } = renderHook(() => usePolkadot(), { wrapper });
|
||||
const { result } = renderHook(() => usePezkuwi(), { wrapper });
|
||||
|
||||
expect(result.current.setSelectedAccount).toBeDefined();
|
||||
expect(typeof result.current.setSelectedAccount).toBe('function');
|
||||
});
|
||||
|
||||
it('should set selected account', () => {
|
||||
const { result } = renderHook(() => usePolkadot(), { wrapper });
|
||||
const { result } = renderHook(() => usePezkuwi(), { wrapper });
|
||||
|
||||
const testAccount = { address: '5test', name: 'Test Account' };
|
||||
|
||||
@@ -67,31 +67,31 @@ describe('PolkadotContext', () => {
|
||||
});
|
||||
|
||||
it('should provide getKeyPair function', () => {
|
||||
const { result } = renderHook(() => usePolkadot(), { wrapper });
|
||||
const { result } = renderHook(() => usePezkuwi(), { wrapper });
|
||||
|
||||
expect(result.current.getKeyPair).toBeDefined();
|
||||
expect(typeof result.current.getKeyPair).toBe('function');
|
||||
});
|
||||
|
||||
it('should throw error when usePolkadot is used outside provider', () => {
|
||||
it('should throw error when usePezkuwi is used outside provider', () => {
|
||||
// Suppress console error for this test
|
||||
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => usePolkadot());
|
||||
}).toThrow('usePolkadot must be used within PolkadotProvider');
|
||||
renderHook(() => usePezkuwi());
|
||||
}).toThrow('usePezkuwi must be used within PezkuwiProvider');
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle accounts array', () => {
|
||||
const { result } = renderHook(() => usePolkadot(), { wrapper });
|
||||
const { result } = renderHook(() => usePezkuwi(), { wrapper });
|
||||
|
||||
expect(Array.isArray(result.current.accounts)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle error state', () => {
|
||||
const { result } = renderHook(() => usePolkadot(), { wrapper });
|
||||
const { result } = renderHook(() => usePezkuwi(), { wrapper });
|
||||
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
+19
-39
@@ -1,75 +1,55 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
// Import shared translations and language configurations
|
||||
import {
|
||||
translations,
|
||||
comprehensiveTranslations as translations,
|
||||
LANGUAGES,
|
||||
DEFAULT_LANGUAGE,
|
||||
LANGUAGE_STORAGE_KEY,
|
||||
isRTL as checkIsRTL,
|
||||
} from '../../../shared/i18n';
|
||||
|
||||
// Language storage key (re-export for compatibility)
|
||||
export const LANGUAGE_KEY = LANGUAGE_STORAGE_KEY;
|
||||
// Language is set at build time via environment variable
|
||||
const BUILD_LANGUAGE = (process.env.EXPO_PUBLIC_DEFAULT_LANGUAGE || DEFAULT_LANGUAGE) as string;
|
||||
|
||||
// Available languages (re-export for compatibility)
|
||||
export const languages = LANGUAGES;
|
||||
|
||||
// Initialize i18n
|
||||
const initializeI18n = async () => {
|
||||
// Try to get saved language
|
||||
let savedLanguage = DEFAULT_LANGUAGE;
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(LANGUAGE_KEY);
|
||||
if (stored) {
|
||||
savedLanguage = stored;
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.warn('Failed to load saved language:', error);
|
||||
// Initialize i18n with build-time language only
|
||||
const initializeI18n = () => {
|
||||
if (__DEV__) {
|
||||
console.log(`[i18n] Initializing with build language: ${BUILD_LANGUAGE}`);
|
||||
}
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { translation: translations.en },
|
||||
tr: { translation: translations.tr },
|
||||
kmr: { translation: translations.kmr },
|
||||
ckb: { translation: translations.ckb },
|
||||
ar: { translation: translations.ar },
|
||||
fa: { translation: translations.fa },
|
||||
// Only load the build-time language (reduces APK size)
|
||||
[BUILD_LANGUAGE]: { translation: translations[BUILD_LANGUAGE as keyof typeof translations] },
|
||||
},
|
||||
lng: savedLanguage,
|
||||
fallbackLng: DEFAULT_LANGUAGE,
|
||||
lng: BUILD_LANGUAGE,
|
||||
fallbackLng: BUILD_LANGUAGE,
|
||||
compatibilityJSON: 'v3',
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
return savedLanguage;
|
||||
return BUILD_LANGUAGE;
|
||||
};
|
||||
|
||||
// Save language preference
|
||||
export const saveLanguage = async (languageCode: string) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(LANGUAGE_KEY, languageCode);
|
||||
await i18n.changeLanguage(languageCode);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Failed to save language:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Get current language
|
||||
export const getCurrentLanguage = () => i18n.language;
|
||||
// Get current language (always returns BUILD_LANGUAGE)
|
||||
export const getCurrentLanguage = () => BUILD_LANGUAGE;
|
||||
|
||||
// Check if language is RTL
|
||||
export const isRTL = (languageCode?: string) => {
|
||||
const code = languageCode || i18n.language;
|
||||
const code = languageCode || BUILD_LANGUAGE;
|
||||
return checkIsRTL(code);
|
||||
};
|
||||
|
||||
export { initializeI18n };
|
||||
// Initialize i18n automatically
|
||||
initializeI18n();
|
||||
|
||||
export { initializeI18n, BUILD_LANGUAGE };
|
||||
export default i18n;
|
||||
|
||||
@@ -138,12 +138,77 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "الإعدادات",
|
||||
"sections": {
|
||||
"appearance": "المظهر",
|
||||
"language": "اللغة",
|
||||
"theme": "المظهر",
|
||||
"notifications": "الإشعارات",
|
||||
"security": "الأمان",
|
||||
"about": "حول",
|
||||
"logout": "تسجيل الخروج"
|
||||
"notifications": "الإشعارات",
|
||||
"about": "حول"
|
||||
},
|
||||
"appearance": {
|
||||
"darkMode": "الوضع الداكن",
|
||||
"darkModeSubtitle": "التبديل بين السمة الفاتحة والداكنة",
|
||||
"fontSize": "حجم الخط",
|
||||
"fontSizeSubtitle": "الحالي: {{size}}",
|
||||
"fontSizePrompt": "اختر حجم الخط المفضل لديك",
|
||||
"small": "صغير",
|
||||
"medium": "متوسط",
|
||||
"large": "كبير"
|
||||
},
|
||||
"language": {
|
||||
"title": "اللغة",
|
||||
"changePrompt": "التبديل إلى {{language}}؟",
|
||||
"changeSuccess": "تم تحديث اللغة بنجاح!"
|
||||
},
|
||||
"security": {
|
||||
"biometric": "المصادقة البيومترية",
|
||||
"biometricSubtitle": "استخدم بصمة الإصبع أو التعرف على الوجه",
|
||||
"biometricPrompt": "هل تريد تفعيل المصادقة البيومترية؟",
|
||||
"biometricEnabled": "تم تفعيل المصادقة البيومترية",
|
||||
"twoFactor": "المصادقة الثنائية",
|
||||
"twoFactorSubtitle": "أضف طبقة أمان إضافية",
|
||||
"twoFactorPrompt": "المصادقة الثنائية تضيف طبقة أمان إضافية.",
|
||||
"twoFactorSetup": "إعداد",
|
||||
"changePassword": "تغيير كلمة المرور",
|
||||
"changePasswordSubtitle": "تحديث كلمة مرور حسابك"
|
||||
},
|
||||
"notifications": {
|
||||
"push": "الإشعارات الفورية",
|
||||
"pushSubtitle": "تلقي التنبيهات والتحديثات",
|
||||
"email": "إشعارات البريد الإلكتروني",
|
||||
"emailSubtitle": "إدارة تفضيلات البريد الإلكتروني"
|
||||
},
|
||||
"about": {
|
||||
"pezkuwi": "حول بيزكوي",
|
||||
"pezkuwiSubtitle": "تعرف أكثر على كردستان الرقمية",
|
||||
"pezkuwiMessage": "بيزكوي هو منصة بلوكتشين لامركزية لكردستان الرقمية.\n\nالإصدار: 1.0.0\n\nصُنع بـ ❤️",
|
||||
"terms": "شروط الخدمة",
|
||||
"privacy": "سياسة الخصوصية",
|
||||
"contact": "اتصل بالدعم",
|
||||
"contactSubtitle": "احصل على مساعدة من فريقنا",
|
||||
"contactEmail": "البريد الإلكتروني: support@pezkuwichain.io"
|
||||
},
|
||||
"version": {
|
||||
"app": "بيزكوي موبايل",
|
||||
"number": "الإصدار 1.0.0",
|
||||
"copyright": "© 2026 كردستان الرقمية"
|
||||
},
|
||||
"alerts": {
|
||||
"comingSoon": "قريباً",
|
||||
"darkModeMessage": "الوضع الداكن سيكون متاحاً قريباً",
|
||||
"twoFactorMessage": "إعداد المصادقة الثنائية سيكون متاحاً قريباً",
|
||||
"passwordMessage": "تغيير كلمة المرور سيكون متاحاً قريباً",
|
||||
"emailMessage": "إعدادات البريد الإلكتروني ستكون متاحة قريباً",
|
||||
"termsMessage": "شروط الخدمة ستكون متاحة قريباً",
|
||||
"privacyMessage": "سياسة الخصوصية ستكون متاحة قريباً"
|
||||
},
|
||||
"common": {
|
||||
"enable": "تفعيل",
|
||||
"cancel": "إلغاء",
|
||||
"confirm": "تأكيد",
|
||||
"success": "نجح",
|
||||
"error": "خطأ"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"cancel": "إلغاء",
|
||||
|
||||
@@ -138,12 +138,77 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "ڕێکخستنەکان",
|
||||
"sections": {
|
||||
"appearance": "دەرکەوتن",
|
||||
"language": "زمان",
|
||||
"theme": "ڕووکار",
|
||||
"security": "ئاسایش",
|
||||
"notifications": "ئاگادارییەکان",
|
||||
"security": "پاراستن",
|
||||
"about": "دەربارە",
|
||||
"logout": "دەرچوون"
|
||||
"about": "دەربارە"
|
||||
},
|
||||
"appearance": {
|
||||
"darkMode": "دۆخی تاریک",
|
||||
"darkModeSubtitle": "لە نێوان دۆخی ڕووناک و تاریک بگۆڕە",
|
||||
"fontSize": "قەبارەی فۆنت",
|
||||
"fontSizeSubtitle": "ئێستا: {{size}}",
|
||||
"fontSizePrompt": "قەبارەی فۆنتی دڵخوازت هەڵبژێرە",
|
||||
"small": "بچووک",
|
||||
"medium": "مامناوەند",
|
||||
"large": "گەورە"
|
||||
},
|
||||
"language": {
|
||||
"title": "زمان",
|
||||
"changePrompt": "بگۆڕدرێت بۆ {{language}}؟",
|
||||
"changeSuccess": "زمان بە سەرکەوتوویی نوێکرایەوە!"
|
||||
},
|
||||
"security": {
|
||||
"biometric": "ناسینەوەی بایۆمێتریک",
|
||||
"biometricSubtitle": "پەنجە نوێن یان ناسینەوەی ڕوخسار بەکاربهێنە",
|
||||
"biometricPrompt": "دەتەوێت ناسینەوەی بایۆمێتریک چالاک بکەیت؟",
|
||||
"biometricEnabled": "ناسینەوەی بایۆمێتریک چالاککرا",
|
||||
"twoFactor": "ناسینەوەی دوو-هەنگاوی",
|
||||
"twoFactorSubtitle": "چینێکی ئاسایشی زیادە زیاد بکە",
|
||||
"twoFactorPrompt": "ناسینەوەی دوو-هەنگاوی چینێکی ئاسایشی زیادە زیاد دەکات.",
|
||||
"twoFactorSetup": "ڕێکبخە",
|
||||
"changePassword": "وشەی نهێنی بگۆڕە",
|
||||
"changePasswordSubtitle": "وشەی نهێنی هەژمارەکەت نوێ بکەرەوە"
|
||||
},
|
||||
"notifications": {
|
||||
"push": "ئاگادارییە خێراکان",
|
||||
"pushSubtitle": "ئاگاداری و نوێکارییەکان وەربگرە",
|
||||
"email": "ئاگادارییەکانی ئیمەیل",
|
||||
"emailSubtitle": "هەڵبژاردنەکانی ئیمەیل بەڕێوەببە"
|
||||
},
|
||||
"about": {
|
||||
"pezkuwi": "دەربارەی پێزکووی",
|
||||
"pezkuwiSubtitle": "زیاتر دەربارەی کوردستانی دیجیتاڵ بزانە",
|
||||
"pezkuwiMessage": "پێزکووی پلاتفۆرمێکی بلۆکچەینی ناناوەندییە بۆ کوردستانی دیجیتاڵ.\n\nوەشان: 1.0.0\n\nبە ❤️ دروستکرا",
|
||||
"terms": "مەرجەکانی خزمەتگوزاری",
|
||||
"privacy": "سیاسەتی تایبەتمەندی",
|
||||
"contact": "پەیوەندی پشتگیری",
|
||||
"contactSubtitle": "یارمەتی لە تیمەکەمان وەربگرە",
|
||||
"contactEmail": "ئیمەیل: support@pezkuwichain.io"
|
||||
},
|
||||
"version": {
|
||||
"app": "پێزکووی مۆبایل",
|
||||
"number": "وەشان 1.0.0",
|
||||
"copyright": "© 2026 کوردستانی دیجیتاڵ"
|
||||
},
|
||||
"alerts": {
|
||||
"comingSoon": "بەم زووانە",
|
||||
"darkModeMessage": "دۆخی تاریک لە نوێکردنەوەی داهاتوودا بەردەست دەبێت",
|
||||
"twoFactorMessage": "ڕێکخستنی 2FA بەم زووانە بەردەست دەبێت",
|
||||
"passwordMessage": "گۆڕینی وشەی نهێنی بەم زووانە بەردەست دەبێت",
|
||||
"emailMessage": "ڕێکخستنەکانی ئیمەیل بەم زووانە بەردەست دەبن",
|
||||
"termsMessage": "مەرجەکانی خزمەتگوزاری بەم زووانە بەردەست دەبن",
|
||||
"privacyMessage": "سیاسەتی تایبەتمەندی بەم زووانە بەردەست دەبێت"
|
||||
},
|
||||
"common": {
|
||||
"enable": "چالاککردن",
|
||||
"cancel": "هەڵوەشاندنەوە",
|
||||
"confirm": "پشتڕاستکردنەوە",
|
||||
"success": "سەرکەوتوو",
|
||||
"error": "هەڵە"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"cancel": "هەڵوەشاندنەوە",
|
||||
|
||||
@@ -138,12 +138,77 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
"theme": "Theme",
|
||||
"notifications": "Notifications",
|
||||
"security": "Security",
|
||||
"about": "About",
|
||||
"logout": "Logout"
|
||||
"sections": {
|
||||
"appearance": "APPEARANCE",
|
||||
"language": "LANGUAGE",
|
||||
"security": "SECURITY",
|
||||
"notifications": "NOTIFICATIONS",
|
||||
"about": "ABOUT"
|
||||
},
|
||||
"appearance": {
|
||||
"darkMode": "Dark Mode",
|
||||
"darkModeSubtitle": "Switch between light and dark theme",
|
||||
"fontSize": "Font Size",
|
||||
"fontSizeSubtitle": "Current: {{size}}",
|
||||
"fontSizePrompt": "Choose your preferred font size",
|
||||
"small": "Small",
|
||||
"medium": "Medium",
|
||||
"large": "Large"
|
||||
},
|
||||
"language": {
|
||||
"title": "Language",
|
||||
"changePrompt": "Switch to {{language}}?",
|
||||
"changeSuccess": "Language updated successfully!"
|
||||
},
|
||||
"security": {
|
||||
"biometric": "Biometric Authentication",
|
||||
"biometricSubtitle": "Use fingerprint or face recognition",
|
||||
"biometricPrompt": "Do you want to enable biometric authentication (fingerprint/face recognition)?",
|
||||
"biometricEnabled": "Biometric authentication enabled",
|
||||
"twoFactor": "Two-Factor Authentication",
|
||||
"twoFactorSubtitle": "Add an extra layer of security",
|
||||
"twoFactorPrompt": "Two-factor authentication adds an extra layer of security. You will need to set up an authenticator app.",
|
||||
"twoFactorSetup": "Set Up",
|
||||
"changePassword": "Change Password",
|
||||
"changePasswordSubtitle": "Update your account password"
|
||||
},
|
||||
"notifications": {
|
||||
"push": "Push Notifications",
|
||||
"pushSubtitle": "Receive alerts and updates",
|
||||
"email": "Email Notifications",
|
||||
"emailSubtitle": "Manage email preferences"
|
||||
},
|
||||
"about": {
|
||||
"pezkuwi": "About Pezkuwi",
|
||||
"pezkuwiSubtitle": "Learn more about Digital Kurdistan",
|
||||
"pezkuwiMessage": "Pezkuwi is a decentralized blockchain platform for Digital Kurdistan, enabling citizens to participate in governance, economy, and social life.\n\nVersion: 1.0.0\n\nBuilt with ❤️ by the Digital Kurdistan team",
|
||||
"terms": "Terms of Service",
|
||||
"privacy": "Privacy Policy",
|
||||
"contact": "Contact Support",
|
||||
"contactSubtitle": "Get help from our team",
|
||||
"contactEmail": "Email: support@pezkuwichain.io"
|
||||
},
|
||||
"version": {
|
||||
"app": "Pezkuwi Mobile",
|
||||
"number": "Version 1.0.0",
|
||||
"copyright": "© 2026 Digital Kurdistan"
|
||||
},
|
||||
"alerts": {
|
||||
"comingSoon": "Coming Soon",
|
||||
"darkModeMessage": "Dark mode will be available in the next update",
|
||||
"twoFactorMessage": "2FA setup will be available soon",
|
||||
"passwordMessage": "Password change will be available soon",
|
||||
"emailMessage": "Email settings will be available soon",
|
||||
"termsMessage": "Terms of Service will be available soon",
|
||||
"privacyMessage": "Privacy Policy will be available soon"
|
||||
},
|
||||
"common": {
|
||||
"enable": "Enable",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"success": "Success",
|
||||
"error": "Error"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
|
||||
@@ -137,13 +137,78 @@
|
||||
"history": "Dîrok"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Mîheng",
|
||||
"language": "Ziman",
|
||||
"theme": "Tema",
|
||||
"notifications": "Agahdarî",
|
||||
"security": "Ewlekarî",
|
||||
"about": "Derbarê",
|
||||
"logout": "Derkeve"
|
||||
"title": "Mîhengên",
|
||||
"sections": {
|
||||
"appearance": "XUYANÎ",
|
||||
"language": "ZIMAN",
|
||||
"security": "EWLEHÎ",
|
||||
"notifications": "AGAHDARÎ",
|
||||
"about": "DER BARÊ"
|
||||
},
|
||||
"appearance": {
|
||||
"darkMode": "Moda Tarî",
|
||||
"darkModeSubtitle": "Di navbera moda ronî û tarî de biguherîne",
|
||||
"fontSize": "Mezinahiya Nivîsê",
|
||||
"fontSizeSubtitle": "Niha: {{size}}",
|
||||
"fontSizePrompt": "Mezinahiya nivîsê ya xwe hilbijêre",
|
||||
"small": "Piçûk",
|
||||
"medium": "Nav",
|
||||
"large": "Mezin"
|
||||
},
|
||||
"language": {
|
||||
"title": "Ziman",
|
||||
"changePrompt": "Biguherîne bo {{language}}?",
|
||||
"changeSuccess": "Ziman bi serkeftî hate nûkirin!"
|
||||
},
|
||||
"security": {
|
||||
"biometric": "Naskirina Bîyometrîk",
|
||||
"biometricSubtitle": "Şopa tilî yan naskirina rû bikar bîne",
|
||||
"biometricPrompt": "Hûn dixwazin naskirina bîyometrîk çalak bikin?",
|
||||
"biometricEnabled": "Naskirina bîyometrîk çalak kirin",
|
||||
"twoFactor": "Naskirina Du-Pîlan",
|
||||
"twoFactorSubtitle": "Qateka ewlehiyê zêde bikin",
|
||||
"twoFactorPrompt": "Naskirina du-pîlan qateka ewlehiyê zêde dike.",
|
||||
"twoFactorSetup": "Saz Bike",
|
||||
"changePassword": "Şîfreyê Biguherîne",
|
||||
"changePasswordSubtitle": "Şîfreya hesabê xwe nû bike"
|
||||
},
|
||||
"notifications": {
|
||||
"push": "Agahdariyên Zû",
|
||||
"pushSubtitle": "Hişyarî û nûvekirinên werbigire",
|
||||
"email": "Agahdariyên E-nameyê",
|
||||
"emailSubtitle": "Vebijarkên e-nameyê birêve bibin"
|
||||
},
|
||||
"about": {
|
||||
"pezkuwi": "Der barê Pezkuwi",
|
||||
"pezkuwiSubtitle": "Zêdetir der barê Kurdistana Dîjîtal bizanin",
|
||||
"pezkuwiMessage": "Pezkuwi platformek blockchain-ê ya bê-navend e ji bo Kurdistana Dîjîtal.\n\nGuherto: 1.0.0\n\nBi ❤️ hatiye çêkirin",
|
||||
"terms": "Mercên Karûbarê",
|
||||
"privacy": "Siyaseta Nepenîtiyê",
|
||||
"contact": "Têkiliya Piştgiriyê",
|
||||
"contactSubtitle": "Ji tîma me alîkarî bistînin",
|
||||
"contactEmail": "E-name: support@pezkuwichain.io"
|
||||
},
|
||||
"version": {
|
||||
"app": "Pezkuwi Mobîl",
|
||||
"number": "Guherto 1.0.0",
|
||||
"copyright": "© 2026 Kurdistana Dîjîtal"
|
||||
},
|
||||
"alerts": {
|
||||
"comingSoon": "Zû tê",
|
||||
"darkModeMessage": "Moda tarî di nûvekirina pêş de berdest dibe",
|
||||
"twoFactorMessage": "Sazkirina 2FA zû berdest dibe",
|
||||
"passwordMessage": "Guherandina şîfreyê zû berdest dibe",
|
||||
"emailMessage": "Mîhengên e-nameyê zû berdest dibin",
|
||||
"termsMessage": "Mercên karûbarê zû berdest dibin",
|
||||
"privacyMessage": "Siyaseta nepenîtiyê zû berdest dibe"
|
||||
},
|
||||
"common": {
|
||||
"enable": "Çalak Bike",
|
||||
"cancel": "Betal Bike",
|
||||
"confirm": "Pejirandin",
|
||||
"success": "Serkeft",
|
||||
"error": "Çewtî"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Betal bike",
|
||||
|
||||
@@ -138,12 +138,77 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ayarlar",
|
||||
"language": "Dil",
|
||||
"theme": "Tema",
|
||||
"notifications": "Bildirimler",
|
||||
"security": "Güvenlik",
|
||||
"about": "Hakkında",
|
||||
"logout": "Çıkış Yap"
|
||||
"sections": {
|
||||
"appearance": "GÖRÜNÜM",
|
||||
"language": "DİL",
|
||||
"security": "GÜVENLİK",
|
||||
"notifications": "BİLDİRİMLER",
|
||||
"about": "HAKKINDA"
|
||||
},
|
||||
"appearance": {
|
||||
"darkMode": "Karanlık Mod",
|
||||
"darkModeSubtitle": "Açık ve karanlık tema arasında geçiş yapın",
|
||||
"fontSize": "Yazı Boyutu",
|
||||
"fontSizeSubtitle": "Şu anki: {{size}}",
|
||||
"fontSizePrompt": "Tercih ettiğiniz yazı boyutunu seçin",
|
||||
"small": "Küçük",
|
||||
"medium": "Orta",
|
||||
"large": "Büyük"
|
||||
},
|
||||
"language": {
|
||||
"title": "Dil",
|
||||
"changePrompt": "{{language}} diline geçilsin mi?",
|
||||
"changeSuccess": "Dil başarıyla güncellendi!"
|
||||
},
|
||||
"security": {
|
||||
"biometric": "Biyometrik Kimlik Doğrulama",
|
||||
"biometricSubtitle": "Parmak izi veya yüz tanıma kullanın",
|
||||
"biometricPrompt": "Biyometrik kimlik doğrulamayı (parmak izi/yüz tanıma) etkinleştirmek istiyor musunuz?",
|
||||
"biometricEnabled": "Biyometrik kimlik doğrulama etkinleştirildi",
|
||||
"twoFactor": "İki Faktörlü Kimlik Doğrulama",
|
||||
"twoFactorSubtitle": "Ekstra bir güvenlik katmanı ekleyin",
|
||||
"twoFactorPrompt": "İki faktörlü kimlik doğrulama ekstra bir güvenlik katmanı ekler. Bir kimlik doğrulayıcı uygulama kurmanız gerekecek.",
|
||||
"twoFactorSetup": "Kur",
|
||||
"changePassword": "Şifre Değiştir",
|
||||
"changePasswordSubtitle": "Hesap şifrenizi güncelleyin"
|
||||
},
|
||||
"notifications": {
|
||||
"push": "Anlık Bildirimler",
|
||||
"pushSubtitle": "Uyarılar ve güncellemeler alın",
|
||||
"email": "E-posta Bildirimleri",
|
||||
"emailSubtitle": "E-posta tercihlerini yönetin"
|
||||
},
|
||||
"about": {
|
||||
"pezkuwi": "Pezkuwi Hakkında",
|
||||
"pezkuwiSubtitle": "Dijital Kürdistan hakkında daha fazla bilgi edinin",
|
||||
"pezkuwiMessage": "Pezkuwi, vatandaşların yönetişim, ekonomi ve sosyal yaşama katılımını sağlayan Dijital Kürdistan için merkezi olmayan bir blockchain platformudur.\n\nVersiyon: 1.0.0\n\nDijital Kürdistan ekibi tarafından ❤️ ile yapıldı",
|
||||
"terms": "Hizmet Şartları",
|
||||
"privacy": "Gizlilik Politikası",
|
||||
"contact": "Destek İletişim",
|
||||
"contactSubtitle": "Ekibimizden yardım alın",
|
||||
"contactEmail": "E-posta: support@pezkuwichain.io"
|
||||
},
|
||||
"version": {
|
||||
"app": "Pezkuwi Mobil",
|
||||
"number": "Versiyon 1.0.0",
|
||||
"copyright": "© 2026 Dijital Kürdistan"
|
||||
},
|
||||
"alerts": {
|
||||
"comingSoon": "Yakında",
|
||||
"darkModeMessage": "Karanlık mod bir sonraki güncellemede kullanılabilir olacak",
|
||||
"twoFactorMessage": "2FA kurulumu yakında kullanılabilir olacak",
|
||||
"passwordMessage": "Şifre değiştirme yakında kullanılabilir olacak",
|
||||
"emailMessage": "E-posta ayarları yakında kullanılabilir olacak",
|
||||
"termsMessage": "Hizmet Şartları yakında kullanılabilir olacak",
|
||||
"privacyMessage": "Gizlilik Politikası yakında kullanılabilir olacak"
|
||||
},
|
||||
"common": {
|
||||
"enable": "Etkinleştir",
|
||||
"cancel": "İptal",
|
||||
"confirm": "Onayla",
|
||||
"success": "Başarılı",
|
||||
"error": "Hata"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"cancel": "İptal",
|
||||
|
||||
+194
-3
@@ -1,18 +1,27 @@
|
||||
/**
|
||||
* Supabase Client Configuration
|
||||
*
|
||||
* Centralized Supabase client for all database operations
|
||||
* Used for: Forum, P2P Platform, Notifications, Referrals
|
||||
*/
|
||||
|
||||
import 'react-native-url-polyfill/auto';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { ENV } from '../config/environment';
|
||||
|
||||
// Initialize Supabase client from environment variables
|
||||
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL || '';
|
||||
const supabaseKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || '';
|
||||
const supabaseUrl = ENV.supabaseUrl || '';
|
||||
const supabaseKey = ENV.supabaseAnonKey || '';
|
||||
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
if (__DEV__) {
|
||||
console.warn('Supabase credentials not found in environment variables');
|
||||
console.warn('⚠️ [Supabase] Credentials not found in environment variables');
|
||||
console.warn('Add EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY to .env');
|
||||
}
|
||||
}
|
||||
|
||||
// Create Supabase client
|
||||
export const supabase = createClient(supabaseUrl, supabaseKey, {
|
||||
auth: {
|
||||
storage: AsyncStorage,
|
||||
@@ -21,3 +30,185 @@ export const supabase = createClient(supabaseUrl, supabaseKey, {
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Database type definitions
|
||||
export interface ForumCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ForumDiscussion {
|
||||
id: string;
|
||||
category_id: string;
|
||||
author_address: string;
|
||||
author_name: string;
|
||||
title: string;
|
||||
content: string;
|
||||
likes: number;
|
||||
replies_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface P2PAd {
|
||||
id: string;
|
||||
user_address: string;
|
||||
type: 'buy' | 'sell';
|
||||
merchant_name: string;
|
||||
rating: number;
|
||||
trades_count: number;
|
||||
price: number;
|
||||
currency: string;
|
||||
amount: string;
|
||||
min_limit: string;
|
||||
max_limit: string;
|
||||
payment_methods: string[];
|
||||
status: 'active' | 'inactive' | 'completed';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
user_address: string;
|
||||
type: 'transaction' | 'governance' | 'p2p' | 'referral' | 'system';
|
||||
title: string;
|
||||
message: string;
|
||||
read: boolean;
|
||||
metadata?: any;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Referral {
|
||||
id: string;
|
||||
referrer_address: string;
|
||||
referee_address: string;
|
||||
status: 'pending' | 'active' | 'completed';
|
||||
earnings: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Helper functions for common queries
|
||||
export const supabaseHelpers = {
|
||||
// Forum
|
||||
async getForumCategories() {
|
||||
const { data, error } = await supabase
|
||||
.from('forum_categories')
|
||||
.select('*')
|
||||
.order('name', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
return data as ForumCategory[];
|
||||
},
|
||||
|
||||
async getForumDiscussions(categoryId?: string) {
|
||||
let query = supabase
|
||||
.from('forum_discussions')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (categoryId) {
|
||||
query = query.eq('category_id', categoryId);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return data as ForumDiscussion[];
|
||||
},
|
||||
|
||||
// P2P
|
||||
async getP2PAds(type?: 'buy' | 'sell') {
|
||||
let query = supabase
|
||||
.from('p2p_ads')
|
||||
.select('*')
|
||||
.eq('status', 'active')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (type) {
|
||||
query = query.eq('type', type);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return data as P2PAd[];
|
||||
},
|
||||
|
||||
// Notifications
|
||||
async getUserNotifications(userAddress: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('notifications')
|
||||
.select('*')
|
||||
.eq('user_address', userAddress)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
if (error) throw error;
|
||||
return data as Notification[];
|
||||
},
|
||||
|
||||
async getUnreadNotificationsCount(userAddress: string) {
|
||||
const { count, error } = await supabase
|
||||
.from('notifications')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_address', userAddress)
|
||||
.eq('read', false);
|
||||
|
||||
if (error) throw error;
|
||||
return count || 0;
|
||||
},
|
||||
|
||||
async markNotificationAsRead(notificationId: string) {
|
||||
const { error } = await supabase
|
||||
.from('notifications')
|
||||
.update({ read: true })
|
||||
.eq('id', notificationId);
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
async markAllNotificationsAsRead(userAddress: string) {
|
||||
const { error } = await supabase
|
||||
.from('notifications')
|
||||
.update({ read: true })
|
||||
.eq('user_address', userAddress)
|
||||
.eq('read', false);
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
// Referrals
|
||||
async getUserReferrals(userAddress: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('referrals')
|
||||
.select('*')
|
||||
.eq('referrer_address', userAddress)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return data as Referral[];
|
||||
},
|
||||
|
||||
async getReferralStats(userAddress: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('referrals')
|
||||
.select('status, earnings')
|
||||
.eq('referrer_address', userAddress);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const stats = {
|
||||
total: data?.length || 0,
|
||||
active: data?.filter(r => r.status === 'active').length || 0,
|
||||
completed: data?.filter(r => r.status === 'completed').length || 0,
|
||||
totalEarnings: data?.reduce((sum, r) => sum + r.earnings, 0) || 0,
|
||||
};
|
||||
|
||||
return stats;
|
||||
},
|
||||
};
|
||||
|
||||
export default supabase;
|
||||
|
||||
@@ -1,55 +1,59 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { View, ActivityIndicator } from 'react-native';
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useAuth } from '../contexts/AuthContext'; // Import useAuth
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
|
||||
// Screens
|
||||
import WelcomeScreen from '../screens/WelcomeScreen';
|
||||
import SignInScreen from '../screens/SignInScreen';
|
||||
import SignUpScreen from '../screens/SignUpScreen';
|
||||
import VerifyHumanScreen, { checkHumanVerification } from '../screens/VerifyHumanScreen';
|
||||
import AuthScreen from '../screens/AuthScreen';
|
||||
import BottomTabNavigator from './BottomTabNavigator';
|
||||
import SettingsScreen from '../screens/SettingsScreen';
|
||||
import BeCitizenChoiceScreen from '../screens/BeCitizenChoiceScreen';
|
||||
import BeCitizenApplyScreen from '../screens/BeCitizenApplyScreen';
|
||||
import BeCitizenClaimScreen from '../screens/BeCitizenClaimScreen';
|
||||
|
||||
export type RootStackParamList = {
|
||||
Welcome: undefined;
|
||||
SignIn: undefined;
|
||||
SignUp: undefined;
|
||||
VerifyHuman: undefined;
|
||||
Auth: undefined;
|
||||
MainApp: undefined;
|
||||
Settings: undefined;
|
||||
BeCitizenChoice: undefined;
|
||||
BeCitizenApply: undefined;
|
||||
BeCitizenClaim: undefined;
|
||||
};
|
||||
|
||||
const Stack = createStackNavigator<RootStackParamList>();
|
||||
|
||||
const AppNavigator: React.FC = () => {
|
||||
const { hasSelectedLanguage } = useLanguage();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
// Language is now hard-coded at build time, no selection needed
|
||||
const { user, loading } = useAuth(); // Use real auth state
|
||||
const [isHumanVerified, setIsHumanVerified] = React.useState<boolean | null>(null);
|
||||
const [privacyConsent, setPrivacyConsent] = React.useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Check authentication status
|
||||
// TODO: Implement actual auth check
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 1000);
|
||||
React.useEffect(() => {
|
||||
// Check privacy consent and human verification
|
||||
const checkAppState = async () => {
|
||||
try {
|
||||
const consent = await AsyncStorage.getItem('@pezkuwi/privacy_consent_accepted');
|
||||
setPrivacyConsent(consent === 'true');
|
||||
|
||||
const verified = await checkHumanVerification();
|
||||
setIsHumanVerified(verified);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error checking app state:', error);
|
||||
setPrivacyConsent(false);
|
||||
setIsHumanVerified(false);
|
||||
}
|
||||
};
|
||||
checkAppState();
|
||||
}, []);
|
||||
|
||||
const handleLanguageSelected = () => {
|
||||
// Navigate to sign in after language selection
|
||||
};
|
||||
|
||||
const handleSignIn = () => {
|
||||
setIsAuthenticated(true);
|
||||
};
|
||||
|
||||
const handleSignUp = () => {
|
||||
setIsAuthenticated(true);
|
||||
};
|
||||
|
||||
const _handleLogout = () => {
|
||||
setIsAuthenticated(false);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
if (loading || isHumanVerified === null || privacyConsent === null) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ActivityIndicator size="large" color={KurdistanColors.kesk} />
|
||||
@@ -65,41 +69,57 @@ const AppNavigator: React.FC = () => {
|
||||
cardStyle: { backgroundColor: '#FFFFFF' },
|
||||
}}
|
||||
>
|
||||
{!hasSelectedLanguage ? (
|
||||
// Show welcome screen if language not selected
|
||||
<Stack.Screen name="Welcome">
|
||||
{(props) => (
|
||||
<WelcomeScreen
|
||||
{...props}
|
||||
onLanguageSelected={handleLanguageSelected}
|
||||
/>
|
||||
)}
|
||||
{!privacyConsent ? (
|
||||
// Step 0: Show Welcome screen if privacy not accepted
|
||||
<Stack.Screen name="Welcome" options={{ headerShown: false }}>
|
||||
{() => <WelcomeScreen onContinue={() => setPrivacyConsent(true)} />}
|
||||
</Stack.Screen>
|
||||
) : !isAuthenticated ? (
|
||||
// Show auth screens if not authenticated
|
||||
<>
|
||||
<Stack.Screen name="SignIn">
|
||||
{(props) => (
|
||||
<SignInScreen
|
||||
{...props}
|
||||
onSignIn={handleSignIn}
|
||||
onNavigateToSignUp={() => props.navigation.navigate('SignUp')}
|
||||
/>
|
||||
)}
|
||||
) : !isHumanVerified ? (
|
||||
// Step 1: Show verify human screen if not verified
|
||||
<Stack.Screen name="VerifyHuman">
|
||||
{() => <VerifyHumanScreen onVerified={() => setIsHumanVerified(true)} />}
|
||||
</Stack.Screen>
|
||||
<Stack.Screen name="SignUp">
|
||||
{(props) => (
|
||||
<SignUpScreen
|
||||
{...props}
|
||||
onSignUp={handleSignUp}
|
||||
onNavigateToSignIn={() => props.navigation.navigate('SignIn')}
|
||||
/>
|
||||
)}
|
||||
</Stack.Screen>
|
||||
</>
|
||||
) : !user ? (
|
||||
// Step 2: Show unified auth screen if not authenticated
|
||||
<Stack.Screen name="Auth" component={AuthScreen} />
|
||||
) : (
|
||||
// Show main app (bottom tabs) if authenticated
|
||||
// Step 3: Show main app (bottom tabs) if authenticated
|
||||
<>
|
||||
<Stack.Screen name="MainApp" component={BottomTabNavigator} />
|
||||
<Stack.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="BeCitizenChoice"
|
||||
component={BeCitizenChoiceScreen}
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="BeCitizenApply"
|
||||
component={BeCitizenApplyScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTitle: 'Apply for Citizenship',
|
||||
headerBackTitle: 'Back',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="BeCitizenClaim"
|
||||
component={BeCitizenClaimScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTitle: 'Verify Citizenship',
|
||||
headerBackTitle: 'Back',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
|
||||
@@ -1,55 +1,62 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Platform } from 'react-native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import { GradientHeader, SimpleHeader } from '../components/navigation/SharedHeader';
|
||||
|
||||
// Screens
|
||||
import DashboardScreen from '../screens/DashboardScreen';
|
||||
import WalletScreen from '../screens/WalletScreen';
|
||||
import SwapScreen from '../screens/SwapScreen';
|
||||
import P2PScreen from '../screens/P2PScreen';
|
||||
import EducationScreen from '../screens/EducationScreen';
|
||||
import ForumScreen from '../screens/ForumScreen';
|
||||
import BeCitizenScreen from '../screens/BeCitizenScreen';
|
||||
import AppsScreen from '../screens/AppsScreen';
|
||||
import ReferralScreen from '../screens/ReferralScreen';
|
||||
import ProfileScreen from '../screens/ProfileScreen';
|
||||
|
||||
// Removed screens from tabs (accessible via Dashboard/Apps):
|
||||
// WalletScreen, SwapScreen, P2PScreen, EducationScreen, ForumScreen
|
||||
|
||||
export type BottomTabParamList = {
|
||||
Home: undefined;
|
||||
Wallet: undefined;
|
||||
Swap: undefined;
|
||||
P2P: undefined;
|
||||
Education: undefined;
|
||||
Forum: undefined;
|
||||
BeCitizen: undefined;
|
||||
Apps: undefined;
|
||||
Citizen: undefined; // Dummy tab, never navigates to a screen
|
||||
Referral: undefined;
|
||||
Profile: undefined;
|
||||
};
|
||||
|
||||
const Tab = createBottomTabNavigator<BottomTabParamList>();
|
||||
|
||||
// Custom Tab Bar Button for Center Button
|
||||
// Custom Tab Bar Button for Center Button (Citizen) - navigates to BeCitizenChoice
|
||||
const CustomTabBarButton: React.FC<{
|
||||
children: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
}> = ({ children, onPress }) => (
|
||||
}> = ({ children }) => {
|
||||
const navigation = useNavigation<any>();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.customButtonContainer}
|
||||
onPress={onPress}
|
||||
onPress={() => navigation.navigate('BeCitizenChoice')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.customButton}>{children}</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const BottomTabNavigator: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
headerShown: true,
|
||||
header: (props) => <SimpleHeader {...props} />,
|
||||
tabBarActiveTintColor: KurdistanColors.kesk,
|
||||
tabBarInactiveTintColor: '#999',
|
||||
tabBarStyle: styles.tabBar,
|
||||
tabBarStyle: {
|
||||
...styles.tabBar,
|
||||
height: (Platform.OS === 'ios' ? 85 : 65) + insets.bottom,
|
||||
paddingBottom: insets.bottom > 0 ? insets.bottom : (Platform.OS === 'ios' ? 20 : 8),
|
||||
},
|
||||
tabBarShowLabel: true,
|
||||
tabBarLabelStyle: styles.tabBarLabel,
|
||||
}}
|
||||
@@ -58,6 +65,8 @@ const BottomTabNavigator: React.FC = () => {
|
||||
name="Home"
|
||||
component={DashboardScreen}
|
||||
options={{
|
||||
header: (props) => <GradientHeader {...props} />,
|
||||
tabBarLabel: 'Home',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Text style={[styles.icon, { color }]}>
|
||||
{focused ? '🏠' : '🏚️'}
|
||||
@@ -67,70 +76,24 @@ const BottomTabNavigator: React.FC = () => {
|
||||
/>
|
||||
|
||||
<Tab.Screen
|
||||
name="Wallet"
|
||||
component={WalletScreen}
|
||||
name="Apps"
|
||||
component={AppsScreen}
|
||||
options={{
|
||||
tabBarLabel: 'Apps',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Text style={[styles.icon, { color }]}>
|
||||
{focused ? '💰' : '👛'}
|
||||
{focused ? '📱' : '📲'}
|
||||
</Text>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tab.Screen
|
||||
name="Swap"
|
||||
component={SwapScreen}
|
||||
name="Citizen"
|
||||
component={View} // Dummy component, never rendered
|
||||
options={{
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Text style={[styles.icon, { color }]}>
|
||||
{focused ? '🔄' : '↔️'}
|
||||
</Text>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tab.Screen
|
||||
name="P2P"
|
||||
component={P2PScreen}
|
||||
options={{
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Text style={[styles.icon, { color }]}>
|
||||
{focused ? '💱' : '💰'}
|
||||
</Text>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tab.Screen
|
||||
name="Education"
|
||||
component={EducationScreen}
|
||||
options={{
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Text style={[styles.icon, { color }]}>
|
||||
{focused ? '🎓' : '📚'}
|
||||
</Text>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tab.Screen
|
||||
name="Forum"
|
||||
component={ForumScreen}
|
||||
options={{
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Text style={[styles.icon, { color }]}>
|
||||
{focused ? '💬' : '📝'}
|
||||
</Text>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tab.Screen
|
||||
name="BeCitizen"
|
||||
component={BeCitizenScreen}
|
||||
options={{
|
||||
tabBarLabel: 'Be Citizen',
|
||||
headerShown: false, // Dummy tab, no header needed
|
||||
tabBarLabel: 'Citizen',
|
||||
tabBarIcon: ({ focused: _focused }) => (
|
||||
<Text style={[styles.centerIcon]}>
|
||||
🏛️
|
||||
@@ -144,6 +107,7 @@ const BottomTabNavigator: React.FC = () => {
|
||||
name="Referral"
|
||||
component={ReferralScreen}
|
||||
options={{
|
||||
tabBarLabel: 'Referral',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Text style={[styles.icon, { color }]}>
|
||||
{focused ? '🤝' : '👥'}
|
||||
@@ -156,6 +120,8 @@ const BottomTabNavigator: React.FC = () => {
|
||||
name="Profile"
|
||||
component={ProfileScreen}
|
||||
options={{
|
||||
header: (props) => <GradientHeader {...props} />,
|
||||
tabBarLabel: 'Profile',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Text style={[styles.icon, { color }]}>
|
||||
{focused ? '👤' : '👨'}
|
||||
@@ -184,19 +150,20 @@ const styles = StyleSheet.create({
|
||||
tabBarLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
marginTop: 2,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 24,
|
||||
fontSize: 22,
|
||||
},
|
||||
customButtonContainer: {
|
||||
top: -20,
|
||||
top: -24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
customButton: {
|
||||
width: 70,
|
||||
height: 70,
|
||||
borderRadius: 35,
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
@@ -206,10 +173,10 @@ const styles = StyleSheet.create({
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
borderWidth: 4,
|
||||
borderColor: KurdistanColors.spi,
|
||||
borderColor: '#f5f5f5', // Matches background color usually
|
||||
},
|
||||
centerIcon: {
|
||||
fontSize: 32,
|
||||
fontSize: 30,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* React Native shim for @pezkuwi/wasm-crypto
|
||||
* Provides waitReady() and isReady() using ASM.js
|
||||
*/
|
||||
console.log('🔧 [SHIM] ==========================================');
|
||||
console.log('🔧 [SHIM] WASM-CRYPTO SHIM LOADING...');
|
||||
console.log('🔧 [SHIM] ==========================================');
|
||||
|
||||
console.log('📦 [SHIM] Importing Bridge...');
|
||||
import { Bridge } from '@pezkuwi/wasm-bridge';
|
||||
console.log('✅ [SHIM] Bridge imported');
|
||||
|
||||
console.log('📦 [SHIM] Importing createWasm (ASM.js)...');
|
||||
import { createWasm } from '@pezkuwi/wasm-crypto-init/asm';
|
||||
console.log('✅ [SHIM] createWasm imported');
|
||||
|
||||
console.log('🏗️ [SHIM] Creating Bridge instance...');
|
||||
// Create bridge with ASM.js
|
||||
export const bridge = new Bridge(createWasm);
|
||||
console.log('✅ [SHIM] Bridge instance created');
|
||||
|
||||
// Export isReady
|
||||
export function isReady() {
|
||||
const ready = !!bridge.wasm;
|
||||
console.log('🔍 [SHIM] isReady() called, result:', ready);
|
||||
return ready;
|
||||
}
|
||||
|
||||
// Export waitReady
|
||||
export async function waitReady() {
|
||||
console.log('⏳ [SHIM] waitReady() called');
|
||||
try {
|
||||
console.log('🔄 [SHIM] Initializing ASM.js bridge...');
|
||||
const wasm = await bridge.init(createWasm);
|
||||
const success = !!wasm;
|
||||
console.log('✅ [SHIM] ASM.js bridge initialized successfully:', success);
|
||||
return success;
|
||||
} catch (error) {
|
||||
console.error('❌ [SHIM] Failed to initialize ASM.js:', error);
|
||||
console.error('❌ [SHIM] Error stack:', error.stack);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📦 [SHIM] Re-exporting bundle functions...');
|
||||
// Re-export all crypto functions from bundle
|
||||
export * from '@pezkuwi/wasm-crypto/bundle';
|
||||
console.log('✅ [SHIM] All exports configured');
|
||||
|
||||
console.log('🔧 [SHIM] ==========================================');
|
||||
console.log('🔧 [SHIM] SHIM LOADED SUCCESSFULLY');
|
||||
console.log('🔧 [SHIM] ==========================================');
|
||||
@@ -0,0 +1,331 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
Image,
|
||||
Alert,
|
||||
Dimensions,
|
||||
FlatList,
|
||||
StatusBar,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
|
||||
// Import Images (Reusing existing assets)
|
||||
import qaEducation from '../../../shared/images/quick-actions/qa_education.png';
|
||||
import qaExchange from '../../../shared/images/quick-actions/qa_exchange.png';
|
||||
import qaForum from '../../../shared/images/quick-actions/qa_forum.jpg';
|
||||
import qaGovernance from '../../../shared/images/quick-actions/qa_governance.jpg';
|
||||
import qaTrading from '../../../shared/images/quick-actions/qa_trading.jpg';
|
||||
import qaB2B from '../../../shared/images/quick-actions/qa_b2b.png';
|
||||
import qaBank from '../../../shared/images/quick-actions/qa_bank.png';
|
||||
import qaKurdMedia from '../../../shared/images/quick-actions/qa_kurdmedia.jpg';
|
||||
import qaUniversity from '../../../shared/images/quick-actions/qa_university.png';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const COLUMN_COUNT = 3;
|
||||
const ITEM_WIDTH = (width - 48) / COLUMN_COUNT; // 48 = padding (16*2) + gaps
|
||||
|
||||
type CategoryType = 'All' | 'Finance' | 'Governance' | 'Social' | 'Education';
|
||||
|
||||
interface MiniApp {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: any;
|
||||
isEmoji: boolean;
|
||||
category: CategoryType;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const APPS_DATA: MiniApp[] = [
|
||||
// FINANCE
|
||||
{ id: 'wallet', name: 'Wallet', icon: '👛', isEmoji: true, category: 'Finance', description: 'Crypto Wallet' },
|
||||
{ id: 'bank', name: 'Bank', icon: qaBank, isEmoji: false, category: 'Finance', description: 'Digital Banking' },
|
||||
{ id: 'exchange', name: 'Exchange', icon: qaExchange, isEmoji: false, category: 'Finance', description: 'Swap & Trade' },
|
||||
{ id: 'p2p', name: 'P2P', icon: qaTrading, isEmoji: false, category: 'Finance', description: 'Peer to Peer' },
|
||||
{ id: 'b2b', name: 'B2B', icon: qaB2B, isEmoji: false, category: 'Finance', description: 'Business Market' },
|
||||
{ id: 'tax', name: 'Tax', icon: '📊', isEmoji: true, category: 'Finance', description: 'Tax & Zekat' },
|
||||
{ id: 'launchpad', name: 'Launchpad', icon: '🚀', isEmoji: true, category: 'Finance', description: 'Startup Funding' },
|
||||
{ id: 'cards', name: 'Cards', icon: '💳', isEmoji: true, category: 'Finance', description: 'Pezkuwi Cards' },
|
||||
|
||||
// GOVERNANCE
|
||||
{ id: 'president', name: 'President', icon: '👑', isEmoji: true, category: 'Governance', description: 'Presidency Office' },
|
||||
{ id: 'assembly', name: 'Assembly', icon: qaGovernance, isEmoji: false, category: 'Governance', description: 'National Assembly' },
|
||||
{ id: 'vote', name: 'Vote', icon: '🗳️', isEmoji: true, category: 'Governance', description: 'Decentralized Voting' },
|
||||
{ id: 'validators', name: 'Validators', icon: '🛡️', isEmoji: true, category: 'Governance', description: 'Network Security' },
|
||||
{ id: 'justice', name: 'Justice', icon: '⚖️', isEmoji: true, category: 'Governance', description: 'Digital Court' },
|
||||
{ id: 'proposals', name: 'Proposals', icon: '📜', isEmoji: true, category: 'Governance', description: 'Law Proposals' },
|
||||
{ id: 'polls', name: 'Polls', icon: '📊', isEmoji: true, category: 'Governance', description: 'Public Surveys' },
|
||||
{ id: 'identity', name: 'Identity', icon: '🆔', isEmoji: true, category: 'Governance', description: 'Digital ID' },
|
||||
|
||||
// SOCIAL
|
||||
{ id: 'whatskurd', name: 'whatsKURD', icon: '💬', isEmoji: true, category: 'Social', description: 'Messenger' },
|
||||
{ id: 'forum', name: 'Forum', icon: qaForum, isEmoji: false, category: 'Social', description: 'Community Talk' },
|
||||
{ id: 'kurdmedia', name: 'KurdMedia', icon: qaKurdMedia, isEmoji: false, category: 'Social', description: 'News & Media' },
|
||||
{ id: 'events', name: 'Events', icon: '🎭', isEmoji: true, category: 'Social', description: 'Çalakî' },
|
||||
{ id: 'help', name: 'Help', icon: '🤝', isEmoji: true, category: 'Social', description: 'Harîkarî' },
|
||||
{ id: 'music', name: 'Music', icon: '🎵', isEmoji: true, category: 'Social', description: 'Kurdish Stream' },
|
||||
{ id: 'vpn', name: 'VPN', icon: '🛡️', isEmoji: true, category: 'Social', description: 'Secure Net' },
|
||||
{ id: 'referral', name: 'Referral', icon: '👥', isEmoji: true, category: 'Social', description: 'Invite Friends' },
|
||||
|
||||
// EDUCATION
|
||||
{ id: 'university', name: 'University', icon: qaUniversity, isEmoji: false, category: 'Education', description: 'Higher Ed' },
|
||||
{ id: 'perwerde', name: 'Perwerde', icon: qaEducation, isEmoji: false, category: 'Education', description: 'Academy' },
|
||||
{ id: 'library', name: 'Library', icon: '📚', isEmoji: true, category: 'Education', description: 'Pirtûkxane' },
|
||||
{ id: 'language', name: 'Language', icon: '🗣️', isEmoji: true, category: 'Education', description: 'Ziman / Learn' },
|
||||
{ id: 'kids', name: 'Kids', icon: '🧸', isEmoji: true, category: 'Education', description: 'Zarok TV' },
|
||||
{ id: 'certificates', name: 'Certificates', icon: '🏆', isEmoji: true, category: 'Education', description: 'NFT Diplomas' },
|
||||
{ id: 'research', name: 'Research', icon: '🔬', isEmoji: true, category: 'Education', description: 'Scientific Data' },
|
||||
{ id: 'history', name: 'History', icon: '🏺', isEmoji: true, category: 'Education', description: 'Kurdish History' },
|
||||
];
|
||||
|
||||
const AppsScreen: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<CategoryType>('All');
|
||||
|
||||
const filteredApps = useMemo(() => {
|
||||
return APPS_DATA.filter(app => {
|
||||
const matchesSearch = app.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory = selectedCategory === 'All' || app.category === selectedCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
}, [searchQuery, selectedCategory]);
|
||||
|
||||
const handleAppPress = (appName: string) => {
|
||||
Alert.alert(
|
||||
'Di bin çêkirinê de ye / Under Maintenance',
|
||||
`The "${appName}" mini-app is currently under development. Please check back later.\n\nSpas ji bo sebra we.`,
|
||||
[{ text: 'Temam (OK)' }]
|
||||
);
|
||||
};
|
||||
|
||||
const renderCategoryChip = (category: CategoryType) => (
|
||||
<TouchableOpacity
|
||||
key={category}
|
||||
style={[
|
||||
styles.categoryChip,
|
||||
selectedCategory === category && styles.categoryChipActive
|
||||
]}
|
||||
onPress={() => setSelectedCategory(category)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.categoryText,
|
||||
selectedCategory === category && styles.categoryTextActive
|
||||
]}>
|
||||
{category}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const renderAppItem = ({ item }: { item: MiniApp }) => (
|
||||
<TouchableOpacity
|
||||
style={styles.appCard}
|
||||
onPress={() => handleAppPress(item.name)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.iconContainer}>
|
||||
{item.isEmoji ? (
|
||||
<Text style={styles.emojiIcon}>{item.icon}</Text>
|
||||
) : (
|
||||
<Image source={item.icon} style={styles.imageIcon} resizeMode="cover" />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.appName} numberOfLines={1}>{item.name}</Text>
|
||||
<Text style={styles.appDesc} numberOfLines={1}>{item.description}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor="#F5F5F5" />
|
||||
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Apps Store</Text>
|
||||
<Text style={styles.headerSubtitle}>Discover Pezkuwi Ecosystem</Text>
|
||||
</View>
|
||||
|
||||
{/* Search Bar */}
|
||||
<View style={styles.searchContainer}>
|
||||
<View style={styles.searchBar}>
|
||||
<Text style={styles.searchIcon}>🔍</Text>
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Search apps..."
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<TouchableOpacity onPress={() => setSearchQuery('')}>
|
||||
<Text style={styles.clearIcon}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Category Filter */}
|
||||
<View style={styles.categoriesContainer}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.categoriesContent}>
|
||||
{['All', 'Finance', 'Governance', 'Social', 'Education'].map((cat) =>
|
||||
renderCategoryChip(cat as CategoryType)
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Apps Grid */}
|
||||
<FlatList
|
||||
data={filteredApps}
|
||||
renderItem={renderAppItem}
|
||||
keyExtractor={item => item.id}
|
||||
numColumns={COLUMN_COUNT}
|
||||
contentContainerStyle={styles.listContent}
|
||||
columnWrapperStyle={styles.columnWrapper}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>No apps found matching "{searchQuery}"</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
},
|
||||
searchContainer: {
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 16,
|
||||
},
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
height: 48,
|
||||
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.05)',
|
||||
elevation: 2,
|
||||
},
|
||||
searchIcon: {
|
||||
fontSize: 18,
|
||||
marginRight: 8,
|
||||
opacity: 0.5,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
height: '100%',
|
||||
},
|
||||
clearIcon: {
|
||||
fontSize: 16,
|
||||
color: '#999',
|
||||
padding: 4,
|
||||
},
|
||||
categoriesContainer: {
|
||||
marginBottom: 10,
|
||||
},
|
||||
categoriesContent: {
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
categoryChip: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#E0E0E0',
|
||||
marginRight: 8,
|
||||
},
|
||||
categoryChipActive: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
},
|
||||
categoryText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#555',
|
||||
},
|
||||
categoryTextActive: {
|
||||
color: KurdistanColors.spi,
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 40,
|
||||
paddingTop: 8,
|
||||
},
|
||||
columnWrapper: {
|
||||
justifyContent: 'flex-start',
|
||||
gap: 8,
|
||||
},
|
||||
appCard: {
|
||||
width: ITEM_WIDTH,
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 16,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
alignItems: 'center',
|
||||
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.05)',
|
||||
elevation: 2,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#F8F9FA',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
imageIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
},
|
||||
emojiIcon: {
|
||||
fontSize: 24,
|
||||
},
|
||||
appName: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
color: '#333',
|
||||
marginBottom: 2,
|
||||
textAlign: 'center',
|
||||
},
|
||||
appDesc: {
|
||||
fontSize: 10,
|
||||
color: '#888',
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
padding: 40,
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
color: '#999',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export default AppsScreen;
|
||||
@@ -0,0 +1,337 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
Image,
|
||||
Alert,
|
||||
Dimensions,
|
||||
FlatList,
|
||||
StatusBar,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
|
||||
// Import Images (Reusing existing assets)
|
||||
import qaEducation from '../../../shared/images/quick-actions/qa_education.png';
|
||||
import qaExchange from '../../../shared/images/quick-actions/qa_exchange.png';
|
||||
import qaForum from '../../../shared/images/quick-actions/qa_forum.jpg';
|
||||
import qaGovernance from '../../../shared/images/quick-actions/qa_governance.jpg';
|
||||
import qaTrading from '../../../shared/images/quick-actions/qa_trading.jpg';
|
||||
import qaB2B from '../../../shared/images/quick-actions/qa_b2b.png';
|
||||
import qaBank from '../../../shared/images/quick-actions/qa_bank.png';
|
||||
import qaKurdMedia from '../../../shared/images/quick-actions/qa_kurdmedia.jpg';
|
||||
import qaUniversity from '../../../shared/images/quick-actions/qa_university.png';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const COLUMN_COUNT = 3;
|
||||
const ITEM_WIDTH = (width - 48) / COLUMN_COUNT; // 48 = padding (16*2) + gaps
|
||||
|
||||
type CategoryType = 'All' | 'Finance' | 'Governance' | 'Social' | 'Education';
|
||||
|
||||
interface MiniApp {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: any;
|
||||
isEmoji: boolean;
|
||||
category: CategoryType;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const APPS_DATA: MiniApp[] = [
|
||||
// FINANCE
|
||||
{ id: 'wallet', name: 'Wallet', icon: '👛', isEmoji: true, category: 'Finance', description: 'Crypto Wallet' },
|
||||
{ id: 'bank', name: 'Bank', icon: qaBank, isEmoji: false, category: 'Finance', description: 'Digital Banking' },
|
||||
{ id: 'exchange', name: 'Exchange', icon: qaExchange, isEmoji: false, category: 'Finance', description: 'Swap & Trade' },
|
||||
{ id: 'p2p', name: 'P2P', icon: qaTrading, isEmoji: false, category: 'Finance', description: 'Peer to Peer' },
|
||||
{ id: 'b2b', name: 'B2B', icon: qaB2B, isEmoji: false, category: 'Finance', description: 'Business Market' },
|
||||
{ id: 'tax', name: 'Tax', icon: '📊', isEmoji: true, category: 'Finance', description: 'Tax & Zekat' },
|
||||
{ id: 'launchpad', name: 'Launchpad', icon: '🚀', isEmoji: true, category: 'Finance', description: 'Startup Funding' },
|
||||
{ id: 'cards', name: 'Cards', icon: '💳', isEmoji: true, category: 'Finance', description: 'Pezkuwi Cards' },
|
||||
|
||||
// GOVERNANCE
|
||||
{ id: 'president', name: 'President', icon: '👑', isEmoji: true, category: 'Governance', description: 'Presidency Office' },
|
||||
{ id: 'assembly', name: 'Assembly', icon: qaGovernance, isEmoji: false, category: 'Governance', description: 'National Assembly' },
|
||||
{ id: 'vote', name: 'Vote', icon: '🗳️', isEmoji: true, category: 'Governance', description: 'Decentralized Voting' },
|
||||
{ id: 'validators', name: 'Validators', icon: '🛡️', isEmoji: true, category: 'Governance', description: 'Network Security' },
|
||||
{ id: 'justice', name: 'Justice', icon: '⚖️', isEmoji: true, category: 'Governance', description: 'Digital Court' },
|
||||
{ id: 'proposals', name: 'Proposals', icon: '📜', isEmoji: true, category: 'Governance', description: 'Law Proposals' },
|
||||
{ id: 'polls', name: 'Polls', icon: '📊', isEmoji: true, category: 'Governance', description: 'Public Surveys' },
|
||||
{ id: 'identity', name: 'Identity', icon: '🆔', isEmoji: true, category: 'Governance', description: 'Digital ID' },
|
||||
|
||||
// SOCIAL
|
||||
{ id: 'whatskurd', name: 'whatsKURD', icon: '💬', isEmoji: true, category: 'Social', description: 'Messenger' },
|
||||
{ id: 'forum', name: 'Forum', icon: qaForum, isEmoji: false, category: 'Social', description: 'Community Talk' },
|
||||
{ id: 'kurdmedia', name: 'KurdMedia', icon: qaKurdMedia, isEmoji: false, category: 'Social', description: 'News & Media' },
|
||||
{ id: 'events', name: 'Events', icon: '🎭', isEmoji: true, category: 'Social', description: 'Çalakî' },
|
||||
{ id: 'help', name: 'Help', icon: '🤝', isEmoji: true, category: 'Social', description: 'Harîkarî' },
|
||||
{ id: 'music', name: 'Music', icon: '🎵', isEmoji: true, category: 'Social', description: 'Kurdish Stream' },
|
||||
{ id: 'vpn', name: 'VPN', icon: '🛡️', isEmoji: true, category: 'Social', description: 'Secure Net' },
|
||||
{ id: 'referral', name: 'Referral', icon: '👥', isEmoji: true, category: 'Social', description: 'Invite Friends' },
|
||||
|
||||
// EDUCATION
|
||||
{ id: 'university', name: 'University', icon: qaUniversity, isEmoji: false, category: 'Education', description: 'Higher Ed' },
|
||||
{ id: 'perwerde', name: 'Perwerde', icon: qaEducation, isEmoji: false, category: 'Education', description: 'Academy' },
|
||||
{ id: 'library', name: 'Library', icon: '📚', isEmoji: true, category: 'Education', description: 'Pirtûkxane' },
|
||||
{ id: 'language', name: 'Language', icon: '🗣️', isEmoji: true, category: 'Education', description: 'Ziman / Learn' },
|
||||
{ id: 'kids', name: 'Kids', icon: '🧸', isEmoji: true, category: 'Education', description: 'Zarok TV' },
|
||||
{ id: 'certificates', name: 'Certificates', icon: '🏆', isEmoji: true, category: 'Education', description: 'NFT Diplomas' },
|
||||
{ id: 'research', name: 'Research', icon: '🔬', isEmoji: true, category: 'Education', description: 'Scientific Data' },
|
||||
{ id: 'history', name: 'History', icon: '🏺', isEmoji: true, category: 'Education', description: 'Kurdish History' },
|
||||
];
|
||||
|
||||
const AppsScreen: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<CategoryType>('All');
|
||||
|
||||
const filteredApps = useMemo(() => {
|
||||
return APPS_DATA.filter(app => {
|
||||
const matchesSearch = app.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory = selectedCategory === 'All' || app.category === selectedCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
}, [searchQuery, selectedCategory]);
|
||||
|
||||
const handleAppPress = (appName: string) => {
|
||||
Alert.alert(
|
||||
'Di bin çêkirinê de ye / Under Maintenance',
|
||||
`The "${appName}" mini-app is currently under development. Please check back later.\n\nSpas ji bo sebra we.`,
|
||||
[{ text: 'Temam (OK)' }]
|
||||
);
|
||||
};
|
||||
|
||||
const renderCategoryChip = (category: CategoryType) => (
|
||||
<TouchableOpacity
|
||||
key={category}
|
||||
style={[
|
||||
styles.categoryChip,
|
||||
selectedCategory === category && styles.categoryChipActive
|
||||
]}
|
||||
onPress={() => setSelectedCategory(category)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.categoryText,
|
||||
selectedCategory === category && styles.categoryTextActive
|
||||
]}>
|
||||
{category}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const renderAppItem = ({ item }: { item: MiniApp }) => (
|
||||
<TouchableOpacity
|
||||
style={styles.appCard}
|
||||
onPress={() => handleAppPress(item.name)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.iconContainer}>
|
||||
{item.isEmoji ? (
|
||||
<Text style={styles.emojiIcon}>{item.icon}</Text>
|
||||
) : (
|
||||
<Image source={item.icon} style={styles.imageIcon} resizeMode="cover" />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.appName} numberOfLines={1}>{item.name}</Text>
|
||||
<Text style={styles.appDesc} numberOfLines={1}>{item.description}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor="#F5F5F5" />
|
||||
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Apps Store</Text>
|
||||
<Text style={styles.headerSubtitle}>Discover Pezkuwi Ecosystem</Text>
|
||||
</View>
|
||||
|
||||
{/* Search Bar */}
|
||||
<View style={styles.searchContainer}>
|
||||
<View style={styles.searchBar}>
|
||||
<Text style={styles.searchIcon}>🔍</Text>
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Search apps..."
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<TouchableOpacity onPress={() => setSearchQuery('')}>
|
||||
<Text style={styles.clearIcon}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Category Filter */}
|
||||
<View style={styles.categoriesContainer}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.categoriesContent}>
|
||||
{['All', 'Finance', 'Governance', 'Social', 'Education'].map((cat) =>
|
||||
renderCategoryChip(cat as CategoryType)
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Apps Grid */}
|
||||
<FlatList
|
||||
data={filteredApps}
|
||||
renderItem={renderAppItem}
|
||||
keyExtractor={item => item.id}
|
||||
numColumns={COLUMN_COUNT}
|
||||
contentContainerStyle={styles.listContent}
|
||||
columnWrapperStyle={styles.columnWrapper}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>No apps found matching "{searchQuery}"</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
},
|
||||
searchContainer: {
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 16,
|
||||
},
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
height: 48,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
searchIcon: {
|
||||
fontSize: 18,
|
||||
marginRight: 8,
|
||||
opacity: 0.5,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
height: '100%',
|
||||
},
|
||||
clearIcon: {
|
||||
fontSize: 16,
|
||||
color: '#999',
|
||||
padding: 4,
|
||||
},
|
||||
categoriesContainer: {
|
||||
marginBottom: 10,
|
||||
},
|
||||
categoriesContent: {
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
categoryChip: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#E0E0E0',
|
||||
marginRight: 8,
|
||||
},
|
||||
categoryChipActive: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
},
|
||||
categoryText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#555',
|
||||
},
|
||||
categoryTextActive: {
|
||||
color: KurdistanColors.spi,
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 40,
|
||||
paddingTop: 8,
|
||||
},
|
||||
columnWrapper: {
|
||||
justifyContent: 'flex-start',
|
||||
gap: 8,
|
||||
},
|
||||
appCard: {
|
||||
width: ITEM_WIDTH,
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 16,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#F8F9FA',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
imageIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
},
|
||||
emojiIcon: {
|
||||
fontSize: 24,
|
||||
},
|
||||
appName: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
color: '#333',
|
||||
marginBottom: 2,
|
||||
textAlign: 'center',
|
||||
},
|
||||
appDesc: {
|
||||
fontSize: 10,
|
||||
color: '#888',
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
padding: 40,
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
color: '#999',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export default AppsScreen;
|
||||
@@ -0,0 +1,631 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
|
||||
const AuthScreen: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { signIn, signUp } = useAuth();
|
||||
|
||||
// Tab state
|
||||
const [activeTab, setActiveTab] = useState<'signin' | 'signup'>('signin');
|
||||
|
||||
// Sign In state
|
||||
const [loginEmail, setLoginEmail] = useState('');
|
||||
const [loginPassword, setLoginPassword] = useState('');
|
||||
const [showLoginPassword, setShowLoginPassword] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
|
||||
// Sign Up state
|
||||
const [signupName, setSignupName] = useState('');
|
||||
const [signupEmail, setSignupEmail] = useState('');
|
||||
const [signupPassword, setSignupPassword] = useState('');
|
||||
const [signupConfirmPassword, setSignupConfirmPassword] = useState('');
|
||||
const [signupReferralCode, setSignupReferralCode] = useState('');
|
||||
const [showSignupPassword, setShowSignupPassword] = useState(false);
|
||||
|
||||
// Common state
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSignIn = async () => {
|
||||
setError('');
|
||||
|
||||
if (!loginEmail || !loginPassword) {
|
||||
setError(t('auth.fillAllFields', 'Please fill in all fields'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { error: signInError } = await signIn(loginEmail, loginPassword, rememberMe);
|
||||
|
||||
if (signInError) {
|
||||
if (signInError.message?.includes('Invalid login credentials')) {
|
||||
setError(t('auth.invalidCredentials', 'Email or password is incorrect'));
|
||||
} else {
|
||||
setError(signInError.message || t('auth.loginFailed', 'Login failed'));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('auth.loginFailed', 'Login failed. Please try again.'));
|
||||
if (__DEV__) console.error('Sign in error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignUp = async () => {
|
||||
setError('');
|
||||
|
||||
if (!signupName || !signupEmail || !signupPassword || !signupConfirmPassword) {
|
||||
setError(t('auth.fillAllFields', 'Please fill in all required fields'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (signupPassword !== signupConfirmPassword) {
|
||||
setError(t('auth.passwordsDoNotMatch', 'Passwords do not match'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (signupPassword.length < 8) {
|
||||
setError(t('auth.passwordTooShort', 'Password must be at least 8 characters'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { error: signUpError } = await signUp(
|
||||
signupEmail,
|
||||
signupPassword,
|
||||
signupName,
|
||||
signupReferralCode
|
||||
);
|
||||
|
||||
if (signUpError) {
|
||||
setError(signUpError.message || t('auth.signupFailed', 'Sign up failed'));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('auth.signupFailed', 'Sign up failed. Please try again.'));
|
||||
if (__DEV__) console.error('Sign up error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
<LinearGradient
|
||||
colors={['#111827', '#000000', '#111827']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
>
|
||||
{/* Grid overlay */}
|
||||
<View style={styles.gridOverlay} />
|
||||
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardView}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Card Container */}
|
||||
<View style={styles.card}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.logoContainer}>
|
||||
<Image
|
||||
source={require('../../assets/kurdistan-map.png')}
|
||||
style={styles.logoImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.brandTitle}>PezkuwiChain</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{t('login.subtitle', 'Access your governance account')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Tabs */}
|
||||
<View style={styles.tabContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, activeTab === 'signin' && styles.tabActive]}
|
||||
onPress={() => {
|
||||
setActiveTab('signin');
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'signin' && styles.tabTextActive]}>
|
||||
{t('login.signin', 'Sign In')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, activeTab === 'signup' && styles.tabActive]}
|
||||
onPress={() => {
|
||||
setActiveTab('signup');
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'signup' && styles.tabTextActive]}>
|
||||
{t('login.signup', 'Sign Up')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Sign In Form */}
|
||||
{activeTab === 'signin' && (
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>{t('login.email', 'Email')}</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputIcon}>✉️</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="name@example.com"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={loginEmail}
|
||||
onChangeText={setLoginEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
editable={!loading}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>{t('login.password', 'Password')}</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputIcon}>🔒</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="••••••••"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={loginPassword}
|
||||
onChangeText={setLoginPassword}
|
||||
secureTextEntry={!showLoginPassword}
|
||||
editable={!loading}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.eyeButton}
|
||||
onPress={() => setShowLoginPassword(!showLoginPassword)}
|
||||
>
|
||||
<Text style={styles.eyeIcon}>{showLoginPassword ? '👁️' : '👁️🗨️'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.rowBetween}>
|
||||
<TouchableOpacity
|
||||
style={styles.checkboxContainer}
|
||||
onPress={() => setRememberMe(!rememberMe)}
|
||||
>
|
||||
<View style={[styles.checkbox, rememberMe && styles.checkboxChecked]}>
|
||||
{rememberMe && <Text style={styles.checkmark}>✓</Text>}
|
||||
</View>
|
||||
<Text style={styles.checkboxLabel}>
|
||||
{t('login.rememberMe', 'Remember me')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity>
|
||||
<Text style={styles.linkText}>
|
||||
{t('login.forgotPassword', 'Forgot password?')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorIcon}>⚠️</Text>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryButton, styles.signInButton, loading && styles.buttonDisabled]}
|
||||
onPress={handleSignIn}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#FFFFFF" />
|
||||
) : (
|
||||
<Text style={styles.primaryButtonText}>
|
||||
{t('login.signin', 'Sign In')}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Sign Up Form */}
|
||||
{activeTab === 'signup' && (
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>{t('login.fullName', 'Full Name')}</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputIcon}>👤</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="John Doe"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={signupName}
|
||||
onChangeText={setSignupName}
|
||||
editable={!loading}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>{t('login.email', 'Email')}</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputIcon}>✉️</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="name@example.com"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={signupEmail}
|
||||
onChangeText={setSignupEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
editable={!loading}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>{t('login.password', 'Password')}</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputIcon}>🔒</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="••••••••"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={signupPassword}
|
||||
onChangeText={setSignupPassword}
|
||||
secureTextEntry={!showSignupPassword}
|
||||
editable={!loading}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.eyeButton}
|
||||
onPress={() => setShowSignupPassword(!showSignupPassword)}
|
||||
>
|
||||
<Text style={styles.eyeIcon}>{showSignupPassword ? '👁️' : '👁️🗨️'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>{t('login.confirmPassword', 'Confirm Password')}</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputIcon}>🔒</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="••••••••"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={signupConfirmPassword}
|
||||
onChangeText={setSignupConfirmPassword}
|
||||
secureTextEntry
|
||||
editable={!loading}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>
|
||||
{t('login.referralCode', 'Referral Code')}{' '}
|
||||
<Text style={styles.optionalText}>
|
||||
({t('login.optional', 'Optional')})
|
||||
</Text>
|
||||
</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputIcon}>👥</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder={t('login.enterReferralCode', 'Referral code (optional)')}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={signupReferralCode}
|
||||
onChangeText={setSignupReferralCode}
|
||||
editable={!loading}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.hintText}>
|
||||
{t('login.referralDescription', 'If someone referred you, enter their code here')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorIcon}>⚠️</Text>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryButton, styles.signUpButton, loading && styles.buttonDisabled]}
|
||||
onPress={handleSignUp}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#FFFFFF" />
|
||||
) : (
|
||||
<Text style={styles.primaryButtonText}>
|
||||
{t('login.createAccount', 'Create Account')}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>
|
||||
{t('login.terms', 'By continuing, you agree to our')}{' '}
|
||||
</Text>
|
||||
<View style={styles.footerLinks}>
|
||||
<Text style={styles.footerLink}>
|
||||
{t('login.termsOfService', 'Terms of Service')}
|
||||
</Text>
|
||||
<Text style={styles.footerText}> {t('login.and', 'and')} </Text>
|
||||
<Text style={styles.footerLink}>
|
||||
{t('login.privacyPolicy', 'Privacy Policy')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</LinearGradient>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000000',
|
||||
},
|
||||
gradient: {
|
||||
flex: 1,
|
||||
},
|
||||
gridOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
opacity: 0.1,
|
||||
},
|
||||
keyboardView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
padding: 16,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: 'rgba(17, 24, 39, 0.9)',
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(55, 65, 81, 0.5)',
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
logoContainer: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: '#FFFFFF',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
padding: 8,
|
||||
},
|
||||
logoImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
brandTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
marginBottom: 4,
|
||||
color: '#10B981',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: '#9CA3AF',
|
||||
textAlign: 'center',
|
||||
},
|
||||
tabContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#1F2937',
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
marginBottom: 24,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: 8,
|
||||
alignItems: 'center',
|
||||
borderRadius: 6,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: '#374151',
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
tabTextActive: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
form: {
|
||||
gap: 16,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: '#D1D5DB',
|
||||
marginBottom: 8,
|
||||
},
|
||||
optionalText: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#1F2937',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#374151',
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
inputIcon: {
|
||||
fontSize: 16,
|
||||
marginRight: 8,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
eyeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
eyeIcon: {
|
||||
fontSize: 20,
|
||||
},
|
||||
hintText: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
marginTop: 4,
|
||||
},
|
||||
rowBetween: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
checkboxContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
checkbox: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 4,
|
||||
borderWidth: 2,
|
||||
borderColor: '#10B981',
|
||||
marginRight: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
checkboxChecked: {
|
||||
backgroundColor: '#10B981',
|
||||
},
|
||||
checkmark: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
checkboxLabel: {
|
||||
fontSize: 14,
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
linkText: {
|
||||
fontSize: 14,
|
||||
color: '#10B981',
|
||||
fontWeight: '500',
|
||||
},
|
||||
errorContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
errorIcon: {
|
||||
fontSize: 16,
|
||||
marginRight: 8,
|
||||
},
|
||||
errorText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: '#FCA5A5',
|
||||
},
|
||||
primaryButton: {
|
||||
paddingVertical: 14,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
signInButton: {
|
||||
backgroundColor: '#059669',
|
||||
},
|
||||
signUpButton: {
|
||||
backgroundColor: '#D97706',
|
||||
},
|
||||
primaryButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
footer: {
|
||||
marginTop: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
textAlign: 'center',
|
||||
},
|
||||
footerLinks: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
footerLink: {
|
||||
fontSize: 12,
|
||||
color: '#10B981',
|
||||
},
|
||||
});
|
||||
|
||||
export default AuthScreen;
|
||||
@@ -0,0 +1,644 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
TextInput,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
import {
|
||||
submitKycApplication,
|
||||
uploadToIPFS,
|
||||
FOUNDER_ADDRESS,
|
||||
} from '@pezkuwi/lib/citizenship-workflow';
|
||||
import type { Region, MaritalStatus } from '@pezkuwi/lib/citizenship-workflow';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
|
||||
// Temporary custom picker component (until we fix @react-native-picker/picker installation)
|
||||
const CustomPicker: React.FC<{
|
||||
selectedValue: Region;
|
||||
onValueChange: (value: Region) => void;
|
||||
options: Array<{ label: string; value: Region }>;
|
||||
}> = ({ selectedValue, onValueChange, options }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const selectedOption = options.find(opt => opt.value === selectedValue);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.pickerButton}
|
||||
onPress={() => setIsVisible(true)}
|
||||
>
|
||||
<Text style={styles.pickerButtonText}>
|
||||
{selectedOption?.label || 'Select Region'}
|
||||
</Text>
|
||||
<Text style={styles.pickerArrow}>▼</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={isVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsVisible(false)}
|
||||
>
|
||||
<View style={styles.pickerModal}>
|
||||
<View style={styles.pickerHeader}>
|
||||
<Text style={styles.pickerTitle}>Select Your Region</Text>
|
||||
<TouchableOpacity onPress={() => setIsVisible(false)}>
|
||||
<Text style={styles.pickerClose}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{options.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
style={[
|
||||
styles.pickerOption,
|
||||
selectedValue === option.value && styles.pickerOptionSelected,
|
||||
]}
|
||||
onPress={() => {
|
||||
onValueChange(option.value);
|
||||
setIsVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.pickerOptionText,
|
||||
selectedValue === option.value && styles.pickerOptionTextSelected,
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
{selectedValue === option.value && (
|
||||
<Text style={styles.pickerCheckmark}>✓</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const BeCitizenApplyScreen: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const { api, selectedAccount } = usePezkuwi();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Form State
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [fatherName, setFatherName] = useState('');
|
||||
const [grandfatherName, setGrandfatherName] = useState('');
|
||||
const [motherName, setMotherName] = useState('');
|
||||
const [tribe, setTribe] = useState('');
|
||||
const [maritalStatus, setMaritalStatus] = useState<MaritalStatus>('nezewici');
|
||||
const [childrenCount, setChildrenCount] = useState(0);
|
||||
const [children, setChildren] = useState<Array<{ name: string; birthYear: number }>>([]);
|
||||
const [region, setRegion] = useState<Region>('basur');
|
||||
const [email, setEmail] = useState('');
|
||||
const [profession, setProfession] = useState('');
|
||||
const [referralCode, setReferralCode] = useState('');
|
||||
|
||||
const regionOptions = [
|
||||
{ label: 'Bakur (North - Turkey/Türkiye)', value: 'bakur' as Region },
|
||||
{ label: 'Başûr (South - Iraq)', value: 'basur' as Region },
|
||||
{ label: 'Rojava (West - Syria)', value: 'rojava' as Region },
|
||||
{ label: 'Rojhilat (East - Iran)', value: 'rojhelat' as Region },
|
||||
{ label: 'Kurdistan a Sor (Red Kurdistan)', value: 'kurdistan_a_sor' as Region },
|
||||
{ label: 'Diaspora (Living Abroad)', value: 'diaspora' as Region },
|
||||
];
|
||||
|
||||
const handleChildCountChange = (count: number) => {
|
||||
setChildrenCount(count);
|
||||
// Initialize children array
|
||||
const newChildren = Array.from({ length: count }, (_, i) =>
|
||||
children[i] || { name: '', birthYear: new Date().getFullYear() }
|
||||
);
|
||||
setChildren(newChildren);
|
||||
};
|
||||
|
||||
const updateChild = (index: number, field: 'name' | 'birthYear', value: string | number) => {
|
||||
const updated = [...children];
|
||||
if (field === 'name') {
|
||||
updated[index].name = value as string;
|
||||
} else {
|
||||
updated[index].birthYear = value as number;
|
||||
}
|
||||
setChildren(updated);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!fullName || !fatherName || !grandfatherName || !motherName || !tribe || !region || !email || !profession) {
|
||||
Alert.alert('Error', 'Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!api || !selectedAccount) {
|
||||
Alert.alert('Error', 'Please connect your wallet first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Prepare citizenship data
|
||||
const citizenshipData = {
|
||||
fullName,
|
||||
fatherName,
|
||||
grandfatherName,
|
||||
motherName,
|
||||
tribe,
|
||||
maritalStatus,
|
||||
childrenCount: maritalStatus === 'zewici' ? childrenCount : 0,
|
||||
children: maritalStatus === 'zewici' ? children.filter(c => c.name) : [],
|
||||
region,
|
||||
email,
|
||||
profession,
|
||||
referralCode: referralCode || FOUNDER_ADDRESS, // Auto-assign to founder if empty
|
||||
walletAddress: selectedAccount.address,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Step 1: Upload encrypted data to IPFS
|
||||
const ipfsCid = await uploadToIPFS(citizenshipData);
|
||||
|
||||
if (!ipfsCid) {
|
||||
throw new Error('Failed to upload data to IPFS');
|
||||
}
|
||||
|
||||
// Step 2: Submit KYC application to blockchain
|
||||
const result = await submitKycApplication(
|
||||
api,
|
||||
selectedAccount,
|
||||
citizenshipData.fullName,
|
||||
citizenshipData.email,
|
||||
String(ipfsCid),
|
||||
'Citizenship application via mobile app'
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
Alert.alert(
|
||||
'Application Submitted!',
|
||||
'Your citizenship application has been submitted for review. You will receive a confirmation once approved.',
|
||||
[
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => {
|
||||
navigation.goBack();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
} else {
|
||||
Alert.alert('Application Failed', result.error || 'Failed to submit application');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (__DEV__) console.error('Citizenship application error:', error);
|
||||
Alert.alert('Error', error instanceof Error ? error.message : 'An unexpected error occurred');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" />
|
||||
<ScrollView style={styles.formContainer} showsVerticalScrollIndicator={false}>
|
||||
<Text style={styles.formTitle}>Nasnameya Kesane</Text>
|
||||
<Text style={styles.formSubtitle}>Personal Identity - Citizenship Application</Text>
|
||||
|
||||
{/* Personal Identity */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Personal Identity</Text>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Navê Te (Your Full Name) *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g., Berzê Ronahî"
|
||||
value={fullName}
|
||||
onChangeText={setFullName}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Navê Bavê Te (Father's Name) *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g., Şêrko"
|
||||
value={fatherName}
|
||||
onChangeText={setFatherName}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Navê Bavkalê Te (Grandfather's Name) *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g., Welat"
|
||||
value={grandfatherName}
|
||||
onChangeText={setGrandfatherName}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Navê Dayika Te (Mother's Name) *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g., Gula"
|
||||
value={motherName}
|
||||
onChangeText={setMotherName}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tribal Affiliation */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Eşîra Te (Tribal Affiliation)</Text>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Eşîra Te (Your Tribe) *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g., Barzanî, Soran, Hewramî..."
|
||||
value={tribe}
|
||||
onChangeText={setTribe}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Family Status */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Rewşa Malbatê (Family Status)</Text>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Zewicî / Nezewicî (Married / Unmarried) *</Text>
|
||||
<View style={styles.radioGroup}>
|
||||
<TouchableOpacity
|
||||
style={styles.radioButton}
|
||||
onPress={() => setMaritalStatus('zewici')}
|
||||
>
|
||||
<View style={[styles.radioCircle, maritalStatus === 'zewici' && styles.radioSelected]}>
|
||||
{maritalStatus === 'zewici' && <View style={styles.radioDot} />}
|
||||
</View>
|
||||
<Text style={styles.radioLabel}>Zewicî (Married)</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.radioButton}
|
||||
onPress={() => setMaritalStatus('nezewici')}
|
||||
>
|
||||
<View style={[styles.radioCircle, maritalStatus === 'nezewici' && styles.radioSelected]}>
|
||||
{maritalStatus === 'nezewici' && <View style={styles.radioDot} />}
|
||||
</View>
|
||||
<Text style={styles.radioLabel}>Nezewicî (Unmarried)</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{maritalStatus === 'zewici' && (
|
||||
<>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Hejmara Zarokan (Number of Children)</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="0"
|
||||
value={String(childrenCount)}
|
||||
onChangeText={(text) => handleChildCountChange(parseInt(text) || 0)}
|
||||
keyboardType="number-pad"
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{childrenCount > 0 && (
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Navên Zarokan (Children's Names)</Text>
|
||||
{children.map((child, index) => (
|
||||
<View key={index} style={styles.childRow}>
|
||||
<TextInput
|
||||
style={[styles.input, styles.childInput]}
|
||||
placeholder={`Child ${index + 1} Name`}
|
||||
value={child.name}
|
||||
onChangeText={(text) => updateChild(index, 'name', text)}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.input, styles.childInput]}
|
||||
placeholder="Birth Year"
|
||||
value={String(child.birthYear)}
|
||||
onChangeText={(text) => updateChild(index, 'birthYear', parseInt(text) || new Date().getFullYear())}
|
||||
keyboardType="number-pad"
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Geographic Origin */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Herêma Te (Your Region)</Text>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Ji Kuderê yî? (Where are you from?) *</Text>
|
||||
<CustomPicker
|
||||
selectedValue={region}
|
||||
onValueChange={setRegion}
|
||||
options={regionOptions}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Contact & Profession */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Têkilî û Pîşe (Contact & Profession)</Text>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>E-mail *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="example@email.com"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Pîşeya Te (Your Profession) *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g., Mamosta, Bijîşk, Xebatkar..."
|
||||
value={profession}
|
||||
onChangeText={setProfession}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Referral */}
|
||||
<View style={[styles.section, styles.referralSection]}>
|
||||
<Text style={styles.sectionTitle}>Koda Referral (Referral Code - Optional)</Text>
|
||||
<Text style={styles.sectionDescription}>
|
||||
If you were invited by another citizen, enter their referral code
|
||||
</Text>
|
||||
<View style={styles.inputGroup}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Referral code (optional)"
|
||||
value={referralCode}
|
||||
onChangeText={setReferralCode}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
<Text style={styles.helpText}>
|
||||
If empty, you will be automatically linked to the Founder (Satoshi Qazi Muhammed)
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.submitButton, isSubmitting && styles.submitButtonDisabled]}
|
||||
onPress={handleSubmit}
|
||||
activeOpacity={0.8}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<ActivityIndicator color={KurdistanColors.spi} />
|
||||
) : (
|
||||
<Text style={styles.submitButtonText}>Submit Application</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.spacer} />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
formContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
formTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.reş,
|
||||
marginBottom: 4,
|
||||
},
|
||||
formSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 24,
|
||||
},
|
||||
section: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.05)',
|
||||
elevation: 2,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: KurdistanColors.kesk,
|
||||
marginBottom: 12,
|
||||
},
|
||||
sectionDescription: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: 12,
|
||||
},
|
||||
referralSection: {
|
||||
backgroundColor: `${KurdistanColors.mor}10`,
|
||||
borderWidth: 1,
|
||||
borderColor: `${KurdistanColors.mor}30`,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E0E0E0',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
radioGroup: {
|
||||
gap: 12,
|
||||
},
|
||||
radioButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
radioCircle: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
borderWidth: 2,
|
||||
borderColor: KurdistanColors.kesk,
|
||||
marginRight: 10,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
radioSelected: {
|
||||
borderColor: KurdistanColors.kesk,
|
||||
},
|
||||
radioDot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
},
|
||||
radioLabel: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
childRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
childInput: {
|
||||
flex: 1,
|
||||
marginBottom: 0,
|
||||
},
|
||||
pickerButton: {
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E0E0E0',
|
||||
padding: 12,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
pickerButtonText: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
pickerArrow: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
pickerModal: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
maxHeight: '70%',
|
||||
paddingBottom: 20,
|
||||
},
|
||||
pickerHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E0E0E0',
|
||||
},
|
||||
pickerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
pickerClose: {
|
||||
fontSize: 24,
|
||||
color: '#666',
|
||||
fontWeight: '300',
|
||||
},
|
||||
pickerOption: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F0F0F0',
|
||||
},
|
||||
pickerOptionSelected: {
|
||||
backgroundColor: `${KurdistanColors.kesk}10`,
|
||||
},
|
||||
pickerOptionText: {
|
||||
fontSize: 15,
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
pickerOptionTextSelected: {
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
pickerCheckmark: {
|
||||
fontSize: 18,
|
||||
color: KurdistanColors.kesk,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
helpText: {
|
||||
fontSize: 11,
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
marginTop: 20,
|
||||
boxShadow: '0px 4px 6px rgba(0, 128, 0, 0.3)',
|
||||
elevation: 6,
|
||||
},
|
||||
submitButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
submitButtonText: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
},
|
||||
spacer: {
|
||||
height: 40,
|
||||
},
|
||||
});
|
||||
|
||||
export default BeCitizenApplyScreen;
|
||||
@@ -0,0 +1,650 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
TextInput,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
import {
|
||||
submitKycApplication,
|
||||
uploadToIPFS,
|
||||
FOUNDER_ADDRESS,
|
||||
} from '@pezkuwi/lib/citizenship-workflow';
|
||||
import type { Region, MaritalStatus } from '@pezkuwi/lib/citizenship-workflow';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
|
||||
// Temporary custom picker component (until we fix @react-native-picker/picker installation)
|
||||
const CustomPicker: React.FC<{
|
||||
selectedValue: Region;
|
||||
onValueChange: (value: Region) => void;
|
||||
options: Array<{ label: string; value: Region }>;
|
||||
}> = ({ selectedValue, onValueChange, options }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const selectedOption = options.find(opt => opt.value === selectedValue);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.pickerButton}
|
||||
onPress={() => setIsVisible(true)}
|
||||
>
|
||||
<Text style={styles.pickerButtonText}>
|
||||
{selectedOption?.label || 'Select Region'}
|
||||
</Text>
|
||||
<Text style={styles.pickerArrow}>▼</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={isVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsVisible(false)}
|
||||
>
|
||||
<View style={styles.pickerModal}>
|
||||
<View style={styles.pickerHeader}>
|
||||
<Text style={styles.pickerTitle}>Select Your Region</Text>
|
||||
<TouchableOpacity onPress={() => setIsVisible(false)}>
|
||||
<Text style={styles.pickerClose}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{options.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
style={[
|
||||
styles.pickerOption,
|
||||
selectedValue === option.value && styles.pickerOptionSelected,
|
||||
]}
|
||||
onPress={() => {
|
||||
onValueChange(option.value);
|
||||
setIsVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.pickerOptionText,
|
||||
selectedValue === option.value && styles.pickerOptionTextSelected,
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
{selectedValue === option.value && (
|
||||
<Text style={styles.pickerCheckmark}>✓</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const BeCitizenApplyScreen: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const { api, selectedAccount } = usePezkuwi();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Form State
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [fatherName, setFatherName] = useState('');
|
||||
const [grandfatherName, setGrandfatherName] = useState('');
|
||||
const [motherName, setMotherName] = useState('');
|
||||
const [tribe, setTribe] = useState('');
|
||||
const [maritalStatus, setMaritalStatus] = useState<MaritalStatus>('nezewici');
|
||||
const [childrenCount, setChildrenCount] = useState(0);
|
||||
const [children, setChildren] = useState<Array<{ name: string; birthYear: number }>>([]);
|
||||
const [region, setRegion] = useState<Region>('basur');
|
||||
const [email, setEmail] = useState('');
|
||||
const [profession, setProfession] = useState('');
|
||||
const [referralCode, setReferralCode] = useState('');
|
||||
|
||||
const regionOptions = [
|
||||
{ label: 'Bakur (North - Turkey/Türkiye)', value: 'bakur' as Region },
|
||||
{ label: 'Başûr (South - Iraq)', value: 'basur' as Region },
|
||||
{ label: 'Rojava (West - Syria)', value: 'rojava' as Region },
|
||||
{ label: 'Rojhilat (East - Iran)', value: 'rojhelat' as Region },
|
||||
{ label: 'Kurdistan a Sor (Red Kurdistan)', value: 'kurdistan_a_sor' as Region },
|
||||
{ label: 'Diaspora (Living Abroad)', value: 'diaspora' as Region },
|
||||
];
|
||||
|
||||
const handleChildCountChange = (count: number) => {
|
||||
setChildrenCount(count);
|
||||
// Initialize children array
|
||||
const newChildren = Array.from({ length: count }, (_, i) =>
|
||||
children[i] || { name: '', birthYear: new Date().getFullYear() }
|
||||
);
|
||||
setChildren(newChildren);
|
||||
};
|
||||
|
||||
const updateChild = (index: number, field: 'name' | 'birthYear', value: string | number) => {
|
||||
const updated = [...children];
|
||||
if (field === 'name') {
|
||||
updated[index].name = value as string;
|
||||
} else {
|
||||
updated[index].birthYear = value as number;
|
||||
}
|
||||
setChildren(updated);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!fullName || !fatherName || !grandfatherName || !motherName || !tribe || !region || !email || !profession) {
|
||||
Alert.alert('Error', 'Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!api || !selectedAccount) {
|
||||
Alert.alert('Error', 'Please connect your wallet first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Prepare citizenship data
|
||||
const citizenshipData = {
|
||||
fullName,
|
||||
fatherName,
|
||||
grandfatherName,
|
||||
motherName,
|
||||
tribe,
|
||||
maritalStatus,
|
||||
childrenCount: maritalStatus === 'zewici' ? childrenCount : 0,
|
||||
children: maritalStatus === 'zewici' ? children.filter(c => c.name) : [],
|
||||
region,
|
||||
email,
|
||||
profession,
|
||||
referralCode: referralCode || FOUNDER_ADDRESS, // Auto-assign to founder if empty
|
||||
walletAddress: selectedAccount.address,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Step 1: Upload encrypted data to IPFS
|
||||
const ipfsCid = await uploadToIPFS(citizenshipData);
|
||||
|
||||
if (!ipfsCid) {
|
||||
throw new Error('Failed to upload data to IPFS');
|
||||
}
|
||||
|
||||
// Step 2: Submit KYC application to blockchain
|
||||
const result = await submitKycApplication(
|
||||
api,
|
||||
selectedAccount,
|
||||
citizenshipData.fullName,
|
||||
citizenshipData.email,
|
||||
String(ipfsCid),
|
||||
'Citizenship application via mobile app'
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
Alert.alert(
|
||||
'Application Submitted!',
|
||||
'Your citizenship application has been submitted for review. You will receive a confirmation once approved.',
|
||||
[
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => {
|
||||
navigation.goBack();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
} else {
|
||||
Alert.alert('Application Failed', result.error || 'Failed to submit application');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (__DEV__) console.error('Citizenship application error:', error);
|
||||
Alert.alert('Error', error instanceof Error ? error.message : 'An unexpected error occurred');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" />
|
||||
<ScrollView style={styles.formContainer} showsVerticalScrollIndicator={false}>
|
||||
<Text style={styles.formTitle}>Nasnameya Kesane</Text>
|
||||
<Text style={styles.formSubtitle}>Personal Identity - Citizenship Application</Text>
|
||||
|
||||
{/* Personal Identity */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Personal Identity</Text>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Navê Te (Your Full Name) *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g., Berzê Ronahî"
|
||||
value={fullName}
|
||||
onChangeText={setFullName}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Navê Bavê Te (Father's Name) *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g., Şêrko"
|
||||
value={fatherName}
|
||||
onChangeText={setFatherName}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Navê Bavkalê Te (Grandfather's Name) *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g., Welat"
|
||||
value={grandfatherName}
|
||||
onChangeText={setGrandfatherName}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Navê Dayika Te (Mother's Name) *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g., Gula"
|
||||
value={motherName}
|
||||
onChangeText={setMotherName}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tribal Affiliation */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Eşîra Te (Tribal Affiliation)</Text>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Eşîra Te (Your Tribe) *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g., Barzanî, Soran, Hewramî..."
|
||||
value={tribe}
|
||||
onChangeText={setTribe}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Family Status */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Rewşa Malbatê (Family Status)</Text>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Zewicî / Nezewicî (Married / Unmarried) *</Text>
|
||||
<View style={styles.radioGroup}>
|
||||
<TouchableOpacity
|
||||
style={styles.radioButton}
|
||||
onPress={() => setMaritalStatus('zewici')}
|
||||
>
|
||||
<View style={[styles.radioCircle, maritalStatus === 'zewici' && styles.radioSelected]}>
|
||||
{maritalStatus === 'zewici' && <View style={styles.radioDot} />}
|
||||
</View>
|
||||
<Text style={styles.radioLabel}>Zewicî (Married)</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.radioButton}
|
||||
onPress={() => setMaritalStatus('nezewici')}
|
||||
>
|
||||
<View style={[styles.radioCircle, maritalStatus === 'nezewici' && styles.radioSelected]}>
|
||||
{maritalStatus === 'nezewici' && <View style={styles.radioDot} />}
|
||||
</View>
|
||||
<Text style={styles.radioLabel}>Nezewicî (Unmarried)</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{maritalStatus === 'zewici' && (
|
||||
<>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Hejmara Zarokan (Number of Children)</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="0"
|
||||
value={String(childrenCount)}
|
||||
onChangeText={(text) => handleChildCountChange(parseInt(text) || 0)}
|
||||
keyboardType="number-pad"
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{childrenCount > 0 && (
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Navên Zarokan (Children's Names)</Text>
|
||||
{children.map((child, index) => (
|
||||
<View key={index} style={styles.childRow}>
|
||||
<TextInput
|
||||
style={[styles.input, styles.childInput]}
|
||||
placeholder={`Child ${index + 1} Name`}
|
||||
value={child.name}
|
||||
onChangeText={(text) => updateChild(index, 'name', text)}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.input, styles.childInput]}
|
||||
placeholder="Birth Year"
|
||||
value={String(child.birthYear)}
|
||||
onChangeText={(text) => updateChild(index, 'birthYear', parseInt(text) || new Date().getFullYear())}
|
||||
keyboardType="number-pad"
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Geographic Origin */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Herêma Te (Your Region)</Text>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Ji Kuderê yî? (Where are you from?) *</Text>
|
||||
<CustomPicker
|
||||
selectedValue={region}
|
||||
onValueChange={setRegion}
|
||||
options={regionOptions}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Contact & Profession */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Têkilî û Pîşe (Contact & Profession)</Text>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>E-mail *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="example@email.com"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Pîşeya Te (Your Profession) *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g., Mamosta, Bijîşk, Xebatkar..."
|
||||
value={profession}
|
||||
onChangeText={setProfession}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Referral */}
|
||||
<View style={[styles.section, styles.referralSection]}>
|
||||
<Text style={styles.sectionTitle}>Koda Referral (Referral Code - Optional)</Text>
|
||||
<Text style={styles.sectionDescription}>
|
||||
If you were invited by another citizen, enter their referral code
|
||||
</Text>
|
||||
<View style={styles.inputGroup}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Referral code (optional)"
|
||||
value={referralCode}
|
||||
onChangeText={setReferralCode}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
<Text style={styles.helpText}>
|
||||
If empty, you will be automatically linked to the Founder (Satoshi Qazi Muhammed)
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.submitButton, isSubmitting && styles.submitButtonDisabled]}
|
||||
onPress={handleSubmit}
|
||||
activeOpacity={0.8}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<ActivityIndicator color={KurdistanColors.spi} />
|
||||
) : (
|
||||
<Text style={styles.submitButtonText}>Submit Application</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.spacer} />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
formContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
formTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.reş,
|
||||
marginBottom: 4,
|
||||
},
|
||||
formSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 24,
|
||||
},
|
||||
section: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: KurdistanColors.kesk,
|
||||
marginBottom: 12,
|
||||
},
|
||||
sectionDescription: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: 12,
|
||||
},
|
||||
referralSection: {
|
||||
backgroundColor: `${KurdistanColors.mor}10`,
|
||||
borderWidth: 1,
|
||||
borderColor: `${KurdistanColors.mor}30`,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E0E0E0',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
radioGroup: {
|
||||
gap: 12,
|
||||
},
|
||||
radioButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
radioCircle: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
borderWidth: 2,
|
||||
borderColor: KurdistanColors.kesk,
|
||||
marginRight: 10,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
radioSelected: {
|
||||
borderColor: KurdistanColors.kesk,
|
||||
},
|
||||
radioDot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
},
|
||||
radioLabel: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
childRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
childInput: {
|
||||
flex: 1,
|
||||
marginBottom: 0,
|
||||
},
|
||||
pickerButton: {
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E0E0E0',
|
||||
padding: 12,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
pickerButtonText: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
pickerArrow: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
pickerModal: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
maxHeight: '70%',
|
||||
paddingBottom: 20,
|
||||
},
|
||||
pickerHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E0E0E0',
|
||||
},
|
||||
pickerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
pickerClose: {
|
||||
fontSize: 24,
|
||||
color: '#666',
|
||||
fontWeight: '300',
|
||||
},
|
||||
pickerOption: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F0F0F0',
|
||||
},
|
||||
pickerOptionSelected: {
|
||||
backgroundColor: `${KurdistanColors.kesk}10`,
|
||||
},
|
||||
pickerOptionText: {
|
||||
fontSize: 15,
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
pickerOptionTextSelected: {
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
pickerCheckmark: {
|
||||
fontSize: 18,
|
||||
color: KurdistanColors.kesk,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
helpText: {
|
||||
fontSize: 11,
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
marginTop: 20,
|
||||
shadowColor: KurdistanColors.kesk,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 6,
|
||||
elevation: 6,
|
||||
},
|
||||
submitButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
submitButtonText: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
},
|
||||
spacer: {
|
||||
height: 40,
|
||||
},
|
||||
});
|
||||
|
||||
export default BeCitizenApplyScreen;
|
||||
@@ -0,0 +1,200 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import type { NavigationProp } from '@react-navigation/native';
|
||||
|
||||
type RootStackParamList = {
|
||||
BeCitizenChoice: undefined;
|
||||
BeCitizenApply: undefined;
|
||||
BeCitizenClaim: undefined;
|
||||
};
|
||||
|
||||
const BeCitizenChoiceScreen: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
<LinearGradient
|
||||
colors={[KurdistanColors.kesk, KurdistanColors.zer, KurdistanColors.sor]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.logoContainer}>
|
||||
<Text style={styles.logoText}>🏛️</Text>
|
||||
</View>
|
||||
<Text style={styles.title}>Be a Citizen</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Join the Pezkuwi decentralized nation
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.choiceContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.choiceCard}
|
||||
onPress={() => navigation.navigate('BeCitizenApply')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.choiceIcon}>📝</Text>
|
||||
<Text style={styles.choiceTitle}>New Citizen</Text>
|
||||
<Text style={styles.choiceDescription}>
|
||||
Apply for citizenship and join our community
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.choiceCard}
|
||||
onPress={() => navigation.navigate('BeCitizenClaim')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.choiceIcon}>🔐</Text>
|
||||
<Text style={styles.choiceTitle}>Existing Citizen</Text>
|
||||
<Text style={styles.choiceDescription}>
|
||||
Access your citizenship account
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoSection}>
|
||||
<Text style={styles.infoTitle}>Citizenship Benefits</Text>
|
||||
<View style={styles.benefitItem}>
|
||||
<Text style={styles.benefitIcon}>✓</Text>
|
||||
<Text style={styles.benefitText}>Voting rights in governance</Text>
|
||||
</View>
|
||||
<View style={styles.benefitItem}>
|
||||
<Text style={styles.benefitIcon}>✓</Text>
|
||||
<Text style={styles.benefitText}>Access to exclusive services</Text>
|
||||
</View>
|
||||
<View style={styles.benefitItem}>
|
||||
<Text style={styles.benefitIcon}>✓</Text>
|
||||
<Text style={styles.benefitText}>Referral rewards program</Text>
|
||||
</View>
|
||||
<View style={styles.benefitItem}>
|
||||
<Text style={styles.benefitIcon}>✓</Text>
|
||||
<Text style={styles.benefitText}>Community recognition</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</LinearGradient>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
gradient: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
padding: 20,
|
||||
paddingTop: 60,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 40,
|
||||
},
|
||||
logoContainer: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.3)',
|
||||
elevation: 8,
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 48,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: KurdistanColors.spi,
|
||||
textAlign: 'center',
|
||||
opacity: 0.9,
|
||||
},
|
||||
choiceContainer: {
|
||||
gap: 16,
|
||||
marginBottom: 40,
|
||||
},
|
||||
choiceCard: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
alignItems: 'center',
|
||||
boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.2)',
|
||||
elevation: 6,
|
||||
},
|
||||
choiceIcon: {
|
||||
fontSize: 48,
|
||||
marginBottom: 16,
|
||||
},
|
||||
choiceTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.kesk,
|
||||
marginBottom: 8,
|
||||
},
|
||||
choiceDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
infoSection: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.spi,
|
||||
marginBottom: 16,
|
||||
},
|
||||
benefitItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
benefitIcon: {
|
||||
fontSize: 16,
|
||||
color: KurdistanColors.spi,
|
||||
marginRight: 12,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
benefitText: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.spi,
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default BeCitizenChoiceScreen;
|
||||
@@ -0,0 +1,206 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import type { NavigationProp } from '@react-navigation/native';
|
||||
|
||||
type RootStackParamList = {
|
||||
BeCitizenChoice: undefined;
|
||||
BeCitizenApply: undefined;
|
||||
BeCitizenClaim: undefined;
|
||||
};
|
||||
|
||||
const BeCitizenChoiceScreen: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
<LinearGradient
|
||||
colors={[KurdistanColors.kesk, KurdistanColors.zer, KurdistanColors.sor]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.logoContainer}>
|
||||
<Text style={styles.logoText}>🏛️</Text>
|
||||
</View>
|
||||
<Text style={styles.title}>Be a Citizen</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Join the Pezkuwi decentralized nation
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.choiceContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.choiceCard}
|
||||
onPress={() => navigation.navigate('BeCitizenApply')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.choiceIcon}>📝</Text>
|
||||
<Text style={styles.choiceTitle}>New Citizen</Text>
|
||||
<Text style={styles.choiceDescription}>
|
||||
Apply for citizenship and join our community
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.choiceCard}
|
||||
onPress={() => navigation.navigate('BeCitizenClaim')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.choiceIcon}>🔐</Text>
|
||||
<Text style={styles.choiceTitle}>Existing Citizen</Text>
|
||||
<Text style={styles.choiceDescription}>
|
||||
Access your citizenship account
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoSection}>
|
||||
<Text style={styles.infoTitle}>Citizenship Benefits</Text>
|
||||
<View style={styles.benefitItem}>
|
||||
<Text style={styles.benefitIcon}>✓</Text>
|
||||
<Text style={styles.benefitText}>Voting rights in governance</Text>
|
||||
</View>
|
||||
<View style={styles.benefitItem}>
|
||||
<Text style={styles.benefitIcon}>✓</Text>
|
||||
<Text style={styles.benefitText}>Access to exclusive services</Text>
|
||||
</View>
|
||||
<View style={styles.benefitItem}>
|
||||
<Text style={styles.benefitIcon}>✓</Text>
|
||||
<Text style={styles.benefitText}>Referral rewards program</Text>
|
||||
</View>
|
||||
<View style={styles.benefitItem}>
|
||||
<Text style={styles.benefitIcon}>✓</Text>
|
||||
<Text style={styles.benefitText}>Community recognition</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</LinearGradient>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
gradient: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
padding: 20,
|
||||
paddingTop: 60,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 40,
|
||||
},
|
||||
logoContainer: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 48,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: KurdistanColors.spi,
|
||||
textAlign: 'center',
|
||||
opacity: 0.9,
|
||||
},
|
||||
choiceContainer: {
|
||||
gap: 16,
|
||||
marginBottom: 40,
|
||||
},
|
||||
choiceCard: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 6,
|
||||
},
|
||||
choiceIcon: {
|
||||
fontSize: 48,
|
||||
marginBottom: 16,
|
||||
},
|
||||
choiceTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.kesk,
|
||||
marginBottom: 8,
|
||||
},
|
||||
choiceDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
infoSection: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.spi,
|
||||
marginBottom: 16,
|
||||
},
|
||||
benefitItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
benefitIcon: {
|
||||
fontSize: 16,
|
||||
color: KurdistanColors.spi,
|
||||
marginRight: 12,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
benefitText: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.spi,
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default BeCitizenChoiceScreen;
|
||||
@@ -0,0 +1,161 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
import { getCitizenshipStatus } from '@pezkuwi/lib/citizenship-workflow';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
|
||||
const BeCitizenClaimScreen: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const { api, selectedAccount } = usePezkuwi();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!api || !selectedAccount) {
|
||||
Alert.alert('Error', 'Please connect your wallet first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const status = await getCitizenshipStatus(api, selectedAccount.address);
|
||||
|
||||
if (status.kycStatus === 'Approved' && status.hasCitizenTiki) {
|
||||
Alert.alert(
|
||||
'Success',
|
||||
`Welcome back, Citizen!\n\nYour Tiki Number: ${status.tikiNumber || 'N/A'}`,
|
||||
[
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => {
|
||||
navigation.goBack();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
} else if (status.kycStatus === 'Approved' && !status.hasCitizenTiki) {
|
||||
Alert.alert(
|
||||
'Almost there!',
|
||||
'Your KYC is approved, but you haven\'t claimed your Citizen Tiki yet. Please claim it on the web portal.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
} else if (status.kycStatus === 'Pending') {
|
||||
Alert.alert(
|
||||
'Application Pending',
|
||||
'Your citizenship application is still under review.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
} else {
|
||||
Alert.alert(
|
||||
'Not a Citizen',
|
||||
'We couldn\'t find a citizenship record for this wallet. If you have a Citizen ID and Password, please note that wallet-based verification is now preferred.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (__DEV__) console.error('Citizenship verification error:', error);
|
||||
Alert.alert('Error', 'Failed to verify citizenship status');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" />
|
||||
<ScrollView style={styles.formContainer} showsVerticalScrollIndicator={false}>
|
||||
<Text style={styles.formTitle}>Citizen Verification</Text>
|
||||
<Text style={styles.formSubtitle}>
|
||||
Verify your status using your connected wallet
|
||||
</Text>
|
||||
|
||||
<View style={styles.infoCard}>
|
||||
<Text style={styles.infoText}>
|
||||
Existing citizens are verified through their blockchain identity. Ensure your citizenship wallet is selected in the wallet tab.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.submitButton, isSubmitting && styles.submitButtonDisabled]}
|
||||
onPress={handleVerify}
|
||||
activeOpacity={0.8}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<ActivityIndicator color={KurdistanColors.spi} />
|
||||
) : (
|
||||
<Text style={styles.submitButtonText}>Verify Citizenship</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
formContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
formTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.reş,
|
||||
marginBottom: 8,
|
||||
},
|
||||
formSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 24,
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: `${KurdistanColors.kesk}15`,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 24,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: KurdistanColors.kesk,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.reş,
|
||||
lineHeight: 20,
|
||||
opacity: 0.8,
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
marginTop: 20,
|
||||
boxShadow: '0px 4px 6px rgba(0, 128, 0, 0.3)',
|
||||
elevation: 6,
|
||||
},
|
||||
submitButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
submitButtonText: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
},
|
||||
});
|
||||
|
||||
export default BeCitizenClaimScreen;
|
||||
@@ -0,0 +1,164 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
import { getCitizenshipStatus } from '@pezkuwi/lib/citizenship-workflow';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
|
||||
const BeCitizenClaimScreen: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const { api, selectedAccount } = usePezkuwi();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!api || !selectedAccount) {
|
||||
Alert.alert('Error', 'Please connect your wallet first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const status = await getCitizenshipStatus(api, selectedAccount.address);
|
||||
|
||||
if (status.kycStatus === 'Approved' && status.hasCitizenTiki) {
|
||||
Alert.alert(
|
||||
'Success',
|
||||
`Welcome back, Citizen!\n\nYour Tiki Number: ${status.tikiNumber || 'N/A'}`,
|
||||
[
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => {
|
||||
navigation.goBack();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
} else if (status.kycStatus === 'Approved' && !status.hasCitizenTiki) {
|
||||
Alert.alert(
|
||||
'Almost there!',
|
||||
'Your KYC is approved, but you haven\'t claimed your Citizen Tiki yet. Please claim it on the web portal.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
} else if (status.kycStatus === 'Pending') {
|
||||
Alert.alert(
|
||||
'Application Pending',
|
||||
'Your citizenship application is still under review.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
} else {
|
||||
Alert.alert(
|
||||
'Not a Citizen',
|
||||
'We couldn\'t find a citizenship record for this wallet. If you have a Citizen ID and Password, please note that wallet-based verification is now preferred.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (__DEV__) console.error('Citizenship verification error:', error);
|
||||
Alert.alert('Error', 'Failed to verify citizenship status');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" />
|
||||
<ScrollView style={styles.formContainer} showsVerticalScrollIndicator={false}>
|
||||
<Text style={styles.formTitle}>Citizen Verification</Text>
|
||||
<Text style={styles.formSubtitle}>
|
||||
Verify your status using your connected wallet
|
||||
</Text>
|
||||
|
||||
<View style={styles.infoCard}>
|
||||
<Text style={styles.infoText}>
|
||||
Existing citizens are verified through their blockchain identity. Ensure your citizenship wallet is selected in the wallet tab.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.submitButton, isSubmitting && styles.submitButtonDisabled]}
|
||||
onPress={handleVerify}
|
||||
activeOpacity={0.8}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<ActivityIndicator color={KurdistanColors.spi} />
|
||||
) : (
|
||||
<Text style={styles.submitButtonText}>Verify Citizenship</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
formContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
formTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.reş,
|
||||
marginBottom: 8,
|
||||
},
|
||||
formSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 24,
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: `${KurdistanColors.kesk}15`,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 24,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: KurdistanColors.kesk,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.reş,
|
||||
lineHeight: 20,
|
||||
opacity: 0.8,
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
marginTop: 20,
|
||||
shadowColor: KurdistanColors.kesk,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 6,
|
||||
elevation: 6,
|
||||
},
|
||||
submitButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
submitButtonText: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
},
|
||||
});
|
||||
|
||||
export default BeCitizenClaimScreen;
|
||||
@@ -13,13 +13,17 @@ import {
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePolkadot } from '../contexts/PolkadotContext';
|
||||
import { submitKycApplication, uploadToIPFS } from '@pezkuwi/lib/citizenship-workflow';
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
import {
|
||||
submitKycApplication,
|
||||
uploadToIPFS,
|
||||
getCitizenshipStatus,
|
||||
} from '@pezkuwi/lib/citizenship-workflow';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
|
||||
const BeCitizenScreen: React.FC = () => {
|
||||
const { t: _t } = useTranslation();
|
||||
const { api, selectedAccount } = usePolkadot();
|
||||
const { api, selectedAccount } = usePezkuwi();
|
||||
const [_isExistingCitizen, _setIsExistingCitizen] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState<'choice' | 'new' | 'existing'>('choice');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -116,14 +120,22 @@ const BeCitizenScreen: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExistingCitizenLogin = () => {
|
||||
if (!citizenId || !password) {
|
||||
Alert.alert('Error', 'Please enter Citizen ID and Password');
|
||||
const handleExistingCitizenLogin = async () => {
|
||||
if (!api || !selectedAccount) {
|
||||
Alert.alert('Error', 'Please connect your wallet first');
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Implement actual citizenship verification
|
||||
Alert.alert('Success', 'Welcome back, Citizen!', [
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const status = await getCitizenshipStatus(api, selectedAccount.address);
|
||||
|
||||
if (status.kycStatus === 'Approved' && status.hasCitizenTiki) {
|
||||
Alert.alert(
|
||||
'Success',
|
||||
`Welcome back, Citizen!\n\nYour Tiki Number: ${status.tikiNumber || 'N/A'}`,
|
||||
[
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => {
|
||||
@@ -132,7 +144,33 @@ const BeCitizenScreen: React.FC = () => {
|
||||
setCurrentStep('choice');
|
||||
},
|
||||
},
|
||||
]);
|
||||
]
|
||||
);
|
||||
} else if (status.kycStatus === 'Approved' && !status.hasCitizenTiki) {
|
||||
Alert.alert(
|
||||
'Almost there!',
|
||||
'Your KYC is approved, but you haven\'t claimed your Citizen Tiki yet. Please claim it on the web portal.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
} else if (status.kycStatus === 'Pending') {
|
||||
Alert.alert(
|
||||
'Application Pending',
|
||||
'Your citizenship application is still under review.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
} else {
|
||||
Alert.alert(
|
||||
'Not a Citizen',
|
||||
'We couldn\'t find a citizenship record for this wallet. If you have a Citizen ID and Password, please note that wallet-based verification is now preferred.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (__DEV__) console.error('Citizenship verification error:', error);
|
||||
Alert.alert('Error', 'Failed to verify citizenship status');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (currentStep === 'choice') {
|
||||
@@ -348,40 +386,28 @@ const BeCitizenScreen: React.FC = () => {
|
||||
<Text style={styles.backButtonText}>← Back</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={styles.formTitle}>Citizen Login</Text>
|
||||
<Text style={styles.formTitle}>Citizen Verification</Text>
|
||||
<Text style={styles.formSubtitle}>
|
||||
Access your citizenship account
|
||||
Verify your status using your connected wallet
|
||||
</Text>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Citizen ID</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter your Citizen ID"
|
||||
value={citizenId}
|
||||
onChangeText={setCitizenId}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
<View style={styles.infoCard}>
|
||||
<Text style={styles.infoText}>
|
||||
Existing citizens are verified through their blockchain identity. Ensure your citizenship wallet is selected in the wallet tab.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.submitButton}
|
||||
style={[styles.submitButton, isSubmitting && styles.submitButtonDisabled]}
|
||||
onPress={handleExistingCitizenLogin}
|
||||
activeOpacity={0.8}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Text style={styles.submitButtonText}>Login</Text>
|
||||
{isSubmitting ? (
|
||||
<ActivityIndicator color={KurdistanColors.spi} />
|
||||
) : (
|
||||
<Text style={styles.submitButtonText}>Verify Citizenship</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
@@ -413,10 +439,7 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.3)',
|
||||
elevation: 8,
|
||||
},
|
||||
logoText: {
|
||||
@@ -443,10 +466,7 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.2)',
|
||||
elevation: 6,
|
||||
},
|
||||
choiceIcon: {
|
||||
@@ -491,6 +511,20 @@ const styles = StyleSheet.create({
|
||||
color: KurdistanColors.spi,
|
||||
flex: 1,
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: `${KurdistanColors.kesk}15`,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 24,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: KurdistanColors.kesk,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.reş,
|
||||
lineHeight: 20,
|
||||
opacity: 0.8,
|
||||
},
|
||||
formContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
@@ -537,10 +571,7 @@ const styles = StyleSheet.create({
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
marginTop: 20,
|
||||
shadowColor: KurdistanColors.kesk,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 6,
|
||||
boxShadow: '0px 4px 6px rgba(0, 128, 0, 0.3)',
|
||||
elevation: 6,
|
||||
},
|
||||
submitButtonDisabled: {
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
TextInput,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
import {
|
||||
submitKycApplication,
|
||||
uploadToIPFS,
|
||||
getCitizenshipStatus,
|
||||
} from '@pezkuwi/lib/citizenship-workflow';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
|
||||
const BeCitizenScreen: React.FC = () => {
|
||||
const { t: _t } = useTranslation();
|
||||
const { api, selectedAccount } = usePezkuwi();
|
||||
const [_isExistingCitizen, _setIsExistingCitizen] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState<'choice' | 'new' | 'existing'>('choice');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// New Citizen Form State
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [fatherName, setFatherName] = useState('');
|
||||
const [motherName, setMotherName] = useState('');
|
||||
const [tribe, setTribe] = useState('');
|
||||
const [region, setRegion] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [profession, setProfession] = useState('');
|
||||
const [referralCode, setReferralCode] = useState('');
|
||||
|
||||
// Existing Citizen Login State
|
||||
const [citizenId, setCitizenId] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleNewCitizenApplication = async () => {
|
||||
if (!fullName || !fatherName || !motherName || !email) {
|
||||
Alert.alert('Error', 'Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!api || !selectedAccount) {
|
||||
Alert.alert('Error', 'Please connect your wallet first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Prepare citizenship data
|
||||
const citizenshipData = {
|
||||
fullName,
|
||||
fatherName,
|
||||
motherName,
|
||||
tribe,
|
||||
region,
|
||||
email,
|
||||
profession,
|
||||
referralCode,
|
||||
walletAddress: selectedAccount.address,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Step 1: Upload encrypted data to IPFS
|
||||
const ipfsCid = await uploadToIPFS(citizenshipData);
|
||||
|
||||
if (!ipfsCid) {
|
||||
throw new Error('Failed to upload data to IPFS');
|
||||
}
|
||||
|
||||
// Step 2: Submit KYC application to blockchain
|
||||
const result = await submitKycApplication(
|
||||
api,
|
||||
selectedAccount,
|
||||
fullName,
|
||||
email,
|
||||
ipfsCid,
|
||||
'Citizenship application via mobile app'
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
Alert.alert(
|
||||
'Application Submitted!',
|
||||
'Your citizenship application has been submitted for review. You will receive a confirmation once approved.',
|
||||
[
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => {
|
||||
// Reset form
|
||||
setFullName('');
|
||||
setFatherName('');
|
||||
setMotherName('');
|
||||
setTribe('');
|
||||
setRegion('');
|
||||
setEmail('');
|
||||
setProfession('');
|
||||
setReferralCode('');
|
||||
setCurrentStep('choice');
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
} else {
|
||||
Alert.alert('Application Failed', result.error || 'Failed to submit application');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (__DEV__) console.error('Citizenship application error:', error);
|
||||
Alert.alert('Error', error instanceof Error ? error.message : 'An unexpected error occurred');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExistingCitizenLogin = async () => {
|
||||
if (!api || !selectedAccount) {
|
||||
Alert.alert('Error', 'Please connect your wallet first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const status = await getCitizenshipStatus(api, selectedAccount.address);
|
||||
|
||||
if (status.kycStatus === 'Approved' && status.hasCitizenTiki) {
|
||||
Alert.alert(
|
||||
'Success',
|
||||
`Welcome back, Citizen!\n\nYour Tiki Number: ${status.tikiNumber || 'N/A'}`,
|
||||
[
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => {
|
||||
setCitizenId('');
|
||||
setPassword('');
|
||||
setCurrentStep('choice');
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
} else if (status.kycStatus === 'Approved' && !status.hasCitizenTiki) {
|
||||
Alert.alert(
|
||||
'Almost there!',
|
||||
'Your KYC is approved, but you haven\'t claimed your Citizen Tiki yet. Please claim it on the web portal.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
} else if (status.kycStatus === 'Pending') {
|
||||
Alert.alert(
|
||||
'Application Pending',
|
||||
'Your citizenship application is still under review.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
} else {
|
||||
Alert.alert(
|
||||
'Not a Citizen',
|
||||
'We couldn\'t find a citizenship record for this wallet. If you have a Citizen ID and Password, please note that wallet-based verification is now preferred.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (__DEV__) console.error('Citizenship verification error:', error);
|
||||
Alert.alert('Error', 'Failed to verify citizenship status');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (currentStep === 'choice') {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
<LinearGradient
|
||||
colors={[KurdistanColors.kesk, KurdistanColors.zer, KurdistanColors.sor]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.logoContainer}>
|
||||
<Text style={styles.logoText}>🏛️</Text>
|
||||
</View>
|
||||
<Text style={styles.title}>Be a Citizen</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Join the Pezkuwi decentralized nation
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.choiceContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.choiceCard}
|
||||
onPress={() => setCurrentStep('new')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.choiceIcon}>📝</Text>
|
||||
<Text style={styles.choiceTitle}>New Citizen</Text>
|
||||
<Text style={styles.choiceDescription}>
|
||||
Apply for citizenship and join our community
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.choiceCard}
|
||||
onPress={() => setCurrentStep('existing')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.choiceIcon}>🔐</Text>
|
||||
<Text style={styles.choiceTitle}>Existing Citizen</Text>
|
||||
<Text style={styles.choiceDescription}>
|
||||
Access your citizenship account
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoSection}>
|
||||
<Text style={styles.infoTitle}>Citizenship Benefits</Text>
|
||||
<View style={styles.benefitItem}>
|
||||
<Text style={styles.benefitIcon}>✓</Text>
|
||||
<Text style={styles.benefitText}>Voting rights in governance</Text>
|
||||
</View>
|
||||
<View style={styles.benefitItem}>
|
||||
<Text style={styles.benefitIcon}>✓</Text>
|
||||
<Text style={styles.benefitText}>Access to exclusive services</Text>
|
||||
</View>
|
||||
<View style={styles.benefitItem}>
|
||||
<Text style={styles.benefitIcon}>✓</Text>
|
||||
<Text style={styles.benefitText}>Referral rewards program</Text>
|
||||
</View>
|
||||
<View style={styles.benefitItem}>
|
||||
<Text style={styles.benefitIcon}>✓</Text>
|
||||
<Text style={styles.benefitText}>Community recognition</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</LinearGradient>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentStep === 'new') {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" />
|
||||
<ScrollView style={styles.formContainer} showsVerticalScrollIndicator={false}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => setCurrentStep('choice')}
|
||||
>
|
||||
<Text style={styles.backButtonText}>← Back</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={styles.formTitle}>New Citizen Application</Text>
|
||||
<Text style={styles.formSubtitle}>
|
||||
Please provide your information to apply for citizenship
|
||||
</Text>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Full Name *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter your full name"
|
||||
value={fullName}
|
||||
onChangeText={setFullName}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Father's Name *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter father's name"
|
||||
value={fatherName}
|
||||
onChangeText={setFatherName}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Mother's Name *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter mother's name"
|
||||
value={motherName}
|
||||
onChangeText={setMotherName}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Tribe</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter tribe (optional)"
|
||||
value={tribe}
|
||||
onChangeText={setTribe}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Region</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter region (optional)"
|
||||
value={region}
|
||||
onChangeText={setRegion}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Email *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter email address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Profession</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter profession (optional)"
|
||||
value={profession}
|
||||
onChangeText={setProfession}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Referral Code</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter referral code (optional)"
|
||||
value={referralCode}
|
||||
onChangeText={setReferralCode}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.submitButton, isSubmitting && styles.submitButtonDisabled]}
|
||||
onPress={handleNewCitizenApplication}
|
||||
activeOpacity={0.8}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<ActivityIndicator color={KurdistanColors.spi} />
|
||||
) : (
|
||||
<Text style={styles.submitButtonText}>Submit Application</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.spacer} />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// Existing Citizen Login
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" />
|
||||
<ScrollView style={styles.formContainer} showsVerticalScrollIndicator={false}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => setCurrentStep('choice')}
|
||||
>
|
||||
<Text style={styles.backButtonText}>← Back</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={styles.formTitle}>Citizen Verification</Text>
|
||||
<Text style={styles.formSubtitle}>
|
||||
Verify your status using your connected wallet
|
||||
</Text>
|
||||
|
||||
<View style={styles.infoCard}>
|
||||
<Text style={styles.infoText}>
|
||||
Existing citizens are verified through their blockchain identity. Ensure your citizenship wallet is selected in the wallet tab.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.submitButton, isSubmitting && styles.submitButtonDisabled]}
|
||||
onPress={handleExistingCitizenLogin}
|
||||
activeOpacity={0.8}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<ActivityIndicator color={KurdistanColors.spi} />
|
||||
) : (
|
||||
<Text style={styles.submitButtonText}>Verify Citizenship</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
gradient: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
padding: 20,
|
||||
paddingTop: 60,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 40,
|
||||
},
|
||||
logoContainer: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 48,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: KurdistanColors.spi,
|
||||
textAlign: 'center',
|
||||
opacity: 0.9,
|
||||
},
|
||||
choiceContainer: {
|
||||
gap: 16,
|
||||
marginBottom: 40,
|
||||
},
|
||||
choiceCard: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 6,
|
||||
},
|
||||
choiceIcon: {
|
||||
fontSize: 48,
|
||||
marginBottom: 16,
|
||||
},
|
||||
choiceTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.kesk,
|
||||
marginBottom: 8,
|
||||
},
|
||||
choiceDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
infoSection: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.spi,
|
||||
marginBottom: 16,
|
||||
},
|
||||
benefitItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
benefitIcon: {
|
||||
fontSize: 16,
|
||||
color: KurdistanColors.spi,
|
||||
marginRight: 12,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
benefitText: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.spi,
|
||||
flex: 1,
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: `${KurdistanColors.kesk}15`,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 24,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: KurdistanColors.kesk,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.reş,
|
||||
lineHeight: 20,
|
||||
opacity: 0.8,
|
||||
},
|
||||
formContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
backButton: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
backButtonText: {
|
||||
fontSize: 16,
|
||||
color: KurdistanColors.kesk,
|
||||
fontWeight: '600',
|
||||
},
|
||||
formTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.reş,
|
||||
marginBottom: 8,
|
||||
},
|
||||
formSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 24,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
fontSize: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E0E0E0',
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
marginTop: 20,
|
||||
shadowColor: KurdistanColors.kesk,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 6,
|
||||
elevation: 6,
|
||||
},
|
||||
submitButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
submitButtonText: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
},
|
||||
spacer: {
|
||||
height: 40,
|
||||
},
|
||||
});
|
||||
|
||||
export default BeCitizenScreen;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,777 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
Image,
|
||||
Alert,
|
||||
Dimensions,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NavigationProp } from '@react-navigation/native';
|
||||
import type { BottomTabParamList } from '../navigation/BottomTabNavigator';
|
||||
import type { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import AvatarPickerModal from '../components/AvatarPickerModal';
|
||||
import { fetchUserTikis, getPrimaryRole, getTikiDisplayName, getTikiEmoji, getTikiColor } from '@pezkuwi/lib/tiki';
|
||||
import { getAllScores, type UserScores } from '@pezkuwi/lib/scores';
|
||||
import { getKycStatus } from '@pezkuwi/lib/kyc';
|
||||
|
||||
// Existing Quick Action Images (Reused)
|
||||
import qaEducation from '../../../shared/images/quick-actions/qa_education.png';
|
||||
import qaExchange from '../../../shared/images/quick-actions/qa_exchange.png';
|
||||
import qaForum from '../../../shared/images/quick-actions/qa_forum.jpg';
|
||||
import qaGovernance from '../../../shared/images/quick-actions/qa_governance.jpg';
|
||||
import qaTrading from '../../../shared/images/quick-actions/qa_trading.jpg';
|
||||
import qaB2B from '../../../shared/images/quick-actions/qa_b2b.png';
|
||||
import qaBank from '../../../shared/images/quick-actions/qa_bank.png';
|
||||
import qaGames from '../../../shared/images/quick-actions/qa_games.png';
|
||||
import qaKurdMedia from '../../../shared/images/quick-actions/qa_kurdmedia.jpg';
|
||||
import qaUniversity from '../../../shared/images/quick-actions/qa_university.png';
|
||||
import avatarPlaceholder from '../../../shared/images/app-image.png'; // Fallback avatar
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
// Avatar pool matching AvatarPickerModal
|
||||
const AVATAR_POOL = [
|
||||
{ id: 'avatar1', emoji: '👨🏻' },
|
||||
{ id: 'avatar2', emoji: '👨🏼' },
|
||||
{ id: 'avatar3', emoji: '👨🏽' },
|
||||
{ id: 'avatar4', emoji: '👨🏾' },
|
||||
{ id: 'avatar5', emoji: '👩🏻' },
|
||||
{ id: 'avatar6', emoji: '👩🏼' },
|
||||
{ id: 'avatar7', emoji: '👩🏽' },
|
||||
{ id: 'avatar8', emoji: '👩🏾' },
|
||||
{ id: 'avatar9', emoji: '🧔🏻' },
|
||||
{ id: 'avatar10', emoji: '🧔🏼' },
|
||||
{ id: 'avatar11', emoji: '🧔🏽' },
|
||||
{ id: 'avatar12', emoji: '🧔🏾' },
|
||||
{ id: 'avatar13', emoji: '👳🏻♂️' },
|
||||
{ id: 'avatar14', emoji: '👳🏼♂️' },
|
||||
{ id: 'avatar15', emoji: '👳🏽♂️' },
|
||||
{ id: 'avatar16', emoji: '🧕🏻' },
|
||||
{ id: 'avatar17', emoji: '🧕🏼' },
|
||||
{ id: 'avatar18', emoji: '🧕🏽' },
|
||||
{ id: 'avatar19', emoji: '👴🏻' },
|
||||
{ id: 'avatar20', emoji: '👴🏼' },
|
||||
{ id: 'avatar21', emoji: '👵🏻' },
|
||||
{ id: 'avatar22', emoji: '👵🏼' },
|
||||
{ id: 'avatar23', emoji: '👦🏻' },
|
||||
{ id: 'avatar24', emoji: '👦🏼' },
|
||||
{ id: 'avatar25', emoji: '👧🏻' },
|
||||
{ id: 'avatar26', emoji: '👧🏼' },
|
||||
];
|
||||
|
||||
// Helper function to get emoji from avatar ID
|
||||
const getEmojiFromAvatarId = (avatarId: string): string => {
|
||||
const avatar = AVATAR_POOL.find(a => a.id === avatarId);
|
||||
return avatar ? avatar.emoji : '👤'; // Default to person emoji if not found
|
||||
};
|
||||
|
||||
interface DashboardScreenProps {}
|
||||
|
||||
const DashboardScreen: React.FC<DashboardScreenProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<BottomTabParamList & RootStackParamList>>();
|
||||
const { user } = useAuth();
|
||||
const { api, isApiReady, selectedAccount } = usePezkuwi();
|
||||
const [profileData, setProfileData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [avatarModalVisible, setAvatarModalVisible] = useState(false);
|
||||
|
||||
// Blockchain state
|
||||
const [tikis, setTikis] = useState<string[]>([]);
|
||||
const [scores, setScores] = useState<UserScores>({
|
||||
trustScore: 0,
|
||||
referralScore: 0,
|
||||
stakingScore: 0,
|
||||
tikiScore: 0,
|
||||
totalScore: 0
|
||||
});
|
||||
const [kycStatus, setKycStatus] = useState<string>('NotStarted');
|
||||
const [loadingScores, setLoadingScores] = useState(false);
|
||||
|
||||
// Fetch profile data from Supabase
|
||||
const fetchProfile = useCallback(async () => {
|
||||
if (!user) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
if (__DEV__) console.warn('Profile fetch error:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
setProfileData(data);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error fetching profile:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Fetch blockchain data (tikis, scores, KYC)
|
||||
const fetchBlockchainData = useCallback(async () => {
|
||||
if (!selectedAccount || !api || !isApiReady) return;
|
||||
|
||||
setLoadingScores(true);
|
||||
try {
|
||||
// Fetch tikis
|
||||
const userTikis = await fetchUserTikis(api, selectedAccount.address);
|
||||
setTikis(userTikis);
|
||||
|
||||
// Fetch all scores
|
||||
const allScores = await getAllScores(api, selectedAccount.address);
|
||||
setScores(allScores);
|
||||
|
||||
// Fetch KYC status
|
||||
const status = await getKycStatus(api, selectedAccount.address);
|
||||
setKycStatus(status);
|
||||
|
||||
if (__DEV__) console.log('[Dashboard] Blockchain data fetched:', { tikis: userTikis, scores: allScores, kycStatus: status });
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('[Dashboard] Error fetching blockchain data:', error);
|
||||
} finally {
|
||||
setLoadingScores(false);
|
||||
}
|
||||
}, [selectedAccount, api, isApiReady]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile();
|
||||
}, [fetchProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAccount && api && isApiReady) {
|
||||
fetchBlockchainData();
|
||||
}
|
||||
}, [fetchBlockchainData]);
|
||||
|
||||
// Check if user is a visitor (default when no blockchain wallet or no tikis)
|
||||
const isVisitor = !selectedAccount || tikis.length === 0;
|
||||
const primaryRole = tikis.length > 0 ? getPrimaryRole(tikis) : 'Visitor';
|
||||
|
||||
const showComingSoon = (featureName: string) => {
|
||||
Alert.alert(
|
||||
t('settingsScreen.comingSoon'),
|
||||
`${featureName} ${t('settingsScreen.comingSoonMessage')}`,
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
};
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
setAvatarModalVisible(true);
|
||||
};
|
||||
|
||||
const handleAvatarSelected = (avatarUrl: string) => {
|
||||
// Refresh profile data to show new avatar
|
||||
setProfileData((prev: any) => ({
|
||||
...prev,
|
||||
avatar_url: avatarUrl,
|
||||
}));
|
||||
};
|
||||
|
||||
const renderAppIcon = (title: string, icon: any, onPress: () => void, isEmoji = false, comingSoon = false) => (
|
||||
<TouchableOpacity
|
||||
style={styles.appIconContainer}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.appIconBox, comingSoon && styles.appIconDisabled]}>
|
||||
{isEmoji ? (
|
||||
<Text style={styles.emojiIcon}>{icon}</Text>
|
||||
) : (
|
||||
<Image source={icon} style={styles.imageIcon} resizeMode="cover" />
|
||||
)}
|
||||
{comingSoon && (
|
||||
<View style={styles.lockBadge}>
|
||||
<Text style={styles.lockText}>🔒</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.appIconTitle} numberOfLines={1}>{title}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" backgroundColor={KurdistanColors.kesk} />
|
||||
|
||||
{/* HEADER SECTION */}
|
||||
<LinearGradient
|
||||
colors={[KurdistanColors.kesk, '#008f43']}
|
||||
style={styles.header}
|
||||
>
|
||||
<View style={styles.headerTop}>
|
||||
<View style={styles.avatarSection}>
|
||||
<TouchableOpacity onPress={handleAvatarClick}>
|
||||
{profileData?.avatar_url ? (
|
||||
// Check if avatar_url is a URL (starts with http) or an emoji ID
|
||||
profileData.avatar_url.startsWith('http') ? (
|
||||
<Image
|
||||
source={{ uri: profileData.avatar_url }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
) : (
|
||||
// It's an emoji ID, render as emoji text
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarEmoji}>
|
||||
{getEmojiFromAvatarId(profileData.avatar_url)}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
<Image
|
||||
source={avatarPlaceholder}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
)}
|
||||
{/* Online Status Indicator */}
|
||||
<View style={styles.statusIndicator} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Tiki Badge next to avatar - shows primary role */}
|
||||
<View style={styles.tikiAvatarBadge}>
|
||||
<Text style={styles.tikiAvatarText}>
|
||||
{getTikiEmoji(primaryRole)} {getTikiDisplayName(primaryRole)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerInfo}>
|
||||
<Text style={styles.greeting}>
|
||||
Rojbaş, {profileData?.full_name || user?.email?.split('@')[0] || 'Heval'}
|
||||
</Text>
|
||||
<View style={styles.tikiContainer}>
|
||||
{tikis.map((tiki, index) => (
|
||||
<View key={index} style={styles.tikiBadge}>
|
||||
<Text style={styles.tikiText}>✓ {tiki}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerActions}>
|
||||
<TouchableOpacity style={styles.iconButton} onPress={() => showComingSoon('Notifications')}>
|
||||
<Text style={styles.headerIcon}>🔔</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.iconButton} onPress={() => navigation.navigate('Settings')}>
|
||||
<Text style={styles.headerIcon}>⚙️</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
style={styles.scrollView}
|
||||
>
|
||||
{/* SCORE CARDS SECTION */}
|
||||
<View style={styles.scoreCardsContainer}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scoreCardsContent}
|
||||
>
|
||||
{/* Member Since Card */}
|
||||
<View style={[styles.scoreCard, { borderLeftColor: KurdistanColors.kesk }]}>
|
||||
<Text style={styles.scoreCardIcon}>📅</Text>
|
||||
<Text style={styles.scoreCardLabel}>Member Since</Text>
|
||||
<Text style={styles.scoreCardValue}>
|
||||
{profileData?.created_at
|
||||
? new Date(profileData.created_at).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })
|
||||
: user?.created_at
|
||||
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })
|
||||
: 'N/A'
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Role Card */}
|
||||
<View style={[styles.scoreCard, { borderLeftColor: '#FF9800' }]}>
|
||||
<Text style={styles.scoreCardIcon}>{getTikiEmoji(primaryRole)}</Text>
|
||||
<Text style={styles.scoreCardLabel}>Role</Text>
|
||||
<Text style={styles.scoreCardValue}>{getTikiDisplayName(primaryRole)}</Text>
|
||||
<Text style={styles.scoreCardSubtext}>
|
||||
{selectedAccount ? `${tikis.length} ${tikis.length === 1 ? 'role' : 'roles'}` : 'Connect wallet'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Total Score Card */}
|
||||
<View style={[styles.scoreCard, { borderLeftColor: '#9C27B0' }]}>
|
||||
<Text style={styles.scoreCardIcon}>🏆</Text>
|
||||
<Text style={styles.scoreCardLabel}>Total Score</Text>
|
||||
{loadingScores ? (
|
||||
<ActivityIndicator size="small" color="#9C27B0" />
|
||||
) : (
|
||||
<>
|
||||
<Text style={[styles.scoreCardValue, { color: '#9C27B0' }]}>
|
||||
{scores.totalScore}
|
||||
</Text>
|
||||
<Text style={styles.scoreCardSubtext}>All score types</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Trust Score Card */}
|
||||
<View style={[styles.scoreCard, { borderLeftColor: '#9C27B0' }]}>
|
||||
<Text style={styles.scoreCardIcon}>🛡️</Text>
|
||||
<Text style={styles.scoreCardLabel}>Trust Score</Text>
|
||||
{loadingScores ? (
|
||||
<ActivityIndicator size="small" color="#9C27B0" />
|
||||
) : (
|
||||
<>
|
||||
<Text style={[styles.scoreCardValue, { color: '#9C27B0' }]}>
|
||||
{scores.trustScore}
|
||||
</Text>
|
||||
<Text style={styles.scoreCardSubtext}>pezpallet_trust</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Referral Score Card */}
|
||||
<View style={[styles.scoreCard, { borderLeftColor: '#00BCD4' }]}>
|
||||
<Text style={styles.scoreCardIcon}>👥</Text>
|
||||
<Text style={styles.scoreCardLabel}>Referral Score</Text>
|
||||
{loadingScores ? (
|
||||
<ActivityIndicator size="small" color="#00BCD4" />
|
||||
) : (
|
||||
<>
|
||||
<Text style={[styles.scoreCardValue, { color: '#00BCD4' }]}>
|
||||
{scores.referralScore}
|
||||
</Text>
|
||||
<Text style={styles.scoreCardSubtext}>Referrals</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Staking Score Card */}
|
||||
<View style={[styles.scoreCard, { borderLeftColor: '#4CAF50' }]}>
|
||||
<Text style={styles.scoreCardIcon}>📈</Text>
|
||||
<Text style={styles.scoreCardLabel}>Staking Score</Text>
|
||||
{loadingScores ? (
|
||||
<ActivityIndicator size="small" color="#4CAF50" />
|
||||
) : (
|
||||
<>
|
||||
<Text style={[styles.scoreCardValue, { color: '#4CAF50' }]}>
|
||||
{scores.stakingScore}
|
||||
</Text>
|
||||
<Text style={styles.scoreCardSubtext}>pezpallet_staking</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Tiki Score Card */}
|
||||
<View style={[styles.scoreCard, { borderLeftColor: '#E91E63' }]}>
|
||||
<Text style={styles.scoreCardIcon}>⭐</Text>
|
||||
<Text style={styles.scoreCardLabel}>Tiki Score</Text>
|
||||
{loadingScores ? (
|
||||
<ActivityIndicator size="small" color="#E91E63" />
|
||||
) : (
|
||||
<>
|
||||
<Text style={[styles.scoreCardValue, { color: '#E91E63' }]}>
|
||||
{scores.tikiScore}
|
||||
</Text>
|
||||
<Text style={styles.scoreCardSubtext}>
|
||||
{tikis.length} {tikis.length === 1 ? 'role' : 'roles'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* KYC Status Card */}
|
||||
<View style={[styles.scoreCard, { borderLeftColor: kycStatus === 'Approved' ? '#4CAF50' : '#FFC107' }]}>
|
||||
<Text style={styles.scoreCardIcon}>
|
||||
{kycStatus === 'Approved' ? '✅' : kycStatus === 'Pending' ? '⏳' : '📝'}
|
||||
</Text>
|
||||
<Text style={styles.scoreCardLabel}>KYC Status</Text>
|
||||
<Text style={[styles.scoreCardValue, {
|
||||
color: kycStatus === 'Approved' ? '#4CAF50' : kycStatus === 'Pending' ? '#FFC107' : '#999',
|
||||
fontSize: 14
|
||||
}]}>
|
||||
{kycStatus}
|
||||
</Text>
|
||||
{kycStatus === 'NotStarted' && (
|
||||
<TouchableOpacity
|
||||
style={styles.kycButton}
|
||||
onPress={() => navigation.navigate('BeCitizen')}
|
||||
>
|
||||
<Text style={styles.kycButtonText}>Apply</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* 1. FINANCE SECTION */}
|
||||
<View style={styles.sectionContainer}>
|
||||
<View style={[styles.sectionHeader, { borderLeftColor: KurdistanColors.kesk }]}>
|
||||
<Text style={styles.sectionTitle}>FINANCE 💰</Text>
|
||||
<TouchableOpacity onPress={() => navigation.navigate('Apps')}>
|
||||
<Text style={styles.seeAllText}>Hemû / All</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.appsGrid}>
|
||||
{/* Wallet Visitors - Everyone can use */}
|
||||
{renderAppIcon('Wallet Visitors', '👁️', () => showComingSoon('Wallet Visitors'), true)}
|
||||
|
||||
{/* Wallet Welati - Only Citizens can use */}
|
||||
{renderAppIcon('Wallet Welati', '🏛️', () => {
|
||||
if (tikis.includes('Citizen') || tikis.includes('Welati')) {
|
||||
showComingSoon('Wallet Welati');
|
||||
} else {
|
||||
Alert.alert(
|
||||
'Citizens Only',
|
||||
'Wallet Welati is only available to Pezkuwi citizens. Please apply for citizenship first.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
}
|
||||
}, true, !tikis.includes('Citizen') && !tikis.includes('Welati'))}
|
||||
|
||||
{renderAppIcon('Bank', qaBank, () => showComingSoon('Bank'), false, true)}
|
||||
{renderAppIcon('Exchange', qaExchange, () => showComingSoon('Swap'), false)}
|
||||
{renderAppIcon('P2P', qaTrading, () => showComingSoon('P2P'), false)}
|
||||
{renderAppIcon('B2B', qaB2B, () => showComingSoon('B2B Trading'), false, true)}
|
||||
{renderAppIcon('Tax', '📊', () => showComingSoon('Tax/Zekat'), true, true)}
|
||||
{renderAppIcon('Launchpad', '🚀', () => showComingSoon('Launchpad'), true, true)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 2. GOVERNANCE SECTION */}
|
||||
<View style={styles.sectionContainer}>
|
||||
<View style={[styles.sectionHeader, { borderLeftColor: KurdistanColors.sor }]}>
|
||||
<Text style={styles.sectionTitle}>GOVERNANCE 🏛️</Text>
|
||||
</View>
|
||||
<View style={styles.appsGrid}>
|
||||
{renderAppIcon('President', '👑', () => showComingSoon('Presidency'), true, true)}
|
||||
{renderAppIcon('Assembly', qaGovernance, () => showComingSoon('Assembly'), false, true)}
|
||||
{renderAppIcon('Vote', '🗳️', () => showComingSoon('Voting'), true, true)}
|
||||
{renderAppIcon('Validators', '🛡️', () => showComingSoon('Validators'), true, true)}
|
||||
{renderAppIcon('Justice', '⚖️', () => showComingSoon('Dad / Justice'), true, true)}
|
||||
{renderAppIcon('Proposals', '📜', () => showComingSoon('Proposals'), true, true)}
|
||||
{renderAppIcon('Polls', '📊', () => showComingSoon('Public Polls'), true, true)}
|
||||
{renderAppIcon('Identity', '🆔', () => navigation.navigate('BeCitizen'), true)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 3. SOCIAL SECTION */}
|
||||
<View style={styles.sectionContainer}>
|
||||
<View style={[styles.sectionHeader, { borderLeftColor: '#2196F3' }]}>
|
||||
<Text style={styles.sectionTitle}>SOCIAL 💬</Text>
|
||||
</View>
|
||||
<View style={styles.appsGrid}>
|
||||
{renderAppIcon('whatsKURD', '💬', () => showComingSoon('whatsKURD'), true, true)}
|
||||
{renderAppIcon('Forum', qaForum, () => showComingSoon('Forum'), false)}
|
||||
{renderAppIcon('KurdMedia', qaKurdMedia, () => showComingSoon('KurdMedia'), false, true)}
|
||||
{renderAppIcon('Events', '🎭', () => showComingSoon('Çalakî / Events'), true, true)}
|
||||
{renderAppIcon('Help', '🤝', () => showComingSoon('Harîkarî / Help'), true, true)}
|
||||
{renderAppIcon('Music', '🎵', () => showComingSoon('Music Stream'), true, true)}
|
||||
{renderAppIcon('VPN', '🛡️', () => showComingSoon('Decentralized VPN'), true, true)}
|
||||
{renderAppIcon('Referral', '👥', () => navigation.navigate('Referral'), true)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 4. EDUCATION SECTION */}
|
||||
<View style={styles.sectionContainer}>
|
||||
<View style={[styles.sectionHeader, { borderLeftColor: KurdistanColors.zer }]}>
|
||||
<Text style={styles.sectionTitle}>EDUCATION 📚</Text>
|
||||
</View>
|
||||
<View style={styles.appsGrid}>
|
||||
{renderAppIcon('University', qaUniversity, () => showComingSoon('University'), false, true)}
|
||||
{renderAppIcon('Perwerde', qaEducation, () => showComingSoon('Education'), false)}
|
||||
{renderAppIcon('Library', '📜', () => showComingSoon('Pirtûkxane'), true, true)}
|
||||
{renderAppIcon('Language', '🗣️', () => showComingSoon('Ziman / Language'), true, true)}
|
||||
{renderAppIcon('Kids', '🧸', () => showComingSoon('Zarok / Kids'), true, true)}
|
||||
{renderAppIcon('Certificates', '🏆', () => showComingSoon('Certificates'), true, true)}
|
||||
{renderAppIcon('Research', '🔬', () => showComingSoon('Research'), true, true)}
|
||||
{renderAppIcon('History', '🏺', () => showComingSoon('History'), true, true)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ height: 100 }} />
|
||||
</ScrollView>
|
||||
|
||||
{/* Avatar Picker Modal */}
|
||||
<AvatarPickerModal
|
||||
visible={avatarModalVisible}
|
||||
onClose={() => setAvatarModalVisible(false)}
|
||||
currentAvatar={profileData?.avatar_url}
|
||||
onAvatarSelected={handleAvatarSelected}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F2F2F7',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
header: {
|
||||
paddingTop: Platform.OS === 'android' ? 40 : 20,
|
||||
paddingBottom: 25,
|
||||
paddingHorizontal: 20,
|
||||
borderBottomLeftRadius: 24,
|
||||
borderBottomRightRadius: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
headerTop: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
avatarSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatar: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
borderWidth: 2,
|
||||
borderColor: KurdistanColors.spi,
|
||||
backgroundColor: '#ddd',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarEmoji: {
|
||||
fontSize: 32,
|
||||
},
|
||||
tikiAvatarBadge: {
|
||||
marginLeft: 8,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.25)',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.4)',
|
||||
},
|
||||
tikiAvatarText: {
|
||||
fontSize: 11,
|
||||
color: KurdistanColors.spi,
|
||||
fontWeight: '700',
|
||||
},
|
||||
statusIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 2,
|
||||
right: 2,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 7,
|
||||
backgroundColor: '#4CAF50', // Online green
|
||||
borderWidth: 2,
|
||||
borderColor: KurdistanColors.kesk,
|
||||
},
|
||||
headerInfo: {
|
||||
flex: 1,
|
||||
marginLeft: 16,
|
||||
},
|
||||
greeting: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
marginBottom: 4,
|
||||
},
|
||||
tikiContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
tikiBadge: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 12,
|
||||
marginRight: 6,
|
||||
marginBottom: 4,
|
||||
},
|
||||
tikiText: {
|
||||
fontSize: 11,
|
||||
color: KurdistanColors.spi,
|
||||
fontWeight: '600',
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
iconButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: 8,
|
||||
},
|
||||
headerIcon: {
|
||||
fontSize: 18,
|
||||
},
|
||||
sectionContainer: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
marginHorizontal: 16,
|
||||
marginTop: 16,
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
borderLeftWidth: 4,
|
||||
paddingLeft: 10,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
color: KurdistanColors.reş,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
seeAllText: {
|
||||
fontSize: 12,
|
||||
color: KurdistanColors.kesk,
|
||||
fontWeight: '600',
|
||||
},
|
||||
appsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
appIconContainer: {
|
||||
width: '25%', // 4 icons per row
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
appIconBox: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F8F9FA',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
appIconDisabled: {
|
||||
opacity: 0.5,
|
||||
backgroundColor: '#F0F0F0',
|
||||
},
|
||||
imageIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
},
|
||||
emojiIcon: {
|
||||
fontSize: 28,
|
||||
},
|
||||
appIconTitle: {
|
||||
fontSize: 11,
|
||||
color: '#333',
|
||||
textAlign: 'center',
|
||||
fontWeight: '500',
|
||||
maxWidth: '90%',
|
||||
},
|
||||
lockBadge: {
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: -4,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
lockText: {
|
||||
fontSize: 12,
|
||||
},
|
||||
// Score Cards Styles
|
||||
scoreCardsContainer: {
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
scoreCardsContent: {
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
scoreCard: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginRight: 12,
|
||||
minWidth: 140,
|
||||
borderLeftWidth: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
scoreCardIcon: {
|
||||
fontSize: 28,
|
||||
marginBottom: 8,
|
||||
},
|
||||
scoreCardLabel: {
|
||||
fontSize: 11,
|
||||
color: '#666',
|
||||
fontWeight: '600',
|
||||
marginBottom: 6,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
scoreCardValue: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.reş,
|
||||
marginBottom: 4,
|
||||
},
|
||||
scoreCardSubtext: {
|
||||
fontSize: 10,
|
||||
color: '#999',
|
||||
},
|
||||
kycButton: {
|
||||
marginTop: 8,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
kycButtonText: {
|
||||
color: KurdistanColors.spi,
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
export default DashboardScreen;
|
||||
@@ -1,371 +1,21 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
TouchableOpacity,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, Button, Badge } from '../components';
|
||||
import { KurdistanColors, AppColors } from '../theme/colors';
|
||||
import { usePolkadot } from '../contexts/PolkadotContext';
|
||||
|
||||
// Import from shared library
|
||||
import {
|
||||
getAllCourses,
|
||||
getStudentEnrollments,
|
||||
enrollInCourse,
|
||||
completeCourse,
|
||||
type Course,
|
||||
type Enrollment,
|
||||
} from '../../../shared/lib/perwerde';
|
||||
|
||||
type TabType = 'all' | 'my-courses';
|
||||
import React from 'react';
|
||||
import { SafeAreaView, StyleSheet } from 'react-native';
|
||||
import { PezkuwiWebView } from '../components';
|
||||
|
||||
/**
|
||||
* Education (Perwerde) Screen
|
||||
*
|
||||
* Uses WebView to load the education platform from the web app.
|
||||
* Includes courses, enrollments, certificates, and progress tracking.
|
||||
* Native wallet bridge allows transaction signing for enrollments.
|
||||
*/
|
||||
const EducationScreen: React.FC = () => {
|
||||
const { t: _t } = useTranslation();
|
||||
const { api, isApiReady, selectedAccount, getKeyPair } = usePolkadot();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [enrollments, setEnrollments] = useState<Enrollment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [enrolling, setEnrolling] = useState<number | null>(null);
|
||||
|
||||
const fetchCourses = useCallback(async () => {
|
||||
if (!api || !isApiReady) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const allCourses = await getAllCourses(api);
|
||||
setCourses(allCourses);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Failed to fetch courses:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [api, isApiReady]);
|
||||
|
||||
const fetchEnrollments = useCallback(async () => {
|
||||
if (!selectedAccount) {
|
||||
setEnrollments([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const studentEnrollments = await getStudentEnrollments(selectedAccount.address);
|
||||
setEnrollments(studentEnrollments);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Failed to fetch enrollments:', error);
|
||||
}
|
||||
}, [selectedAccount]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourses();
|
||||
fetchEnrollments();
|
||||
}, [fetchCourses, fetchEnrollments]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchCourses();
|
||||
fetchEnrollments();
|
||||
};
|
||||
|
||||
const handleEnroll = async (courseId: number) => {
|
||||
if (!api || !selectedAccount) {
|
||||
Alert.alert('Error', 'Please connect your wallet');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setEnrolling(courseId);
|
||||
|
||||
const keyPair = await getKeyPair(selectedAccount.address);
|
||||
if (!keyPair) {
|
||||
throw new Error('Failed to load keypair');
|
||||
}
|
||||
|
||||
await enrollInCourse(api, {
|
||||
address: selectedAccount.address,
|
||||
meta: {},
|
||||
type: 'sr25519',
|
||||
}, courseId);
|
||||
|
||||
Alert.alert('Success', 'Successfully enrolled in course!');
|
||||
fetchEnrollments();
|
||||
} catch (error: unknown) {
|
||||
if (__DEV__) console.error('Enrollment failed:', error);
|
||||
Alert.alert('Enrollment Failed', error instanceof Error ? error.message : 'Failed to enroll in course');
|
||||
} finally {
|
||||
setEnrolling(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteCourse = async (courseId: number) => {
|
||||
if (!api || !selectedAccount) {
|
||||
Alert.alert('Error', 'Please connect your wallet');
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'Complete Course',
|
||||
'Are you sure you want to mark this course as completed?',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Complete',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const keyPair = await getKeyPair(selectedAccount.address);
|
||||
if (!keyPair) {
|
||||
throw new Error('Failed to load keypair');
|
||||
}
|
||||
|
||||
await completeCourse(api, {
|
||||
address: selectedAccount.address,
|
||||
meta: {},
|
||||
type: 'sr25519',
|
||||
}, courseId);
|
||||
|
||||
Alert.alert('Success', 'Course completed! Certificate issued.');
|
||||
fetchEnrollments();
|
||||
} catch (error: unknown) {
|
||||
if (__DEV__) console.error('Completion failed:', error);
|
||||
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to complete course');
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const isEnrolled = (courseId: number) => {
|
||||
return enrollments.some((e) => e.course_id === courseId);
|
||||
};
|
||||
|
||||
const isCompleted = (courseId: number) => {
|
||||
return enrollments.some((e) => e.course_id === courseId && e.is_completed);
|
||||
};
|
||||
|
||||
const getEnrollmentProgress = (courseId: number) => {
|
||||
const enrollment = enrollments.find((e) => e.course_id === courseId);
|
||||
return enrollment?.points_earned || 0;
|
||||
};
|
||||
|
||||
const renderCourseCard = ({ item }: { item: Course }) => {
|
||||
const enrolled = isEnrolled(item.id);
|
||||
const completed = isCompleted(item.id);
|
||||
const progress = getEnrollmentProgress(item.id);
|
||||
const isEnrollingThis = enrolling === item.id;
|
||||
|
||||
return (
|
||||
<Card style={styles.courseCard}>
|
||||
{/* Course Header */}
|
||||
<View style={styles.courseHeader}>
|
||||
<View style={styles.courseIcon}>
|
||||
<Text style={styles.courseIconText}>📚</Text>
|
||||
</View>
|
||||
<View style={styles.courseInfo}>
|
||||
<Text style={styles.courseName}>{item.name}</Text>
|
||||
<Text style={styles.courseInstructor}>
|
||||
By: {item.owner.slice(0, 6)}...{item.owner.slice(-4)}
|
||||
</Text>
|
||||
</View>
|
||||
{completed && (
|
||||
<Badge
|
||||
text="✓ Completed"
|
||||
variant="success"
|
||||
style={{ backgroundColor: KurdistanColors.kesk }}
|
||||
/>
|
||||
)}
|
||||
{enrolled && !completed && (
|
||||
<Badge text="Enrolled" variant="outline" />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Course Description */}
|
||||
<Text style={styles.courseDescription} numberOfLines={3}>
|
||||
{item.description}
|
||||
</Text>
|
||||
|
||||
{/* Progress (if enrolled) */}
|
||||
{enrolled && !completed && (
|
||||
<View style={styles.progressContainer}>
|
||||
<Text style={styles.progressLabel}>Progress</Text>
|
||||
<View style={styles.progressBar}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{ width: `${Math.min(progress, 100)}%` },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.progressText}>{progress} points</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Course Metadata */}
|
||||
<View style={styles.courseMetadata}>
|
||||
<View style={styles.metadataItem}>
|
||||
<Text style={styles.metadataIcon}>🎓</Text>
|
||||
<Text style={styles.metadataText}>Certificate upon completion</Text>
|
||||
</View>
|
||||
<View style={styles.metadataItem}>
|
||||
<Text style={styles.metadataIcon}>📅</Text>
|
||||
<Text style={styles.metadataText}>
|
||||
Created: {new Date(item.created_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action Button */}
|
||||
{!enrolled && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={() => handleEnroll(item.id)}
|
||||
disabled={isEnrollingThis || !isApiReady}
|
||||
style={styles.enrollButton}
|
||||
>
|
||||
{isEnrollingThis ? (
|
||||
<ActivityIndicator color="#FFFFFF" />
|
||||
) : (
|
||||
'Enroll Now'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{enrolled && !completed && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={() => handleCompleteCourse(item.id)}
|
||||
style={styles.enrollButton}
|
||||
>
|
||||
Mark as Completed
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{completed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onPress={() => {
|
||||
Alert.alert(
|
||||
'Certificate',
|
||||
`Congratulations! You've completed "${item.name}".\n\nYour certificate is stored on the blockchain.`
|
||||
);
|
||||
}}
|
||||
style={styles.enrollButton}
|
||||
>
|
||||
View Certificate
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const displayCourses =
|
||||
activeTab === 'all'
|
||||
? courses
|
||||
: courses.filter((c) => isEnrolled(c.id));
|
||||
|
||||
const renderEmptyState = () => (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyIcon}>
|
||||
{activeTab === 'all' ? '📚' : '🎓'}
|
||||
</Text>
|
||||
<Text style={styles.emptyTitle}>
|
||||
{activeTab === 'all' ? 'No Courses Available' : 'No Enrolled Courses'}
|
||||
</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
{activeTab === 'all'
|
||||
? 'Check back later for new courses'
|
||||
: 'Browse available courses and enroll to start learning'}
|
||||
</Text>
|
||||
{activeTab === 'my-courses' && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={() => setActiveTab('all')}
|
||||
style={styles.browseButton}
|
||||
>
|
||||
Browse Courses
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.title}>Perwerde 🎓</Text>
|
||||
<Text style={styles.subtitle}>Decentralized Education Platform</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Connection Warning */}
|
||||
{!isApiReady && (
|
||||
<View style={styles.warningBanner}>
|
||||
<Text style={styles.warningText}>Connecting to blockchain...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<View style={styles.tabs}>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, activeTab === 'all' && styles.activeTab]}
|
||||
onPress={() => setActiveTab('all')}
|
||||
>
|
||||
<Text
|
||||
style={[styles.tabText, activeTab === 'all' && styles.activeTabText]}
|
||||
>
|
||||
All Courses
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, activeTab === 'my-courses' && styles.activeTab]}
|
||||
onPress={() => setActiveTab('my-courses')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === 'my-courses' && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
My Courses ({enrollments.length})
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Course List */}
|
||||
{loading && !refreshing ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={KurdistanColors.kesk} />
|
||||
<Text style={styles.loadingText}>Loading courses...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={displayCourses}
|
||||
renderItem={renderCourseCard}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
contentContainerStyle={styles.listContent}
|
||||
ListEmptyComponent={renderEmptyState}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={KurdistanColors.kesk}
|
||||
<PezkuwiWebView
|
||||
path="/education"
|
||||
title="Perwerde"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
@@ -373,187 +23,7 @@ const EducationScreen: React.FC = () => {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: AppColors.background,
|
||||
},
|
||||
header: {
|
||||
padding: 16,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
warningBanner: {
|
||||
backgroundColor: '#FFF3CD',
|
||||
padding: 12,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 12,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#FFE69C',
|
||||
},
|
||||
warningText: {
|
||||
fontSize: 14,
|
||||
color: '#856404',
|
||||
textAlign: 'center',
|
||||
},
|
||||
tabs: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E0E0E0',
|
||||
marginBottom: 16,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: 'transparent',
|
||||
},
|
||||
activeTab: {
|
||||
borderBottomColor: KurdistanColors.kesk,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
},
|
||||
activeTabText: {
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingTop: 0,
|
||||
},
|
||||
courseCard: {
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
courseHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 12,
|
||||
},
|
||||
courseIcon: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#F0F9F4',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
courseIconText: {
|
||||
fontSize: 28,
|
||||
},
|
||||
courseInfo: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
},
|
||||
courseName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
marginBottom: 4,
|
||||
},
|
||||
courseInstructor: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
courseDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
lineHeight: 20,
|
||||
marginBottom: 16,
|
||||
},
|
||||
progressContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
progressLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
progressBar: {
|
||||
height: 8,
|
||||
backgroundColor: '#E0E0E0',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 4,
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
borderRadius: 4,
|
||||
},
|
||||
progressText: {
|
||||
fontSize: 12,
|
||||
color: KurdistanColors.kesk,
|
||||
fontWeight: '600',
|
||||
},
|
||||
courseMetadata: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#F0F0F0',
|
||||
paddingTop: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
metadataItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
metadataIcon: {
|
||||
fontSize: 16,
|
||||
marginRight: 8,
|
||||
},
|
||||
metadataText: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
enrollButton: {
|
||||
marginTop: 8,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
browseButton: {
|
||||
minWidth: 150,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,351 +1,20 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, Badge } from '../components';
|
||||
import { KurdistanColors, AppColors } from '../theme/colors';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
interface ForumThread {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
author: string;
|
||||
category: string;
|
||||
replies_count: number;
|
||||
views_count: number;
|
||||
created_at: string;
|
||||
last_activity: string;
|
||||
is_pinned: boolean;
|
||||
is_locked: boolean;
|
||||
}
|
||||
|
||||
interface ForumCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
threads_count: number;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const CATEGORIES: ForumCategory[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'General Discussion',
|
||||
description: 'General topics about PezkuwiChain',
|
||||
threads_count: 42,
|
||||
icon: '💬',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Governance',
|
||||
description: 'Discuss proposals and governance',
|
||||
threads_count: 28,
|
||||
icon: '🏛️',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Technical',
|
||||
description: 'Development and technical discussions',
|
||||
threads_count: 35,
|
||||
icon: '⚙️',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Trading',
|
||||
description: 'Market discussions and trading',
|
||||
threads_count: 18,
|
||||
icon: '📈',
|
||||
},
|
||||
];
|
||||
|
||||
// Mock data - will be replaced with Supabase
|
||||
const MOCK_THREADS: ForumThread[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Welcome to PezkuwiChain Forum!',
|
||||
content: 'Introduce yourself and join the community...',
|
||||
author: '5GrwV...xQjz',
|
||||
category: 'General Discussion',
|
||||
replies_count: 24,
|
||||
views_count: 156,
|
||||
created_at: '2024-01-15T10:00:00Z',
|
||||
last_activity: '2024-01-20T14:30:00Z',
|
||||
is_pinned: true,
|
||||
is_locked: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'New Governance Proposal: Treasury Allocation',
|
||||
content: 'Discussion about treasury fund allocation...',
|
||||
author: '5HpG8...kLm2',
|
||||
category: 'Governance',
|
||||
replies_count: 45,
|
||||
views_count: 289,
|
||||
created_at: '2024-01-18T09:15:00Z',
|
||||
last_activity: '2024-01-20T16:45:00Z',
|
||||
is_pinned: false,
|
||||
is_locked: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'How to stake PEZ tokens?',
|
||||
content: 'Guide for staking PEZ tokens...',
|
||||
author: '5FHne...pQr8',
|
||||
category: 'General Discussion',
|
||||
replies_count: 12,
|
||||
views_count: 98,
|
||||
created_at: '2024-01-19T11:20:00Z',
|
||||
last_activity: '2024-01-20T13:10:00Z',
|
||||
is_pinned: false,
|
||||
is_locked: false,
|
||||
},
|
||||
];
|
||||
|
||||
type ViewType = 'categories' | 'threads';
|
||||
import React from 'react';
|
||||
import { SafeAreaView, StyleSheet } from 'react-native';
|
||||
import { PezkuwiWebView } from '../components';
|
||||
|
||||
/**
|
||||
* Forum Screen
|
||||
*
|
||||
* Uses WebView to load the full-featured forum from the web app.
|
||||
* Includes categories, threads, posts, replies, and moderation features.
|
||||
*/
|
||||
const ForumScreen: React.FC = () => {
|
||||
const { t: _t } = useTranslation();
|
||||
|
||||
const [viewType, setViewType] = useState<ViewType>('categories');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [threads, setThreads] = useState<ForumThread[]>(MOCK_THREADS);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetchThreads = async (categoryId?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch from Supabase
|
||||
let query = supabase
|
||||
.from('forum_threads')
|
||||
.select(`
|
||||
*,
|
||||
forum_categories(name)
|
||||
`)
|
||||
.order('is_pinned', { ascending: false })
|
||||
.order('last_activity', { ascending: false });
|
||||
|
||||
// Filter by category if provided
|
||||
if (categoryId) {
|
||||
query = query.eq('category_id', categoryId);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) {
|
||||
if (__DEV__) console.error('Supabase fetch error:', error);
|
||||
// Fallback to mock data on error
|
||||
setThreads(MOCK_THREADS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data && data.length > 0) {
|
||||
// Transform Supabase data to match ForumThread interface
|
||||
const transformedThreads: ForumThread[] = data.map((thread: Record<string, unknown>) => ({
|
||||
id: String(thread.id),
|
||||
title: String(thread.title),
|
||||
content: String(thread.content),
|
||||
author: String(thread.author_id),
|
||||
category: (thread.forum_categories as { name?: string })?.name || 'Unknown',
|
||||
replies_count: Number(thread.replies_count) || 0,
|
||||
views_count: Number(thread.views_count) || 0,
|
||||
created_at: String(thread.created_at),
|
||||
last_activity: String(thread.last_activity || thread.created_at),
|
||||
is_pinned: Boolean(thread.is_pinned),
|
||||
is_locked: Boolean(thread.is_locked),
|
||||
}));
|
||||
setThreads(transformedThreads);
|
||||
} else {
|
||||
// No data, use mock data
|
||||
setThreads(MOCK_THREADS);
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Failed to fetch threads:', error);
|
||||
// Fallback to mock data on error
|
||||
setThreads(MOCK_THREADS);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchThreads(selectedCategory || undefined);
|
||||
};
|
||||
|
||||
const handleCategoryPress = (categoryId: string, _categoryName: string) => {
|
||||
setSelectedCategory(categoryId);
|
||||
setViewType('threads');
|
||||
fetchThreads(categoryId);
|
||||
};
|
||||
|
||||
const formatTimeAgo = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (seconds < 60) return 'Just now';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return `${Math.floor(seconds / 86400)}d ago`;
|
||||
};
|
||||
|
||||
const renderCategoryCard = ({ item }: { item: ForumCategory }) => (
|
||||
<TouchableOpacity onPress={() => handleCategoryPress(item.id, item.name)}>
|
||||
<Card style={styles.categoryCard}>
|
||||
<View style={styles.categoryHeader}>
|
||||
<View style={styles.categoryIcon}>
|
||||
<Text style={styles.categoryIconText}>{item.icon}</Text>
|
||||
</View>
|
||||
<View style={styles.categoryInfo}>
|
||||
<Text style={styles.categoryName}>{item.name}</Text>
|
||||
<Text style={styles.categoryDescription} numberOfLines={2}>
|
||||
{item.description}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.categoryFooter}>
|
||||
<Text style={styles.categoryStats}>
|
||||
{item.threads_count} threads
|
||||
</Text>
|
||||
<Text style={styles.categoryArrow}>→</Text>
|
||||
</View>
|
||||
</Card>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const renderThreadCard = ({ item }: { item: ForumThread }) => (
|
||||
<TouchableOpacity>
|
||||
<Card style={styles.threadCard}>
|
||||
{/* Thread Header */}
|
||||
<View style={styles.threadHeader}>
|
||||
{item.is_pinned && (
|
||||
<View style={styles.pinnedBadge}>
|
||||
<Text style={styles.pinnedIcon}>📌</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.threadTitle} numberOfLines={2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
{item.is_locked && (
|
||||
<Text style={styles.lockedIcon}>🔒</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Thread Meta */}
|
||||
<View style={styles.threadMeta}>
|
||||
<Text style={styles.threadAuthor}>by {item.author}</Text>
|
||||
<Badge text={item.category} variant="outline" />
|
||||
</View>
|
||||
|
||||
{/* Thread Stats */}
|
||||
<View style={styles.threadStats}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statIcon}>💬</Text>
|
||||
<Text style={styles.statText}>{item.replies_count}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statIcon}>👁️</Text>
|
||||
<Text style={styles.statText}>{item.views_count}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statIcon}>🕐</Text>
|
||||
<Text style={styles.statText}>
|
||||
{formatTimeAgo(item.last_activity)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const renderEmptyState = () => (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyIcon}>💬</Text>
|
||||
<Text style={styles.emptyTitle}>No Threads Yet</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Be the first to start a discussion in this category
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.title}>
|
||||
{viewType === 'categories' ? 'Forum' : 'Threads'}
|
||||
</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{viewType === 'categories'
|
||||
? 'Join the community discussion'
|
||||
: selectedCategory || 'All threads'}
|
||||
</Text>
|
||||
</View>
|
||||
{viewType === 'threads' && (
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => setViewType('categories')}
|
||||
>
|
||||
<Text style={styles.backButtonText}>← Back</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
{loading && !refreshing ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={KurdistanColors.kesk} />
|
||||
<Text style={styles.loadingText}>Loading...</Text>
|
||||
</View>
|
||||
) : viewType === 'categories' ? (
|
||||
<FlatList
|
||||
data={CATEGORIES}
|
||||
renderItem={renderCategoryCard}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={KurdistanColors.kesk}
|
||||
<PezkuwiWebView
|
||||
path="/forum"
|
||||
title="Forum"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<FlatList
|
||||
data={threads}
|
||||
renderItem={renderThreadCard}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
ListEmptyComponent={renderEmptyState}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={KurdistanColors.kesk}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Thread FAB */}
|
||||
{viewType === 'threads' && (
|
||||
<TouchableOpacity style={styles.fab}>
|
||||
<Text style={styles.fabIcon}>✏️</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
@@ -353,196 +22,7 @@ const ForumScreen: React.FC = () => {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: AppColors.background,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
padding: 16,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
backButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
backButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingTop: 8,
|
||||
},
|
||||
categoryCard: {
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
categoryHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 12,
|
||||
},
|
||||
categoryIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#F0F9F4',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
categoryIconText: {
|
||||
fontSize: 24,
|
||||
},
|
||||
categoryInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
categoryName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
marginBottom: 4,
|
||||
},
|
||||
categoryDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
lineHeight: 20,
|
||||
},
|
||||
categoryFooter: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#F0F0F0',
|
||||
},
|
||||
categoryStats: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
categoryArrow: {
|
||||
fontSize: 20,
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
threadCard: {
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
threadHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 8,
|
||||
},
|
||||
pinnedBadge: {
|
||||
marginRight: 8,
|
||||
},
|
||||
pinnedIcon: {
|
||||
fontSize: 16,
|
||||
},
|
||||
threadTitle: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
lineHeight: 22,
|
||||
},
|
||||
lockedIcon: {
|
||||
fontSize: 16,
|
||||
marginLeft: 8,
|
||||
},
|
||||
threadMeta: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
threadAuthor: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
threadStats: {
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#F0F0F0',
|
||||
},
|
||||
statItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
statIcon: {
|
||||
fontSize: 14,
|
||||
},
|
||||
statText: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
fabIcon: {
|
||||
fontSize: 24,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -222,10 +222,7 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
elevation: 8,
|
||||
},
|
||||
lockEmoji: {
|
||||
@@ -258,10 +255,7 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
shadowColor: KurdistanColors.kesk,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
boxShadow: '0px 4px 12px rgba(0, 128, 0, 0.3)',
|
||||
elevation: 8,
|
||||
},
|
||||
biometricIcon: {
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useBiometricAuth } from '../contexts/BiometricAuthContext';
|
||||
import { AppColors, KurdistanColors } from '../theme/colors';
|
||||
import { Button, Input } from '../components';
|
||||
|
||||
/**
|
||||
* Lock Screen
|
||||
* Shown when app is locked - requires biometric or PIN
|
||||
*
|
||||
* PRIVACY: All authentication happens locally
|
||||
*/
|
||||
export default function LockScreen() {
|
||||
const {
|
||||
isBiometricSupported,
|
||||
isBiometricEnrolled,
|
||||
isBiometricEnabled,
|
||||
biometricType,
|
||||
authenticate,
|
||||
verifyPinCode,
|
||||
} = useBiometricAuth();
|
||||
|
||||
const [showPinInput, setShowPinInput] = useState(false);
|
||||
const [pin, setPin] = useState('');
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
|
||||
const handleBiometricAuth = React.useCallback(async () => {
|
||||
const success = await authenticate();
|
||||
if (!success) {
|
||||
// Biometric failed, show PIN option
|
||||
setShowPinInput(true);
|
||||
}
|
||||
}, [authenticate]);
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-trigger biometric on mount if enabled
|
||||
if (isBiometricEnabled && isBiometricSupported && isBiometricEnrolled) {
|
||||
handleBiometricAuth();
|
||||
}
|
||||
}, [isBiometricEnabled, isBiometricSupported, isBiometricEnrolled, handleBiometricAuth]);
|
||||
|
||||
const handlePinSubmit = async () => {
|
||||
if (!pin || pin.length < 4) {
|
||||
Alert.alert('Error', 'Please enter your PIN');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setVerifying(true);
|
||||
const success = await verifyPinCode(pin);
|
||||
|
||||
if (!success) {
|
||||
Alert.alert('Error', 'Incorrect PIN. Please try again.');
|
||||
setPin('');
|
||||
}
|
||||
} catch {
|
||||
Alert.alert('Error', 'Failed to verify PIN');
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getBiometricIcon = () => {
|
||||
switch (biometricType) {
|
||||
case 'facial': return '😊';
|
||||
case 'fingerprint': return '👆';
|
||||
case 'iris': return '👁️';
|
||||
default: return '🔒';
|
||||
}
|
||||
};
|
||||
|
||||
const getBiometricLabel = () => {
|
||||
switch (biometricType) {
|
||||
case 'facial': return 'Face ID';
|
||||
case 'fingerprint': return 'Fingerprint';
|
||||
case 'iris': return 'Iris';
|
||||
default: return 'Biometric';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Logo */}
|
||||
<View style={styles.logoContainer}>
|
||||
<Text style={styles.logo}>🌟</Text>
|
||||
<Text style={styles.appName}>PezkuwiChain</Text>
|
||||
<Text style={styles.subtitle}>Digital Kurdistan</Text>
|
||||
</View>
|
||||
|
||||
{/* Lock Icon */}
|
||||
<View style={styles.lockIcon}>
|
||||
<Text style={styles.lockEmoji}>🔒</Text>
|
||||
</View>
|
||||
|
||||
{/* Title */}
|
||||
<Text style={styles.title}>App Locked</Text>
|
||||
<Text style={styles.description}>
|
||||
Authenticate to unlock and access your wallet
|
||||
</Text>
|
||||
|
||||
{/* Biometric or PIN */}
|
||||
<View style={styles.authContainer}>
|
||||
{!showPinInput ? (
|
||||
// Biometric Button
|
||||
isBiometricEnabled && isBiometricSupported && isBiometricEnrolled ? (
|
||||
<View style={styles.biometricContainer}>
|
||||
<Pressable
|
||||
onPress={handleBiometricAuth}
|
||||
style={styles.biometricButton}
|
||||
>
|
||||
<Text style={styles.biometricIcon}>{getBiometricIcon()}</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.biometricLabel}>
|
||||
Tap to use {getBiometricLabel()}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => setShowPinInput(true)}
|
||||
style={styles.usePinButton}
|
||||
>
|
||||
<Text style={styles.usePinText}>Use PIN instead</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
// No biometric, show PIN immediately
|
||||
<View style={styles.noBiometricContainer}>
|
||||
<Text style={styles.noBiometricText}>
|
||||
Biometric authentication not available
|
||||
</Text>
|
||||
<Button
|
||||
title="Enter PIN"
|
||||
onPress={() => setShowPinInput(true)}
|
||||
variant="primary"
|
||||
fullWidth
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
// PIN Input
|
||||
<View style={styles.pinContainer}>
|
||||
<Input
|
||||
label="Enter PIN"
|
||||
value={pin}
|
||||
onChangeText={setPin}
|
||||
keyboardType="numeric"
|
||||
maxLength={6}
|
||||
secureTextEntry
|
||||
placeholder="Enter your PIN"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
title="Unlock"
|
||||
onPress={handlePinSubmit}
|
||||
loading={verifying}
|
||||
disabled={verifying || pin.length < 4}
|
||||
variant="primary"
|
||||
fullWidth
|
||||
/>
|
||||
{isBiometricEnabled && (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setShowPinInput(false);
|
||||
setPin('');
|
||||
}}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<Text style={styles.backText}>
|
||||
Use {getBiometricLabel()} instead
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Privacy Notice */}
|
||||
<View style={styles.privacyNotice}>
|
||||
<Text style={styles.privacyText}>
|
||||
🔐 Authentication happens on your device only
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: AppColors.background,
|
||||
paddingHorizontal: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 40,
|
||||
},
|
||||
logo: {
|
||||
fontSize: 64,
|
||||
marginBottom: 8,
|
||||
},
|
||||
appName: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
color: KurdistanColors.kesk,
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
lockIcon: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: AppColors.surface,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
lockEmoji: {
|
||||
fontSize: 48,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: AppColors.text,
|
||||
marginBottom: 8,
|
||||
},
|
||||
description: {
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginBottom: 40,
|
||||
},
|
||||
authContainer: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
},
|
||||
biometricContainer: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
biometricButton: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
shadowColor: KurdistanColors.kesk,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
biometricIcon: {
|
||||
fontSize: 40,
|
||||
},
|
||||
biometricLabel: {
|
||||
fontSize: 16,
|
||||
color: AppColors.text,
|
||||
marginBottom: 24,
|
||||
},
|
||||
usePinButton: {
|
||||
paddingVertical: 12,
|
||||
},
|
||||
usePinText: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.kesk,
|
||||
fontWeight: '600',
|
||||
},
|
||||
noBiometricContainer: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
noBiometricText: {
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
pinContainer: {
|
||||
gap: 16,
|
||||
},
|
||||
backButton: {
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
backText: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.kesk,
|
||||
fontWeight: '600',
|
||||
},
|
||||
privacyNotice: {
|
||||
position: 'absolute',
|
||||
bottom: 40,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
privacyText: {
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Dimensions,
|
||||
Pressable,
|
||||
} from 'react-native';
|
||||
import { usePolkadot } from '../contexts/PolkadotContext';
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
import { AppColors, KurdistanColors } from '../theme/colors';
|
||||
import {
|
||||
Card,
|
||||
@@ -39,7 +39,7 @@ interface NFT {
|
||||
* Inspired by OpenSea, Rarible, and modern NFT galleries
|
||||
*/
|
||||
export default function NFTGalleryScreen() {
|
||||
const { api, selectedAccount, isApiReady } = usePolkadot();
|
||||
const { api, selectedAccount, isApiReady } = usePezkuwi();
|
||||
const [nfts, setNfts] = useState<NFT[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -420,10 +420,7 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: AppColors.surface,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
elevation: 4,
|
||||
},
|
||||
nftCardPressed: {
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
RefreshControl,
|
||||
Dimensions,
|
||||
Pressable,
|
||||
} from 'react-native';
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
import { AppColors, KurdistanColors } from '../theme/colors';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
BottomSheet,
|
||||
Badge,
|
||||
CardSkeleton,
|
||||
} from '../components';
|
||||
import { fetchUserTikis, getTikiDisplayName, getTikiEmoji } from '@pezkuwi/lib/tiki';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const NFT_SIZE = (width - 48) / 2; // 2 columns with padding
|
||||
|
||||
interface NFT {
|
||||
id: string;
|
||||
type: 'citizenship' | 'tiki' | 'achievement';
|
||||
name: string;
|
||||
description: string;
|
||||
image: string;
|
||||
rarity: 'common' | 'rare' | 'epic' | 'legendary';
|
||||
mintDate: string;
|
||||
attributes: { trait: string; value: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* NFT Gallery Screen
|
||||
* Display Citizenship NFTs, Tiki Badges, Achievement NFTs
|
||||
* Inspired by OpenSea, Rarible, and modern NFT galleries
|
||||
*/
|
||||
export default function NFTGalleryScreen() {
|
||||
const { api, selectedAccount, isApiReady } = usePezkuwi();
|
||||
const [nfts, setNfts] = useState<NFT[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [selectedNFT, setSelectedNFT] = useState<NFT | null>(null);
|
||||
const [detailsVisible, setDetailsVisible] = useState(false);
|
||||
const [filter, setFilter] = useState<'all' | 'citizenship' | 'tiki' | 'achievement'>('all');
|
||||
|
||||
const fetchNFTs = React.useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
if (!api || !selectedAccount) return;
|
||||
|
||||
const nftList: NFT[] = [];
|
||||
|
||||
// 1. Check Citizenship NFT
|
||||
const citizenNft = await api.query.tiki?.citizenNft?.(selectedAccount.address);
|
||||
|
||||
if (citizenNft && !citizenNft.isEmpty) {
|
||||
const nftData = citizenNft.toJSON() as Record<string, unknown>;
|
||||
|
||||
nftList.push({
|
||||
id: 'citizenship-001',
|
||||
type: 'citizenship',
|
||||
name: 'Digital Kurdistan Citizenship',
|
||||
description: 'Official citizenship NFT of Digital Kurdistan. This NFT represents your verified status as a citizen of the Pezkuwi nation.',
|
||||
image: '🪪', // Will use emoji/icon for now
|
||||
rarity: 'legendary',
|
||||
mintDate: new Date(nftData?.mintedAt || Date.now()).toISOString(),
|
||||
attributes: [
|
||||
{ trait: 'Type', value: 'Citizenship' },
|
||||
{ trait: 'Nation', value: 'Kurdistan' },
|
||||
{ trait: 'Status', value: 'Verified' },
|
||||
{ trait: 'Rights', value: 'Full Voting Rights' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Fetch Tiki Role Badges
|
||||
const tikis = await fetchUserTikis(api, selectedAccount.address);
|
||||
|
||||
tikis.forEach((tiki, index) => {
|
||||
nftList.push({
|
||||
id: `tiki-${index}`,
|
||||
type: 'tiki',
|
||||
name: getTikiDisplayName(tiki),
|
||||
description: `You hold the role of ${getTikiDisplayName(tiki)} in Digital Kurdistan. This badge represents your responsibilities and privileges.`,
|
||||
image: getTikiEmoji(tiki),
|
||||
rarity: getRarityByTiki(tiki),
|
||||
mintDate: new Date().toISOString(),
|
||||
attributes: [
|
||||
{ trait: 'Type', value: 'Tiki Role' },
|
||||
{ trait: 'Role', value: getTikiDisplayName(tiki) },
|
||||
{ trait: 'Native Name', value: tiki },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Achievement NFTs (placeholder for future)
|
||||
// Query actual achievement NFTs when implemented
|
||||
|
||||
setNfts(nftList);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error fetching NFTs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [api, selectedAccount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isApiReady && selectedAccount) {
|
||||
fetchNFTs();
|
||||
}
|
||||
}, [isApiReady, selectedAccount, fetchNFTs]);
|
||||
|
||||
const getRarityByTiki = (tiki: string): NFT['rarity'] => {
|
||||
const highRank = ['Serok', 'SerokiMeclise', 'SerokWeziran', 'Axa'];
|
||||
const mediumRank = ['Wezir', 'Parlementer', 'EndameDiwane'];
|
||||
|
||||
if (highRank.includes(tiki)) return 'legendary';
|
||||
if (mediumRank.includes(tiki)) return 'epic';
|
||||
return 'rare';
|
||||
};
|
||||
|
||||
const filteredNFTs = filter === 'all'
|
||||
? nfts
|
||||
: nfts.filter(nft => nft.type === filter);
|
||||
|
||||
const getRarityColor = (rarity: NFT['rarity']) => {
|
||||
switch (rarity) {
|
||||
case 'legendary': return KurdistanColors.zer;
|
||||
case 'epic': return '#A855F7';
|
||||
case 'rare': return '#3B82F6';
|
||||
default: return AppColors.textSecondary;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && nfts.length === 0) {
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>NFT Gallery</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
{nfts.length} {nfts.length === 1 ? 'NFT' : 'NFTs'} collected
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.filterScroll}
|
||||
contentContainerStyle={styles.filterContainer}
|
||||
>
|
||||
<FilterButton
|
||||
label="All"
|
||||
count={nfts.length}
|
||||
active={filter === 'all'}
|
||||
onPress={() => setFilter('all')}
|
||||
/>
|
||||
<FilterButton
|
||||
label="Citizenship"
|
||||
count={nfts.filter(n => n.type === 'citizenship').length}
|
||||
active={filter === 'citizenship'}
|
||||
onPress={() => setFilter('citizenship')}
|
||||
/>
|
||||
<FilterButton
|
||||
label="Tiki Roles"
|
||||
count={nfts.filter(n => n.type === 'tiki').length}
|
||||
active={filter === 'tiki'}
|
||||
onPress={() => setFilter('tiki')}
|
||||
/>
|
||||
<FilterButton
|
||||
label="Achievements"
|
||||
count={nfts.filter(n => n.type === 'achievement').length}
|
||||
active={filter === 'achievement'}
|
||||
onPress={() => setFilter('achievement')}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
{/* NFT Grid */}
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.content}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => {
|
||||
setRefreshing(true);
|
||||
fetchNFTs();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{filteredNFTs.length === 0 ? (
|
||||
<Card style={styles.emptyCard}>
|
||||
<Text style={styles.emptyText}>No NFTs yet</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Complete citizenship application to earn your first NFT
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<View style={styles.grid}>
|
||||
{filteredNFTs.map((nft) => (
|
||||
<Pressable
|
||||
key={nft.id}
|
||||
onPress={() => {
|
||||
setSelectedNFT(nft);
|
||||
setDetailsVisible(true);
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
styles.nftCard,
|
||||
pressed && styles.nftCardPressed,
|
||||
]}
|
||||
>
|
||||
{/* NFT Image/Icon */}
|
||||
<View style={[
|
||||
styles.nftImage,
|
||||
{ borderColor: getRarityColor(nft.rarity) }
|
||||
]}>
|
||||
<Text style={styles.nftEmoji}>{nft.image}</Text>
|
||||
<View style={[
|
||||
styles.rarityBadge,
|
||||
{ backgroundColor: getRarityColor(nft.rarity) }
|
||||
]}>
|
||||
<Text style={styles.rarityText}>
|
||||
{nft.rarity.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* NFT Info */}
|
||||
<View style={styles.nftInfo}>
|
||||
<Text style={styles.nftName} numberOfLines={2}>
|
||||
{nft.name}
|
||||
</Text>
|
||||
<Badge
|
||||
label={nft.type}
|
||||
variant={nft.type === 'citizenship' ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* NFT Details Bottom Sheet */}
|
||||
<BottomSheet
|
||||
visible={detailsVisible}
|
||||
onClose={() => setDetailsVisible(false)}
|
||||
title="NFT Details"
|
||||
height={600}
|
||||
>
|
||||
{selectedNFT && (
|
||||
<ScrollView>
|
||||
{/* Large NFT Display */}
|
||||
<View style={[
|
||||
styles.detailImage,
|
||||
{ borderColor: getRarityColor(selectedNFT.rarity) }
|
||||
]}>
|
||||
<Text style={styles.detailEmoji}>{selectedNFT.image}</Text>
|
||||
</View>
|
||||
|
||||
{/* NFT Title & Rarity */}
|
||||
<View style={styles.detailHeader}>
|
||||
<Text style={styles.detailName}>{selectedNFT.name}</Text>
|
||||
<Badge
|
||||
label={selectedNFT.rarity}
|
||||
variant={selectedNFT.rarity === 'legendary' ? 'warning' : 'info'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
<Text style={styles.detailDescription}>
|
||||
{selectedNFT.description}
|
||||
</Text>
|
||||
|
||||
{/* Attributes */}
|
||||
<Text style={styles.attributesTitle}>Attributes</Text>
|
||||
<View style={styles.attributes}>
|
||||
{selectedNFT.attributes.map((attr, index) => (
|
||||
<View key={index} style={styles.attribute}>
|
||||
<Text style={styles.attributeTrait}>{attr.trait}</Text>
|
||||
<Text style={styles.attributeValue}>{attr.value}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Mint Date */}
|
||||
<View style={styles.mintInfo}>
|
||||
<Text style={styles.mintLabel}>Minted</Text>
|
||||
<Text style={styles.mintDate}>
|
||||
{new Date(selectedNFT.mintDate).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.detailActions}>
|
||||
<Button
|
||||
title="View on Explorer"
|
||||
variant="outline"
|
||||
fullWidth
|
||||
onPress={() => {
|
||||
// Open blockchain explorer
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</BottomSheet>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const FilterButton: React.FC<{
|
||||
label: string;
|
||||
count: number;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}> = ({ label, count, active, onPress }) => (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.filterButton,
|
||||
active && styles.filterButtonActive,
|
||||
]}
|
||||
>
|
||||
<Text style={[
|
||||
styles.filterButtonText,
|
||||
active && styles.filterButtonTextActive,
|
||||
]}>
|
||||
{label}
|
||||
</Text>
|
||||
<Badge
|
||||
label={count.toString()}
|
||||
variant={active ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: AppColors.background,
|
||||
},
|
||||
header: {
|
||||
padding: 16,
|
||||
paddingTop: 60,
|
||||
backgroundColor: AppColors.surface,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 32,
|
||||
fontWeight: '700',
|
||||
color: AppColors.text,
|
||||
marginBottom: 4,
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
filterScroll: {
|
||||
backgroundColor: AppColors.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: AppColors.border,
|
||||
},
|
||||
filterContainer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
gap: 8,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
filterButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: AppColors.background,
|
||||
gap: 8,
|
||||
},
|
||||
filterButtonActive: {
|
||||
backgroundColor: `${KurdistanColors.kesk}15`,
|
||||
},
|
||||
filterButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: AppColors.text,
|
||||
},
|
||||
filterButtonTextActive: {
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
},
|
||||
grid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
nftCard: {
|
||||
width: NFT_SIZE,
|
||||
backgroundColor: AppColors.surface,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
nftCardPressed: {
|
||||
opacity: 0.8,
|
||||
transform: [{ scale: 0.98 }],
|
||||
},
|
||||
nftImage: {
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
backgroundColor: AppColors.background,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 3,
|
||||
position: 'relative',
|
||||
},
|
||||
nftEmoji: {
|
||||
fontSize: 64,
|
||||
},
|
||||
rarityBadge: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
},
|
||||
rarityText: {
|
||||
fontSize: 9,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
nftInfo: {
|
||||
padding: 12,
|
||||
gap: 8,
|
||||
},
|
||||
nftName: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: AppColors.text,
|
||||
lineHeight: 18,
|
||||
},
|
||||
emptyCard: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: AppColors.text,
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
detailImage: {
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
backgroundColor: AppColors.background,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 4,
|
||||
marginBottom: 24,
|
||||
},
|
||||
detailEmoji: {
|
||||
fontSize: 120,
|
||||
},
|
||||
detailHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 16,
|
||||
gap: 12,
|
||||
},
|
||||
detailName: {
|
||||
flex: 1,
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: AppColors.text,
|
||||
lineHeight: 30,
|
||||
},
|
||||
detailDescription: {
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary,
|
||||
lineHeight: 24,
|
||||
marginBottom: 24,
|
||||
},
|
||||
attributesTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: AppColors.text,
|
||||
marginBottom: 12,
|
||||
},
|
||||
attributes: {
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
},
|
||||
attribute: {
|
||||
backgroundColor: AppColors.background,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
attributeTrait: {
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
attributeValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: AppColors.text,
|
||||
},
|
||||
mintInfo: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: AppColors.border,
|
||||
marginBottom: 24,
|
||||
},
|
||||
mintLabel: {
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
mintDate: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: AppColors.text,
|
||||
},
|
||||
detailActions: {
|
||||
gap: 12,
|
||||
marginBottom: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,542 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import { supabaseHelpers } from '../lib/supabase';
|
||||
|
||||
interface P2PAd {
|
||||
id: string;
|
||||
type: 'buy' | 'sell';
|
||||
merchant: string;
|
||||
rating: number;
|
||||
trades: number;
|
||||
price: number;
|
||||
currency: string;
|
||||
amount: string;
|
||||
limits: string;
|
||||
paymentMethods: string[];
|
||||
}
|
||||
|
||||
// P2P ads stored in Supabase database - fetched from p2p_ads table
|
||||
|
||||
const P2PPlatformScreen: React.FC = () => {
|
||||
const [selectedTab, setSelectedTab] = useState<'buy' | 'sell'>('buy');
|
||||
const [selectedFilter, setSelectedFilter] = useState<'all' | 'bank' | 'online'>('all');
|
||||
const [ads, setAds] = useState<P2PAd[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetchAds = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch P2P ads from Supabase database
|
||||
const data = await supabaseHelpers.getP2PAds(selectedTab);
|
||||
|
||||
// Transform Supabase data to component format
|
||||
const transformedAds: P2PAd[] = data.map(ad => ({
|
||||
id: ad.id,
|
||||
type: ad.type,
|
||||
merchant: ad.merchant_name,
|
||||
rating: ad.rating,
|
||||
trades: ad.trades_count,
|
||||
price: ad.price,
|
||||
currency: ad.currency,
|
||||
amount: ad.amount,
|
||||
limits: `${ad.min_limit} - ${ad.max_limit}`,
|
||||
paymentMethods: ad.payment_methods,
|
||||
}));
|
||||
|
||||
setAds(transformedAds);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load P2P ads:', error);
|
||||
// If tables don't exist yet, show empty state instead of error
|
||||
setAds([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAds();
|
||||
// Refresh ads every 30 seconds
|
||||
const interval = setInterval(fetchAds, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedTab]); // Re-fetch when tab changes
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchAds();
|
||||
};
|
||||
|
||||
const handleTrade = (ad: P2PAd) => {
|
||||
Alert.alert(
|
||||
'Start Trade',
|
||||
`Trade with ${ad.merchant}?\nPrice: $${ad.price} ${ad.currency}\nLimits: ${ad.limits}`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Continue', onPress: () => Alert.alert('Trade Modal', 'Trade modal would open here') },
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreateAd = () => {
|
||||
Alert.alert('Create Ad', 'Create ad modal would open here');
|
||||
};
|
||||
|
||||
const filteredAds = ads.filter((ad) => ad.type === selectedTab);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<ScrollView
|
||||
style={styles.scrollContent}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>P2P Trading</Text>
|
||||
<Text style={styles.headerSubtitle}>Buy and sell crypto with local currency</Text>
|
||||
</View>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statIcon}>⏰</Text>
|
||||
<Text style={styles.statValue}>0</Text>
|
||||
<Text style={styles.statLabel}>Active Trades</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statIcon}>✅</Text>
|
||||
<Text style={styles.statValue}>0</Text>
|
||||
<Text style={styles.statLabel}>Completed</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statIcon}>📈</Text>
|
||||
<Text style={styles.statValue}>$0</Text>
|
||||
<Text style={styles.statLabel}>Volume</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Create Ad Button */}
|
||||
<TouchableOpacity style={styles.createAdButton} onPress={handleCreateAd}>
|
||||
<Text style={styles.createAdButtonText}>➕ Post a New Ad</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Buy/Sell Tabs */}
|
||||
<View style={styles.tabsContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, selectedTab === 'buy' && styles.tabActive]}
|
||||
onPress={() => setSelectedTab('buy')}
|
||||
>
|
||||
<Text style={[styles.tabText, selectedTab === 'buy' && styles.tabTextActive]}>
|
||||
Buy HEZ
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, selectedTab === 'sell' && styles.tabActive]}
|
||||
onPress={() => setSelectedTab('sell')}
|
||||
>
|
||||
<Text style={[styles.tabText, selectedTab === 'sell' && styles.tabTextActive]}>
|
||||
Sell HEZ
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Filter Chips */}
|
||||
<View style={styles.filterRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.filterChip, selectedFilter === 'all' && styles.filterChipActive]}
|
||||
onPress={() => setSelectedFilter('all')}
|
||||
>
|
||||
<Text style={[styles.filterChipText, selectedFilter === 'all' && styles.filterChipTextActive]}>
|
||||
All Payment
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.filterChip, selectedFilter === 'bank' && styles.filterChipActive]}
|
||||
onPress={() => setSelectedFilter('bank')}
|
||||
>
|
||||
<Text style={[styles.filterChipText, selectedFilter === 'bank' && styles.filterChipTextActive]}>
|
||||
Bank Transfer
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.filterChip, selectedFilter === 'online' && styles.filterChipActive]}
|
||||
onPress={() => setSelectedFilter('online')}
|
||||
>
|
||||
<Text style={[styles.filterChipText, selectedFilter === 'online' && styles.filterChipTextActive]}>
|
||||
Online Payment
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Ads List */}
|
||||
<View style={styles.adsList}>
|
||||
{filteredAds.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyIcon}>🛒</Text>
|
||||
<Text style={styles.emptyText}>No ads available</Text>
|
||||
<Text style={styles.emptySubtext}>Be the first to post an ad!</Text>
|
||||
</View>
|
||||
) : (
|
||||
filteredAds.map((ad) => (
|
||||
<View key={ad.id} style={styles.adCard}>
|
||||
{/* Merchant Info */}
|
||||
<View style={styles.merchantRow}>
|
||||
<View style={styles.merchantInfo}>
|
||||
<Text style={styles.merchantName}>{ad.merchant}</Text>
|
||||
<View style={styles.merchantStats}>
|
||||
<Text style={styles.merchantRating}>⭐ {ad.rating}</Text>
|
||||
<Text style={styles.merchantTrades}> | {ad.trades} trades</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.typeBadge, ad.type === 'buy' ? styles.buyBadge : styles.sellBadge]}>
|
||||
<Text style={styles.typeBadgeText}>{ad.type.toUpperCase()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Price Info */}
|
||||
<View style={styles.priceRow}>
|
||||
<View>
|
||||
<Text style={styles.priceLabel}>Price</Text>
|
||||
<Text style={styles.priceValue}>${ad.price.toLocaleString()}</Text>
|
||||
</View>
|
||||
<View style={styles.priceRightColumn}>
|
||||
<Text style={styles.amountLabel}>Available</Text>
|
||||
<Text style={styles.amountValue}>{ad.amount}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Limits */}
|
||||
<View style={styles.limitsRow}>
|
||||
<Text style={styles.limitsLabel}>Limits: </Text>
|
||||
<Text style={styles.limitsValue}>{ad.limits}</Text>
|
||||
</View>
|
||||
|
||||
{/* Payment Methods */}
|
||||
<View style={styles.paymentMethodsRow}>
|
||||
{ad.paymentMethods.map((method, index) => (
|
||||
<View key={index} style={styles.paymentMethodChip}>
|
||||
<Text style={styles.paymentMethodText}>{method}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Trade Button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.tradeButton, ad.type === 'buy' ? styles.buyButton : styles.sellButton]}
|
||||
onPress={() => handleTrade(ad)}
|
||||
>
|
||||
<Text style={styles.tradeButtonText}>
|
||||
{ad.type === 'buy' ? 'Buy HEZ' : 'Sell HEZ'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Info Note */}
|
||||
<View style={styles.infoNote}>
|
||||
<Text style={styles.infoNoteIcon}>ℹ️</Text>
|
||||
<Text style={styles.infoNoteText}>
|
||||
P2P trading is currently in beta. Always verify merchant ratings and complete trades within the escrow system.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8F9FA',
|
||||
},
|
||||
scrollContent: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
padding: 20,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: 4,
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
boxShadow: '0px 1px 4px rgba(0, 0, 0, 0.05)',
|
||||
elevation: 2,
|
||||
},
|
||||
statIcon: {
|
||||
fontSize: 24,
|
||||
marginBottom: 8,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: 4,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 11,
|
||||
color: '#999',
|
||||
},
|
||||
createAdButton: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 20,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
createAdButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
tabsContainer: {
|
||||
flexDirection: 'row',
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
backgroundColor: '#E5E5E5',
|
||||
borderRadius: 12,
|
||||
padding: 4,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
borderRadius: 10,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
},
|
||||
tabTextActive: {
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
filterRow: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
gap: 8,
|
||||
},
|
||||
filterChip: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#E5E5E5',
|
||||
},
|
||||
filterChipActive: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
},
|
||||
filterChipText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
},
|
||||
filterChipTextActive: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
adsList: {
|
||||
paddingHorizontal: 16,
|
||||
gap: 16,
|
||||
},
|
||||
emptyContainer: {
|
||||
padding: 40,
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
adCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.05)',
|
||||
elevation: 2,
|
||||
},
|
||||
merchantRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
merchantInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
merchantName: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: 4,
|
||||
},
|
||||
merchantStats: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
merchantRating: {
|
||||
fontSize: 12,
|
||||
color: '#F59E0B',
|
||||
fontWeight: '600',
|
||||
},
|
||||
merchantTrades: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
typeBadge: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
},
|
||||
buyBadge: {
|
||||
backgroundColor: 'rgba(0, 143, 67, 0.1)',
|
||||
},
|
||||
sellBadge: {
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
},
|
||||
typeBadgeText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
priceRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
},
|
||||
priceLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: 4,
|
||||
},
|
||||
priceValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
},
|
||||
priceRightColumn: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
amountLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: 4,
|
||||
},
|
||||
amountValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
limitsRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 12,
|
||||
},
|
||||
limitsLabel: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
},
|
||||
limitsValue: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
paymentMethodsRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
paymentMethodChip: {
|
||||
backgroundColor: '#F0F0F0',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
},
|
||||
paymentMethodText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
color: '#666',
|
||||
},
|
||||
tradeButton: {
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buyButton: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
},
|
||||
sellButton: {
|
||||
backgroundColor: '#EF4444',
|
||||
},
|
||||
tradeButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
infoNote: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#FEF3C7',
|
||||
marginHorizontal: 16,
|
||||
marginTop: 16,
|
||||
marginBottom: 24,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
gap: 12,
|
||||
},
|
||||
infoNoteIcon: {
|
||||
fontSize: 20,
|
||||
},
|
||||
infoNoteText: {
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
color: '#92400E',
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
|
||||
export default P2PPlatformScreen;
|
||||
@@ -0,0 +1,548 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import { supabaseHelpers } from '../lib/supabase';
|
||||
|
||||
interface P2PAd {
|
||||
id: string;
|
||||
type: 'buy' | 'sell';
|
||||
merchant: string;
|
||||
rating: number;
|
||||
trades: number;
|
||||
price: number;
|
||||
currency: string;
|
||||
amount: string;
|
||||
limits: string;
|
||||
paymentMethods: string[];
|
||||
}
|
||||
|
||||
// P2P ads stored in Supabase database - fetched from p2p_ads table
|
||||
|
||||
const P2PPlatformScreen: React.FC = () => {
|
||||
const [selectedTab, setSelectedTab] = useState<'buy' | 'sell'>('buy');
|
||||
const [selectedFilter, setSelectedFilter] = useState<'all' | 'bank' | 'online'>('all');
|
||||
const [ads, setAds] = useState<P2PAd[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetchAds = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch P2P ads from Supabase database
|
||||
const data = await supabaseHelpers.getP2PAds(selectedTab);
|
||||
|
||||
// Transform Supabase data to component format
|
||||
const transformedAds: P2PAd[] = data.map(ad => ({
|
||||
id: ad.id,
|
||||
type: ad.type,
|
||||
merchant: ad.merchant_name,
|
||||
rating: ad.rating,
|
||||
trades: ad.trades_count,
|
||||
price: ad.price,
|
||||
currency: ad.currency,
|
||||
amount: ad.amount,
|
||||
limits: `${ad.min_limit} - ${ad.max_limit}`,
|
||||
paymentMethods: ad.payment_methods,
|
||||
}));
|
||||
|
||||
setAds(transformedAds);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load P2P ads:', error);
|
||||
// If tables don't exist yet, show empty state instead of error
|
||||
setAds([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAds();
|
||||
// Refresh ads every 30 seconds
|
||||
const interval = setInterval(fetchAds, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedTab]); // Re-fetch when tab changes
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchAds();
|
||||
};
|
||||
|
||||
const handleTrade = (ad: P2PAd) => {
|
||||
Alert.alert(
|
||||
'Start Trade',
|
||||
`Trade with ${ad.merchant}?\nPrice: $${ad.price} ${ad.currency}\nLimits: ${ad.limits}`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Continue', onPress: () => Alert.alert('Trade Modal', 'Trade modal would open here') },
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreateAd = () => {
|
||||
Alert.alert('Create Ad', 'Create ad modal would open here');
|
||||
};
|
||||
|
||||
const filteredAds = ads.filter((ad) => ad.type === selectedTab);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<ScrollView
|
||||
style={styles.scrollContent}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>P2P Trading</Text>
|
||||
<Text style={styles.headerSubtitle}>Buy and sell crypto with local currency</Text>
|
||||
</View>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statIcon}>⏰</Text>
|
||||
<Text style={styles.statValue}>0</Text>
|
||||
<Text style={styles.statLabel}>Active Trades</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statIcon}>✅</Text>
|
||||
<Text style={styles.statValue}>0</Text>
|
||||
<Text style={styles.statLabel}>Completed</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statIcon}>📈</Text>
|
||||
<Text style={styles.statValue}>$0</Text>
|
||||
<Text style={styles.statLabel}>Volume</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Create Ad Button */}
|
||||
<TouchableOpacity style={styles.createAdButton} onPress={handleCreateAd}>
|
||||
<Text style={styles.createAdButtonText}>➕ Post a New Ad</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Buy/Sell Tabs */}
|
||||
<View style={styles.tabsContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, selectedTab === 'buy' && styles.tabActive]}
|
||||
onPress={() => setSelectedTab('buy')}
|
||||
>
|
||||
<Text style={[styles.tabText, selectedTab === 'buy' && styles.tabTextActive]}>
|
||||
Buy HEZ
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, selectedTab === 'sell' && styles.tabActive]}
|
||||
onPress={() => setSelectedTab('sell')}
|
||||
>
|
||||
<Text style={[styles.tabText, selectedTab === 'sell' && styles.tabTextActive]}>
|
||||
Sell HEZ
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Filter Chips */}
|
||||
<View style={styles.filterRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.filterChip, selectedFilter === 'all' && styles.filterChipActive]}
|
||||
onPress={() => setSelectedFilter('all')}
|
||||
>
|
||||
<Text style={[styles.filterChipText, selectedFilter === 'all' && styles.filterChipTextActive]}>
|
||||
All Payment
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.filterChip, selectedFilter === 'bank' && styles.filterChipActive]}
|
||||
onPress={() => setSelectedFilter('bank')}
|
||||
>
|
||||
<Text style={[styles.filterChipText, selectedFilter === 'bank' && styles.filterChipTextActive]}>
|
||||
Bank Transfer
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.filterChip, selectedFilter === 'online' && styles.filterChipActive]}
|
||||
onPress={() => setSelectedFilter('online')}
|
||||
>
|
||||
<Text style={[styles.filterChipText, selectedFilter === 'online' && styles.filterChipTextActive]}>
|
||||
Online Payment
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Ads List */}
|
||||
<View style={styles.adsList}>
|
||||
{filteredAds.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyIcon}>🛒</Text>
|
||||
<Text style={styles.emptyText}>No ads available</Text>
|
||||
<Text style={styles.emptySubtext}>Be the first to post an ad!</Text>
|
||||
</View>
|
||||
) : (
|
||||
filteredAds.map((ad) => (
|
||||
<View key={ad.id} style={styles.adCard}>
|
||||
{/* Merchant Info */}
|
||||
<View style={styles.merchantRow}>
|
||||
<View style={styles.merchantInfo}>
|
||||
<Text style={styles.merchantName}>{ad.merchant}</Text>
|
||||
<View style={styles.merchantStats}>
|
||||
<Text style={styles.merchantRating}>⭐ {ad.rating}</Text>
|
||||
<Text style={styles.merchantTrades}> | {ad.trades} trades</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.typeBadge, ad.type === 'buy' ? styles.buyBadge : styles.sellBadge]}>
|
||||
<Text style={styles.typeBadgeText}>{ad.type.toUpperCase()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Price Info */}
|
||||
<View style={styles.priceRow}>
|
||||
<View>
|
||||
<Text style={styles.priceLabel}>Price</Text>
|
||||
<Text style={styles.priceValue}>${ad.price.toLocaleString()}</Text>
|
||||
</View>
|
||||
<View style={styles.priceRightColumn}>
|
||||
<Text style={styles.amountLabel}>Available</Text>
|
||||
<Text style={styles.amountValue}>{ad.amount}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Limits */}
|
||||
<View style={styles.limitsRow}>
|
||||
<Text style={styles.limitsLabel}>Limits: </Text>
|
||||
<Text style={styles.limitsValue}>{ad.limits}</Text>
|
||||
</View>
|
||||
|
||||
{/* Payment Methods */}
|
||||
<View style={styles.paymentMethodsRow}>
|
||||
{ad.paymentMethods.map((method, index) => (
|
||||
<View key={index} style={styles.paymentMethodChip}>
|
||||
<Text style={styles.paymentMethodText}>{method}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Trade Button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.tradeButton, ad.type === 'buy' ? styles.buyButton : styles.sellButton]}
|
||||
onPress={() => handleTrade(ad)}
|
||||
>
|
||||
<Text style={styles.tradeButtonText}>
|
||||
{ad.type === 'buy' ? 'Buy HEZ' : 'Sell HEZ'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Info Note */}
|
||||
<View style={styles.infoNote}>
|
||||
<Text style={styles.infoNoteIcon}>ℹ️</Text>
|
||||
<Text style={styles.infoNoteText}>
|
||||
P2P trading is currently in beta. Always verify merchant ratings and complete trades within the escrow system.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8F9FA',
|
||||
},
|
||||
scrollContent: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
padding: 20,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: 4,
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
statIcon: {
|
||||
fontSize: 24,
|
||||
marginBottom: 8,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: 4,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 11,
|
||||
color: '#999',
|
||||
},
|
||||
createAdButton: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 20,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
createAdButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
tabsContainer: {
|
||||
flexDirection: 'row',
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
backgroundColor: '#E5E5E5',
|
||||
borderRadius: 12,
|
||||
padding: 4,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
borderRadius: 10,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
},
|
||||
tabTextActive: {
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
filterRow: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
gap: 8,
|
||||
},
|
||||
filterChip: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#E5E5E5',
|
||||
},
|
||||
filterChipActive: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
},
|
||||
filterChipText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
},
|
||||
filterChipTextActive: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
adsList: {
|
||||
paddingHorizontal: 16,
|
||||
gap: 16,
|
||||
},
|
||||
emptyContainer: {
|
||||
padding: 40,
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
adCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
merchantRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
merchantInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
merchantName: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: 4,
|
||||
},
|
||||
merchantStats: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
merchantRating: {
|
||||
fontSize: 12,
|
||||
color: '#F59E0B',
|
||||
fontWeight: '600',
|
||||
},
|
||||
merchantTrades: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
typeBadge: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
},
|
||||
buyBadge: {
|
||||
backgroundColor: 'rgba(0, 143, 67, 0.1)',
|
||||
},
|
||||
sellBadge: {
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
},
|
||||
typeBadgeText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
priceRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
},
|
||||
priceLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: 4,
|
||||
},
|
||||
priceValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
},
|
||||
priceRightColumn: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
amountLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: 4,
|
||||
},
|
||||
amountValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
limitsRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 12,
|
||||
},
|
||||
limitsLabel: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
},
|
||||
limitsValue: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
paymentMethodsRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
paymentMethodChip: {
|
||||
backgroundColor: '#F0F0F0',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
},
|
||||
paymentMethodText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
color: '#666',
|
||||
},
|
||||
tradeButton: {
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buyButton: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
},
|
||||
sellButton: {
|
||||
backgroundColor: '#EF4444',
|
||||
},
|
||||
tradeButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
infoNote: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#FEF3C7',
|
||||
marginHorizontal: 16,
|
||||
marginTop: 16,
|
||||
marginBottom: 24,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
gap: 12,
|
||||
},
|
||||
infoNoteIcon: {
|
||||
fontSize: 20,
|
||||
},
|
||||
infoNoteText: {
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
color: '#92400E',
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
|
||||
export default P2PPlatformScreen;
|
||||
@@ -1,462 +1,21 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
Modal,
|
||||
TextInput,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, Button, Badge } from '../components';
|
||||
import { KurdistanColors, AppColors } from '../theme/colors';
|
||||
import { usePolkadot } from '../contexts/PolkadotContext';
|
||||
|
||||
// Import from shared library
|
||||
import {
|
||||
getActiveOffers,
|
||||
type P2PFiatOffer,
|
||||
type P2PReputation,
|
||||
} from '../../../shared/lib/p2p-fiat';
|
||||
|
||||
interface OfferWithReputation extends P2PFiatOffer {
|
||||
seller_reputation?: P2PReputation;
|
||||
payment_method_name?: string;
|
||||
}
|
||||
|
||||
type TabType = 'buy' | 'sell' | 'my-offers';
|
||||
import React from 'react';
|
||||
import { SafeAreaView, StyleSheet } from 'react-native';
|
||||
import { PezkuwiWebView } from '../components';
|
||||
|
||||
/**
|
||||
* P2P Trading Screen
|
||||
*
|
||||
* Uses WebView to load the full-featured P2P trading interface from the web app.
|
||||
* The web app handles all P2P logic (offers, trades, escrow, chat, disputes).
|
||||
* Native wallet bridge allows transaction signing from the mobile app.
|
||||
*/
|
||||
const P2PScreen: React.FC = () => {
|
||||
const { t: _t } = useTranslation();
|
||||
const { selectedAccount } = usePolkadot();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('buy');
|
||||
const [offers, setOffers] = useState<OfferWithReputation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [showCreateOffer, setShowCreateOffer] = useState(false);
|
||||
const [showTradeModal, setShowTradeModal] = useState(false);
|
||||
const [selectedOffer, setSelectedOffer] = useState<OfferWithReputation | null>(null);
|
||||
const [tradeAmount, setTradeAmount] = useState('');
|
||||
|
||||
const fetchOffers = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let offersData: P2PFiatOffer[] = [];
|
||||
|
||||
if (activeTab === 'buy') {
|
||||
// Buy = looking for sell offers
|
||||
offersData = await getActiveOffers();
|
||||
} else if (activeTab === 'my-offers' && selectedAccount) {
|
||||
// TODO: Implement getUserOffers from shared library
|
||||
offersData = [];
|
||||
}
|
||||
|
||||
// Enrich with reputation (simplified for now)
|
||||
const enrichedOffers: OfferWithReputation[] = offersData.map((offer) => ({
|
||||
...offer,
|
||||
}));
|
||||
|
||||
setOffers(enrichedOffers);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Fetch offers error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [activeTab, selectedAccount]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOffers();
|
||||
}, [fetchOffers]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchOffers();
|
||||
};
|
||||
|
||||
const getTrustLevelColor = (
|
||||
level: 'new' | 'basic' | 'intermediate' | 'advanced' | 'verified'
|
||||
) => {
|
||||
const colors = {
|
||||
new: '#999',
|
||||
basic: KurdistanColors.zer,
|
||||
intermediate: '#2196F3',
|
||||
advanced: KurdistanColors.kesk,
|
||||
verified: '#9C27B0',
|
||||
};
|
||||
return colors[level];
|
||||
};
|
||||
|
||||
const getTrustLevelLabel = (
|
||||
level: 'new' | 'basic' | 'intermediate' | 'advanced' | 'verified'
|
||||
) => {
|
||||
const labels = {
|
||||
new: 'New',
|
||||
basic: 'Basic',
|
||||
intermediate: 'Intermediate',
|
||||
advanced: 'Advanced',
|
||||
verified: 'Verified',
|
||||
};
|
||||
return labels[level];
|
||||
};
|
||||
|
||||
const renderOfferCard = ({ item }: { item: OfferWithReputation }) => (
|
||||
<Card style={styles.offerCard}>
|
||||
{/* Seller Info */}
|
||||
<View style={styles.sellerRow}>
|
||||
<View style={styles.sellerInfo}>
|
||||
<View style={styles.sellerAvatar}>
|
||||
<Text style={styles.sellerAvatarText}>
|
||||
{item.seller_wallet.slice(0, 2).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.sellerDetails}>
|
||||
<Text style={styles.sellerName}>
|
||||
{item.seller_wallet.slice(0, 6)}...{item.seller_wallet.slice(-4)}
|
||||
</Text>
|
||||
{item.seller_reputation && (
|
||||
<View style={styles.reputationRow}>
|
||||
<Badge
|
||||
text={getTrustLevelLabel(item.seller_reputation.trust_level)}
|
||||
variant="success"
|
||||
style={{
|
||||
backgroundColor: getTrustLevelColor(
|
||||
item.seller_reputation.trust_level
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Text style={styles.tradesCount}>
|
||||
{item.seller_reputation.completed_trades} trades
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{item.seller_reputation?.verified_merchant && (
|
||||
<View style={styles.verifiedBadge}>
|
||||
<Text style={styles.verifiedIcon}>✓</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Offer Details */}
|
||||
<View style={styles.offerDetails}>
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel}>Amount</Text>
|
||||
<Text style={styles.detailValue}>
|
||||
{item.amount_crypto} {item.token}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel}>Price</Text>
|
||||
<Text style={styles.detailValue}>
|
||||
{item.price_per_unit.toFixed(2)} {item.fiat_currency}/{item.token}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel}>Total</Text>
|
||||
<Text style={[styles.detailValue, styles.totalValue]}>
|
||||
{item.fiat_amount.toFixed(2)} {item.fiat_currency}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{item.payment_method_name && (
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel}>Payment</Text>
|
||||
<Badge text={item.payment_method_name} variant="outline" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel}>Limits</Text>
|
||||
<Text style={styles.detailValue}>
|
||||
{item.min_order_amount || 0} - {item.max_order_amount || item.fiat_amount}{' '}
|
||||
{item.fiat_currency}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel}>Time Limit</Text>
|
||||
<Text style={styles.detailValue}>{item.time_limit_minutes} min</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action Button */}
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={() => {
|
||||
setSelectedOffer(item);
|
||||
setShowTradeModal(true);
|
||||
}}
|
||||
style={styles.tradeButton}
|
||||
>
|
||||
Buy {item.token}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderEmptyState = () => (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyIcon}>📭</Text>
|
||||
<Text style={styles.emptyTitle}>No Offers Available</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
{activeTab === 'my-offers'
|
||||
? 'You haven\'t created any offers yet'
|
||||
: 'No active offers at the moment'}
|
||||
</Text>
|
||||
{activeTab === 'my-offers' && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={() => setShowCreateOffer(true)}
|
||||
style={styles.createButton}
|
||||
>
|
||||
Create Your First Offer
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.title}>P2P Trading</Text>
|
||||
<Text style={styles.subtitle}>Buy and sell crypto with local currency</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.createButton}
|
||||
onPress={() => setShowCreateOffer(true)}
|
||||
>
|
||||
<Text style={styles.createButtonText}>+ Post Ad</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Tabs */}
|
||||
<View style={styles.tabs}>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, activeTab === 'buy' && styles.activeTab]}
|
||||
onPress={() => setActiveTab('buy')}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'buy' && styles.activeTabText]}>
|
||||
Buy
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, activeTab === 'sell' && styles.activeTab]}
|
||||
onPress={() => setActiveTab('sell')}
|
||||
>
|
||||
<Text
|
||||
style={[styles.tabText, activeTab === 'sell' && styles.activeTabText]}
|
||||
>
|
||||
Sell
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, activeTab === 'my-offers' && styles.activeTab]}
|
||||
onPress={() => setActiveTab('my-offers')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === 'my-offers' && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
My Offers
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Offer List */}
|
||||
{loading && !refreshing ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={KurdistanColors.kesk} />
|
||||
<Text style={styles.loadingText}>Loading offers...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={offers}
|
||||
renderItem={renderOfferCard}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
ListEmptyComponent={renderEmptyState}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={KurdistanColors.kesk}
|
||||
<PezkuwiWebView
|
||||
path="/p2p"
|
||||
title="P2P Trading"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Trade Modal */}
|
||||
<Modal
|
||||
visible={showTradeModal}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={() => setShowTradeModal(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>
|
||||
Buy {selectedOffer?.token || 'Token'}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setShowTradeModal(false);
|
||||
setTradeAmount('');
|
||||
}}
|
||||
>
|
||||
<Text style={styles.modalClose}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView>
|
||||
{selectedOffer && (
|
||||
<>
|
||||
{/* Seller Info */}
|
||||
<View style={styles.modalSection}>
|
||||
<Text style={styles.modalSectionTitle}>Trading with</Text>
|
||||
<Text style={styles.modalAddress}>
|
||||
{selectedOffer.seller_wallet.slice(0, 6)}...
|
||||
{selectedOffer.seller_wallet.slice(-4)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Price Info */}
|
||||
<View style={[styles.modalSection, styles.priceSection]}>
|
||||
<View style={styles.priceRow}>
|
||||
<Text style={styles.priceLabel}>Price</Text>
|
||||
<Text style={styles.priceValue}>
|
||||
{selectedOffer.price_per_unit.toFixed(2)}{' '}
|
||||
{selectedOffer.fiat_currency}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.priceRow}>
|
||||
<Text style={styles.priceLabel}>Available</Text>
|
||||
<Text style={styles.priceValue}>
|
||||
{selectedOffer.remaining_amount} {selectedOffer.token}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Amount Input */}
|
||||
<View style={styles.modalSection}>
|
||||
<Text style={styles.inputLabel}>
|
||||
Amount to Buy ({selectedOffer.token})
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.modalInput}
|
||||
placeholder="0.00"
|
||||
keyboardType="decimal-pad"
|
||||
value={tradeAmount}
|
||||
onChangeText={setTradeAmount}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
{selectedOffer.min_order_amount && (
|
||||
<Text style={styles.inputHint}>
|
||||
Min: {selectedOffer.min_order_amount} {selectedOffer.token}
|
||||
</Text>
|
||||
)}
|
||||
{selectedOffer.max_order_amount && (
|
||||
<Text style={styles.inputHint}>
|
||||
Max: {selectedOffer.max_order_amount} {selectedOffer.token}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Calculation */}
|
||||
{parseFloat(tradeAmount) > 0 && (
|
||||
<View style={[styles.modalSection, styles.calculationSection]}>
|
||||
<Text style={styles.calculationLabel}>You will pay</Text>
|
||||
<Text style={styles.calculationValue}>
|
||||
{(parseFloat(tradeAmount) * selectedOffer.price_per_unit).toFixed(2)}{' '}
|
||||
{selectedOffer.fiat_currency}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Trade Button */}
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={() => {
|
||||
if (!selectedAccount) {
|
||||
Alert.alert('Error', 'Please connect your wallet first');
|
||||
return;
|
||||
}
|
||||
if (!tradeAmount || parseFloat(tradeAmount) <= 0) {
|
||||
Alert.alert('Error', 'Please enter a valid amount');
|
||||
return;
|
||||
}
|
||||
// TODO: Implement blockchain trade initiation
|
||||
Alert.alert(
|
||||
'Coming Soon',
|
||||
'P2P trading blockchain integration will be available soon. UI is ready!'
|
||||
);
|
||||
setShowTradeModal(false);
|
||||
setTradeAmount('');
|
||||
}}
|
||||
style={styles.tradeModalButton}
|
||||
>
|
||||
Initiate Trade
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* Create Offer Modal */}
|
||||
<Modal
|
||||
visible={showCreateOffer}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={() => setShowCreateOffer(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Create Offer</Text>
|
||||
<TouchableOpacity onPress={() => setShowCreateOffer(false)}>
|
||||
<Text style={styles.modalClose}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView>
|
||||
<View style={styles.comingSoonContainer}>
|
||||
<Text style={styles.comingSoonIcon}>🚧</Text>
|
||||
<Text style={styles.comingSoonTitle}>Coming Soon</Text>
|
||||
<Text style={styles.comingSoonText}>
|
||||
Create P2P offer functionality will be available in the next update.
|
||||
The blockchain integration is ready and waiting for final testing!
|
||||
</Text>
|
||||
<Button
|
||||
variant="outline"
|
||||
onPress={() => setShowCreateOffer(false)}
|
||||
style={styles.comingSoonButton}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
@@ -464,312 +23,7 @@ const P2PScreen: React.FC = () => {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: AppColors.background,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
padding: 16,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
createButton: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
},
|
||||
createButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
tabs: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E0E0E0',
|
||||
marginBottom: 16,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: 'transparent',
|
||||
},
|
||||
activeTab: {
|
||||
borderBottomColor: KurdistanColors.kesk,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
},
|
||||
activeTabText: {
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingTop: 0,
|
||||
},
|
||||
offerCard: {
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
sellerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 16,
|
||||
paddingBottom: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F0F0F0',
|
||||
},
|
||||
sellerInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
sellerAvatar: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
sellerAvatarText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
sellerDetails: {
|
||||
marginLeft: 12,
|
||||
},
|
||||
sellerName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#000',
|
||||
marginBottom: 4,
|
||||
},
|
||||
reputationRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
tradesCount: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
verifiedBadge: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
verifiedIcon: {
|
||||
fontSize: 14,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '700',
|
||||
},
|
||||
offerDetails: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
detailRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
detailLabel: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
detailValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#000',
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: 16,
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
tradeButton: {
|
||||
marginTop: 8,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
paddingTop: 20,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 40,
|
||||
maxHeight: '90%',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
paddingBottom: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E0E0E0',
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
},
|
||||
modalClose: {
|
||||
fontSize: 24,
|
||||
color: '#666',
|
||||
fontWeight: '600',
|
||||
},
|
||||
modalSection: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
modalSectionTitle: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
modalAddress: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#000',
|
||||
},
|
||||
priceSection: {
|
||||
backgroundColor: '#F5F5F5',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
},
|
||||
priceRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
priceLabel: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
priceValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#000',
|
||||
marginBottom: 8,
|
||||
},
|
||||
modalInput: {
|
||||
backgroundColor: '#F5F5F5',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
fontSize: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E0E0E0',
|
||||
},
|
||||
inputHint: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
},
|
||||
calculationSection: {
|
||||
backgroundColor: 'rgba(0, 169, 79, 0.1)',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0, 169, 79, 0.3)',
|
||||
},
|
||||
calculationLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: 4,
|
||||
},
|
||||
calculationValue: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
tradeModalButton: {
|
||||
marginTop: 20,
|
||||
},
|
||||
comingSoonContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 40,
|
||||
},
|
||||
comingSoonIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16,
|
||||
},
|
||||
comingSoonTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
marginBottom: 12,
|
||||
},
|
||||
comingSoonText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
lineHeight: 20,
|
||||
},
|
||||
comingSoonButton: {
|
||||
minWidth: 120,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
|
||||
interface PoolInfo {
|
||||
id: string;
|
||||
asset1: number;
|
||||
asset2: number;
|
||||
asset1Symbol: string;
|
||||
asset2Symbol: string;
|
||||
asset1Decimals: number;
|
||||
asset2Decimals: number;
|
||||
reserve1: string;
|
||||
reserve2: string;
|
||||
feeRate?: string;
|
||||
volume24h?: string;
|
||||
apr7d?: string;
|
||||
}
|
||||
|
||||
const PoolBrowserScreen: React.FC = () => {
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
|
||||
const [pools, setPools] = useState<PoolInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const fetchPools = async () => {
|
||||
if (!api || !isApiReady) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch all pools from chain
|
||||
const poolsEntries = await api.query.assetConversion.pools.entries();
|
||||
|
||||
const poolsData: PoolInfo[] = [];
|
||||
|
||||
for (const [key, value] of poolsEntries) {
|
||||
const poolAccount = value.toString();
|
||||
|
||||
// Parse pool assets from key
|
||||
const keyData = key.toHuman() as any;
|
||||
const assets = keyData[0];
|
||||
|
||||
if (!assets || assets.length !== 2) continue;
|
||||
|
||||
const asset1 = parseInt(assets[0]);
|
||||
const asset2 = parseInt(assets[1]);
|
||||
|
||||
// Fetch metadata for both assets
|
||||
let asset1Symbol = asset1 === 0 ? 'wHEZ' : 'Unknown';
|
||||
let asset2Symbol = asset2 === 0 ? 'wHEZ' : 'Unknown';
|
||||
let asset1Decimals = 12;
|
||||
let asset2Decimals = 12;
|
||||
|
||||
try {
|
||||
if (asset1 !== 0) {
|
||||
const metadata1 = await api.query.assets.metadata(asset1);
|
||||
const meta1 = metadata1.toJSON() as any;
|
||||
asset1Symbol = meta1.symbol || `Asset ${asset1}`;
|
||||
asset1Decimals = meta1.decimals || 12;
|
||||
}
|
||||
|
||||
if (asset2 !== 0) {
|
||||
const metadata2 = await api.query.assets.metadata(asset2);
|
||||
const meta2 = metadata2.toJSON() as any;
|
||||
asset2Symbol = meta2.symbol || `Asset ${asset2}`;
|
||||
asset2Decimals = meta2.decimals || 12;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch asset metadata:', error);
|
||||
}
|
||||
|
||||
// Fetch pool reserves
|
||||
let reserve1 = '0';
|
||||
let reserve2 = '0';
|
||||
|
||||
try {
|
||||
if (asset1 === 0) {
|
||||
// Native token (wHEZ)
|
||||
const balance1 = await api.query.system.account(poolAccount);
|
||||
reserve1 = balance1.data.free.toString();
|
||||
} else {
|
||||
const balance1 = await api.query.assets.account(asset1, poolAccount);
|
||||
reserve1 = balance1.isSome ? balance1.unwrap().balance.toString() : '0';
|
||||
}
|
||||
|
||||
if (asset2 === 0) {
|
||||
const balance2 = await api.query.system.account(poolAccount);
|
||||
reserve2 = balance2.data.free.toString();
|
||||
} else {
|
||||
const balance2 = await api.query.assets.account(asset2, poolAccount);
|
||||
reserve2 = balance2.isSome ? balance2.unwrap().balance.toString() : '0';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch reserves:', error);
|
||||
}
|
||||
|
||||
poolsData.push({
|
||||
id: `${asset1}-${asset2}`,
|
||||
asset1,
|
||||
asset2,
|
||||
asset1Symbol,
|
||||
asset2Symbol,
|
||||
asset1Decimals,
|
||||
asset2Decimals,
|
||||
reserve1,
|
||||
reserve2,
|
||||
feeRate: '0.3', // 0.3% default
|
||||
volume24h: 'N/A',
|
||||
apr7d: 'N/A',
|
||||
});
|
||||
}
|
||||
|
||||
setPools(poolsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load pools:', error);
|
||||
Alert.alert('Error', 'Failed to load liquidity pools');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPools();
|
||||
|
||||
// Refresh pools every 10 seconds
|
||||
const interval = setInterval(fetchPools, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [api, isApiReady]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchPools();
|
||||
};
|
||||
|
||||
const filteredPools = pools.filter((pool) => {
|
||||
if (!searchTerm) return true;
|
||||
const search = searchTerm.toLowerCase();
|
||||
return (
|
||||
pool.asset1Symbol.toLowerCase().includes(search) ||
|
||||
pool.asset2Symbol.toLowerCase().includes(search) ||
|
||||
pool.id.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
const formatBalance = (balance: string, decimals: number): string => {
|
||||
return (Number(balance) / Math.pow(10, decimals)).toFixed(2);
|
||||
};
|
||||
|
||||
const calculateExchangeRate = (pool: PoolInfo): string => {
|
||||
const reserve1Num = Number(pool.reserve1);
|
||||
const reserve2Num = Number(pool.reserve2);
|
||||
|
||||
if (reserve1Num === 0) return '0';
|
||||
|
||||
const rate = reserve2Num / reserve1Num;
|
||||
return rate.toFixed(4);
|
||||
};
|
||||
|
||||
const handleAddLiquidity = (pool: PoolInfo) => {
|
||||
Alert.alert('Add Liquidity', `Adding liquidity to ${pool.asset1Symbol}/${pool.asset2Symbol} pool`);
|
||||
// TODO: Navigate to AddLiquidityModal
|
||||
};
|
||||
|
||||
const handleRemoveLiquidity = (pool: PoolInfo) => {
|
||||
Alert.alert('Remove Liquidity', `Removing liquidity from ${pool.asset1Symbol}/${pool.asset2Symbol} pool`);
|
||||
// TODO: Navigate to RemoveLiquidityModal
|
||||
};
|
||||
|
||||
const handleSwap = (pool: PoolInfo) => {
|
||||
Alert.alert('Swap', `Swapping in ${pool.asset1Symbol}/${pool.asset2Symbol} pool`);
|
||||
// TODO: Navigate to SwapScreen with pool pre-selected
|
||||
};
|
||||
|
||||
if (loading && pools.length === 0) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.centerContent}>
|
||||
<ActivityIndicator size="large" color={KurdistanColors.kesk} />
|
||||
<Text style={styles.loadingText}>Loading liquidity pools...</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<ScrollView
|
||||
style={styles.scrollContent}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Liquidity Pools</Text>
|
||||
</View>
|
||||
|
||||
{/* Search Bar */}
|
||||
<View style={styles.searchContainer}>
|
||||
<Text style={styles.searchIcon}>🔍</Text>
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Search pools by token..."
|
||||
placeholderTextColor="#999"
|
||||
value={searchTerm}
|
||||
onChangeText={setSearchTerm}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Pools List */}
|
||||
{filteredPools.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyIcon}>💧</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
{searchTerm
|
||||
? 'No pools found matching your search'
|
||||
: 'No liquidity pools available yet'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.poolsList}>
|
||||
{filteredPools.map((pool) => (
|
||||
<View key={pool.id} style={styles.poolCard}>
|
||||
{/* Pool Header */}
|
||||
<View style={styles.poolHeader}>
|
||||
<View style={styles.poolTitleRow}>
|
||||
<Text style={styles.poolAsset1}>{pool.asset1Symbol}</Text>
|
||||
<Text style={styles.poolSeparator}>/</Text>
|
||||
<Text style={styles.poolAsset2}>{pool.asset2Symbol}</Text>
|
||||
</View>
|
||||
<View style={styles.activeBadge}>
|
||||
<Text style={styles.activeBadgeText}>Active</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Reserves */}
|
||||
<View style={styles.reservesSection}>
|
||||
<View style={styles.reserveRow}>
|
||||
<Text style={styles.reserveLabel}>Reserve {pool.asset1Symbol}</Text>
|
||||
<Text style={styles.reserveValue}>
|
||||
{formatBalance(pool.reserve1, pool.asset1Decimals)} {pool.asset1Symbol}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.reserveRow}>
|
||||
<Text style={styles.reserveLabel}>Reserve {pool.asset2Symbol}</Text>
|
||||
<Text style={styles.reserveValue}>
|
||||
{formatBalance(pool.reserve2, pool.asset2Decimals)} {pool.asset2Symbol}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Exchange Rate */}
|
||||
<View style={styles.exchangeRateCard}>
|
||||
<Text style={styles.exchangeRateLabel}>Exchange Rate</Text>
|
||||
<Text style={styles.exchangeRateValue}>
|
||||
1 {pool.asset1Symbol} = {calculateExchangeRate(pool)} {pool.asset2Symbol}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Stats Row */}
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statBox}>
|
||||
<Text style={styles.statLabel}>Fee</Text>
|
||||
<Text style={styles.statValue}>{pool.feeRate}%</Text>
|
||||
</View>
|
||||
<View style={styles.statBox}>
|
||||
<Text style={styles.statLabel}>Volume 24h</Text>
|
||||
<Text style={styles.statValue}>{pool.volume24h}</Text>
|
||||
</View>
|
||||
<View style={styles.statBox}>
|
||||
<Text style={styles.statLabel}>APR</Text>
|
||||
<Text style={[styles.statValue, styles.statValuePositive]}>
|
||||
{pool.apr7d}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={styles.actionButtons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.addButton]}
|
||||
onPress={() => handleAddLiquidity(pool)}
|
||||
>
|
||||
<Text style={styles.actionButtonText}>💧 Add</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.removeButton]}
|
||||
onPress={() => handleRemoveLiquidity(pool)}
|
||||
>
|
||||
<Text style={styles.actionButtonText}>Remove</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.swapButton]}
|
||||
onPress={() => handleSwap(pool)}
|
||||
>
|
||||
<Text style={styles.actionButtonText}>📈 Swap</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8F9FA',
|
||||
},
|
||||
centerContent: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 40,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
scrollContent: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
padding: 20,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFFFFF',
|
||||
marginHorizontal: 20,
|
||||
marginBottom: 20,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
boxShadow: '0px 1px 4px rgba(0, 0, 0, 0.05)',
|
||||
elevation: 2,
|
||||
},
|
||||
searchIcon: {
|
||||
fontSize: 18,
|
||||
marginRight: 8,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
},
|
||||
emptyContainer: {
|
||||
padding: 40,
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
poolsList: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
},
|
||||
poolCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.05)',
|
||||
elevation: 2,
|
||||
},
|
||||
poolHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
poolTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
poolAsset1: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
poolSeparator: {
|
||||
fontSize: 18,
|
||||
color: '#999',
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
poolAsset2: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#F59E0B',
|
||||
},
|
||||
activeBadge: {
|
||||
backgroundColor: 'rgba(0, 143, 67, 0.1)',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0, 143, 67, 0.3)',
|
||||
},
|
||||
activeBadgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
reservesSection: {
|
||||
gap: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
reserveRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
reserveLabel: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
reserveValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
exchangeRateCard: {
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
exchangeRateLabel: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
exchangeRateValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#3B82F6',
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
paddingTop: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E5E5E5',
|
||||
marginBottom: 16,
|
||||
},
|
||||
statBox: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 11,
|
||||
color: '#999',
|
||||
marginBottom: 4,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
statValuePositive: {
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
actionButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
},
|
||||
addButton: {
|
||||
backgroundColor: 'rgba(0, 143, 67, 0.1)',
|
||||
borderColor: 'rgba(0, 143, 67, 0.3)',
|
||||
},
|
||||
removeButton: {
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
borderColor: 'rgba(239, 68, 68, 0.3)',
|
||||
},
|
||||
swapButton: {
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
borderColor: 'rgba(59, 130, 246, 0.3)',
|
||||
},
|
||||
actionButtonText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
});
|
||||
|
||||
export default PoolBrowserScreen;
|
||||
@@ -0,0 +1,533 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
|
||||
interface PoolInfo {
|
||||
id: string;
|
||||
asset1: number;
|
||||
asset2: number;
|
||||
asset1Symbol: string;
|
||||
asset2Symbol: string;
|
||||
asset1Decimals: number;
|
||||
asset2Decimals: number;
|
||||
reserve1: string;
|
||||
reserve2: string;
|
||||
feeRate?: string;
|
||||
volume24h?: string;
|
||||
apr7d?: string;
|
||||
}
|
||||
|
||||
const PoolBrowserScreen: React.FC = () => {
|
||||
const { api, isApiReady } = usePezkuwi();
|
||||
|
||||
const [pools, setPools] = useState<PoolInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const fetchPools = async () => {
|
||||
if (!api || !isApiReady) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch all pools from chain
|
||||
const poolsEntries = await api.query.assetConversion.pools.entries();
|
||||
|
||||
const poolsData: PoolInfo[] = [];
|
||||
|
||||
for (const [key, value] of poolsEntries) {
|
||||
const poolAccount = value.toString();
|
||||
|
||||
// Parse pool assets from key
|
||||
const keyData = key.toHuman() as any;
|
||||
const assets = keyData[0];
|
||||
|
||||
if (!assets || assets.length !== 2) continue;
|
||||
|
||||
const asset1 = parseInt(assets[0]);
|
||||
const asset2 = parseInt(assets[1]);
|
||||
|
||||
// Fetch metadata for both assets
|
||||
let asset1Symbol = asset1 === 0 ? 'wHEZ' : 'Unknown';
|
||||
let asset2Symbol = asset2 === 0 ? 'wHEZ' : 'Unknown';
|
||||
let asset1Decimals = 12;
|
||||
let asset2Decimals = 12;
|
||||
|
||||
try {
|
||||
if (asset1 !== 0) {
|
||||
const metadata1 = await api.query.assets.metadata(asset1);
|
||||
const meta1 = metadata1.toJSON() as any;
|
||||
asset1Symbol = meta1.symbol || `Asset ${asset1}`;
|
||||
asset1Decimals = meta1.decimals || 12;
|
||||
}
|
||||
|
||||
if (asset2 !== 0) {
|
||||
const metadata2 = await api.query.assets.metadata(asset2);
|
||||
const meta2 = metadata2.toJSON() as any;
|
||||
asset2Symbol = meta2.symbol || `Asset ${asset2}`;
|
||||
asset2Decimals = meta2.decimals || 12;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch asset metadata:', error);
|
||||
}
|
||||
|
||||
// Fetch pool reserves
|
||||
let reserve1 = '0';
|
||||
let reserve2 = '0';
|
||||
|
||||
try {
|
||||
if (asset1 === 0) {
|
||||
// Native token (wHEZ)
|
||||
const balance1 = await api.query.system.account(poolAccount);
|
||||
reserve1 = balance1.data.free.toString();
|
||||
} else {
|
||||
const balance1 = await api.query.assets.account(asset1, poolAccount);
|
||||
reserve1 = balance1.isSome ? balance1.unwrap().balance.toString() : '0';
|
||||
}
|
||||
|
||||
if (asset2 === 0) {
|
||||
const balance2 = await api.query.system.account(poolAccount);
|
||||
reserve2 = balance2.data.free.toString();
|
||||
} else {
|
||||
const balance2 = await api.query.assets.account(asset2, poolAccount);
|
||||
reserve2 = balance2.isSome ? balance2.unwrap().balance.toString() : '0';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch reserves:', error);
|
||||
}
|
||||
|
||||
poolsData.push({
|
||||
id: `${asset1}-${asset2}`,
|
||||
asset1,
|
||||
asset2,
|
||||
asset1Symbol,
|
||||
asset2Symbol,
|
||||
asset1Decimals,
|
||||
asset2Decimals,
|
||||
reserve1,
|
||||
reserve2,
|
||||
feeRate: '0.3', // 0.3% default
|
||||
volume24h: 'N/A',
|
||||
apr7d: 'N/A',
|
||||
});
|
||||
}
|
||||
|
||||
setPools(poolsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load pools:', error);
|
||||
Alert.alert('Error', 'Failed to load liquidity pools');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPools();
|
||||
|
||||
// Refresh pools every 10 seconds
|
||||
const interval = setInterval(fetchPools, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [api, isApiReady]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchPools();
|
||||
};
|
||||
|
||||
const filteredPools = pools.filter((pool) => {
|
||||
if (!searchTerm) return true;
|
||||
const search = searchTerm.toLowerCase();
|
||||
return (
|
||||
pool.asset1Symbol.toLowerCase().includes(search) ||
|
||||
pool.asset2Symbol.toLowerCase().includes(search) ||
|
||||
pool.id.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
const formatBalance = (balance: string, decimals: number): string => {
|
||||
return (Number(balance) / Math.pow(10, decimals)).toFixed(2);
|
||||
};
|
||||
|
||||
const calculateExchangeRate = (pool: PoolInfo): string => {
|
||||
const reserve1Num = Number(pool.reserve1);
|
||||
const reserve2Num = Number(pool.reserve2);
|
||||
|
||||
if (reserve1Num === 0) return '0';
|
||||
|
||||
const rate = reserve2Num / reserve1Num;
|
||||
return rate.toFixed(4);
|
||||
};
|
||||
|
||||
const handleAddLiquidity = (pool: PoolInfo) => {
|
||||
Alert.alert('Add Liquidity', `Adding liquidity to ${pool.asset1Symbol}/${pool.asset2Symbol} pool`);
|
||||
// TODO: Navigate to AddLiquidityModal
|
||||
};
|
||||
|
||||
const handleRemoveLiquidity = (pool: PoolInfo) => {
|
||||
Alert.alert('Remove Liquidity', `Removing liquidity from ${pool.asset1Symbol}/${pool.asset2Symbol} pool`);
|
||||
// TODO: Navigate to RemoveLiquidityModal
|
||||
};
|
||||
|
||||
const handleSwap = (pool: PoolInfo) => {
|
||||
Alert.alert('Swap', `Swapping in ${pool.asset1Symbol}/${pool.asset2Symbol} pool`);
|
||||
// TODO: Navigate to SwapScreen with pool pre-selected
|
||||
};
|
||||
|
||||
if (loading && pools.length === 0) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.centerContent}>
|
||||
<ActivityIndicator size="large" color={KurdistanColors.kesk} />
|
||||
<Text style={styles.loadingText}>Loading liquidity pools...</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<ScrollView
|
||||
style={styles.scrollContent}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Liquidity Pools</Text>
|
||||
</View>
|
||||
|
||||
{/* Search Bar */}
|
||||
<View style={styles.searchContainer}>
|
||||
<Text style={styles.searchIcon}>🔍</Text>
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Search pools by token..."
|
||||
placeholderTextColor="#999"
|
||||
value={searchTerm}
|
||||
onChangeText={setSearchTerm}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Pools List */}
|
||||
{filteredPools.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyIcon}>💧</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
{searchTerm
|
||||
? 'No pools found matching your search'
|
||||
: 'No liquidity pools available yet'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.poolsList}>
|
||||
{filteredPools.map((pool) => (
|
||||
<View key={pool.id} style={styles.poolCard}>
|
||||
{/* Pool Header */}
|
||||
<View style={styles.poolHeader}>
|
||||
<View style={styles.poolTitleRow}>
|
||||
<Text style={styles.poolAsset1}>{pool.asset1Symbol}</Text>
|
||||
<Text style={styles.poolSeparator}>/</Text>
|
||||
<Text style={styles.poolAsset2}>{pool.asset2Symbol}</Text>
|
||||
</View>
|
||||
<View style={styles.activeBadge}>
|
||||
<Text style={styles.activeBadgeText}>Active</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Reserves */}
|
||||
<View style={styles.reservesSection}>
|
||||
<View style={styles.reserveRow}>
|
||||
<Text style={styles.reserveLabel}>Reserve {pool.asset1Symbol}</Text>
|
||||
<Text style={styles.reserveValue}>
|
||||
{formatBalance(pool.reserve1, pool.asset1Decimals)} {pool.asset1Symbol}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.reserveRow}>
|
||||
<Text style={styles.reserveLabel}>Reserve {pool.asset2Symbol}</Text>
|
||||
<Text style={styles.reserveValue}>
|
||||
{formatBalance(pool.reserve2, pool.asset2Decimals)} {pool.asset2Symbol}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Exchange Rate */}
|
||||
<View style={styles.exchangeRateCard}>
|
||||
<Text style={styles.exchangeRateLabel}>Exchange Rate</Text>
|
||||
<Text style={styles.exchangeRateValue}>
|
||||
1 {pool.asset1Symbol} = {calculateExchangeRate(pool)} {pool.asset2Symbol}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Stats Row */}
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statBox}>
|
||||
<Text style={styles.statLabel}>Fee</Text>
|
||||
<Text style={styles.statValue}>{pool.feeRate}%</Text>
|
||||
</View>
|
||||
<View style={styles.statBox}>
|
||||
<Text style={styles.statLabel}>Volume 24h</Text>
|
||||
<Text style={styles.statValue}>{pool.volume24h}</Text>
|
||||
</View>
|
||||
<View style={styles.statBox}>
|
||||
<Text style={styles.statLabel}>APR</Text>
|
||||
<Text style={[styles.statValue, styles.statValuePositive]}>
|
||||
{pool.apr7d}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={styles.actionButtons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.addButton]}
|
||||
onPress={() => handleAddLiquidity(pool)}
|
||||
>
|
||||
<Text style={styles.actionButtonText}>💧 Add</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.removeButton]}
|
||||
onPress={() => handleRemoveLiquidity(pool)}
|
||||
>
|
||||
<Text style={styles.actionButtonText}>Remove</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.swapButton]}
|
||||
onPress={() => handleSwap(pool)}
|
||||
>
|
||||
<Text style={styles.actionButtonText}>📈 Swap</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8F9FA',
|
||||
},
|
||||
centerContent: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 40,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
scrollContent: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
padding: 20,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFFFFF',
|
||||
marginHorizontal: 20,
|
||||
marginBottom: 20,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
searchIcon: {
|
||||
fontSize: 18,
|
||||
marginRight: 8,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
},
|
||||
emptyContainer: {
|
||||
padding: 40,
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
poolsList: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
},
|
||||
poolCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
poolHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
poolTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
poolAsset1: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
poolSeparator: {
|
||||
fontSize: 18,
|
||||
color: '#999',
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
poolAsset2: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#F59E0B',
|
||||
},
|
||||
activeBadge: {
|
||||
backgroundColor: 'rgba(0, 143, 67, 0.1)',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0, 143, 67, 0.3)',
|
||||
},
|
||||
activeBadgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
reservesSection: {
|
||||
gap: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
reserveRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
reserveLabel: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
reserveValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
exchangeRateCard: {
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
exchangeRateLabel: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
exchangeRateValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#3B82F6',
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
paddingTop: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E5E5E5',
|
||||
marginBottom: 16,
|
||||
},
|
||||
statBox: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 11,
|
||||
color: '#999',
|
||||
marginBottom: 4,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
statValuePositive: {
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
actionButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
},
|
||||
addButton: {
|
||||
backgroundColor: 'rgba(0, 143, 67, 0.1)',
|
||||
borderColor: 'rgba(0, 143, 67, 0.3)',
|
||||
},
|
||||
removeButton: {
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
borderColor: 'rgba(239, 68, 68, 0.3)',
|
||||
},
|
||||
swapButton: {
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
borderColor: 'rgba(59, 130, 246, 0.3)',
|
||||
},
|
||||
actionButtonText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
});
|
||||
|
||||
export default PoolBrowserScreen;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -7,152 +7,242 @@ import {
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { languages } from '../i18n';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import AvatarPickerModal from '../components/AvatarPickerModal';
|
||||
|
||||
interface SettingsScreenProps {
|
||||
onBack: () => void;
|
||||
onLogout: () => void;
|
||||
// Avatar pool matching AvatarPickerModal
|
||||
const AVATAR_POOL = [
|
||||
{ id: 'avatar1', emoji: '👨🏻' },
|
||||
{ id: 'avatar2', emoji: '👨🏼' },
|
||||
{ id: 'avatar3', emoji: '👨🏽' },
|
||||
{ id: 'avatar4', emoji: '👨🏾' },
|
||||
{ id: 'avatar5', emoji: '👩🏻' },
|
||||
{ id: 'avatar6', emoji: '👩🏼' },
|
||||
{ id: 'avatar7', emoji: '👩🏽' },
|
||||
{ id: 'avatar8', emoji: '👩🏾' },
|
||||
{ id: 'avatar9', emoji: '🧔🏻' },
|
||||
{ id: 'avatar10', emoji: '🧔🏼' },
|
||||
{ id: 'avatar11', emoji: '🧔🏽' },
|
||||
{ id: 'avatar12', emoji: '🧔🏾' },
|
||||
{ id: 'avatar13', emoji: '👳🏻♂️' },
|
||||
{ id: 'avatar14', emoji: '👳🏼♂️' },
|
||||
{ id: 'avatar15', emoji: '👳🏽♂️' },
|
||||
{ id: 'avatar16', emoji: '🧕🏻' },
|
||||
{ id: 'avatar17', emoji: '🧕🏼' },
|
||||
{ id: 'avatar18', emoji: '🧕🏽' },
|
||||
{ id: 'avatar19', emoji: '👴🏻' },
|
||||
{ id: 'avatar20', emoji: '👴🏼' },
|
||||
{ id: 'avatar21', emoji: '👵🏻' },
|
||||
{ id: 'avatar22', emoji: '👵🏼' },
|
||||
{ id: 'avatar23', emoji: '👦🏻' },
|
||||
{ id: 'avatar24', emoji: '👦🏼' },
|
||||
{ id: 'avatar25', emoji: '👧🏻' },
|
||||
{ id: 'avatar26', emoji: '👧🏼' },
|
||||
];
|
||||
|
||||
// Helper function to get emoji from avatar ID
|
||||
const getEmojiFromAvatarId = (avatarId: string): string => {
|
||||
const avatar = AVATAR_POOL.find(a => a.id === avatarId);
|
||||
return avatar ? avatar.emoji : '👤'; // Default to person emoji if not found
|
||||
};
|
||||
|
||||
interface ProfileData {
|
||||
full_name: string | null;
|
||||
avatar_url: string | null;
|
||||
wallet_address: string | null;
|
||||
created_at: string;
|
||||
referral_code: string | null;
|
||||
referral_count: number;
|
||||
}
|
||||
|
||||
const SettingsScreen: React.FC<SettingsScreenProps> = ({ onBack, onLogout }) => {
|
||||
const ProfileScreen: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { currentLanguage, changeLanguage } = useLanguage();
|
||||
const navigation = useNavigation();
|
||||
const { user, signOut } = useAuth();
|
||||
const [profileData, setProfileData] = useState<ProfileData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [avatarModalVisible, setAvatarModalVisible] = useState(false);
|
||||
|
||||
const handleLanguageChange = async (languageCode: string) => {
|
||||
if (languageCode === currentLanguage) return;
|
||||
useEffect(() => {
|
||||
fetchProfileData();
|
||||
}, [user]);
|
||||
|
||||
Alert.alert(
|
||||
'Change Language',
|
||||
`Switch to ${languages.find(l => l.code === languageCode)?.nativeName}?`,
|
||||
[
|
||||
{ text: t('common.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('common.confirm'),
|
||||
onPress: async () => {
|
||||
await changeLanguage(languageCode);
|
||||
Alert.alert(
|
||||
t('common.success'),
|
||||
'Language updated successfully! The app will now use your selected language.'
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
const fetchProfileData = async () => {
|
||||
if (!user) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error} = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setProfileData(data);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error fetching profile:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
Alert.alert(
|
||||
t('settings.logout'),
|
||||
'Logout',
|
||||
'Are you sure you want to logout?',
|
||||
[
|
||||
{ text: t('common.cancel'), style: 'cancel' },
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: t('settings.logout'),
|
||||
text: 'Logout',
|
||||
style: 'destructive',
|
||||
onPress: onLogout,
|
||||
onPress: async () => {
|
||||
await signOut();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleAvatarSelected = (avatarUrl: string) => {
|
||||
setProfileData(prev => prev ? { ...prev, avatar_url: avatarUrl } : null);
|
||||
};
|
||||
|
||||
const ProfileCard = ({ icon, title, value, onPress }: { icon: string; title: string; value: string; onPress?: () => void }) => (
|
||||
<TouchableOpacity style={styles.profileCard} onPress={onPress} disabled={!onPress} activeOpacity={onPress ? 0.7 : 1}>
|
||||
<Text style={styles.cardIcon}>{icon}</Text>
|
||||
<View style={styles.cardContent}>
|
||||
<Text style={styles.cardTitle}>{title}</Text>
|
||||
<Text style={styles.cardValue} numberOfLines={1}>{value}</Text>
|
||||
</View>
|
||||
{onPress && <Text style={styles.cardArrow}>→</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" />
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onBack} style={styles.backButton}>
|
||||
<Text style={styles.backButtonText}>←</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>{t('settings.title')}</Text>
|
||||
<View style={styles.placeholder} />
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={KurdistanColors.kesk} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{/* Language Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{t('settings.language')}</Text>
|
||||
{languages.map((language) => (
|
||||
<TouchableOpacity
|
||||
key={language.code}
|
||||
style={[
|
||||
styles.languageItem,
|
||||
currentLanguage === language.code && styles.languageItemActive,
|
||||
]}
|
||||
onPress={() => handleLanguageChange(language.code)}
|
||||
{/* Header with Gradient */}
|
||||
<LinearGradient
|
||||
colors={[KurdistanColors.kesk, '#008f43']}
|
||||
style={styles.header}
|
||||
>
|
||||
<View style={styles.languageInfo}>
|
||||
<Text style={[
|
||||
styles.languageName,
|
||||
currentLanguage === language.code && styles.languageNameActive,
|
||||
]}>
|
||||
{language.nativeName}
|
||||
<View style={styles.avatarContainer}>
|
||||
<TouchableOpacity onPress={() => setAvatarModalVisible(true)} style={styles.avatarWrapper}>
|
||||
{profileData?.avatar_url ? (
|
||||
// Check if avatar_url is a URL (starts with http) or an emoji ID
|
||||
profileData.avatar_url.startsWith('http') ? (
|
||||
<Image source={{ uri: profileData.avatar_url }} style={styles.avatar} />
|
||||
) : (
|
||||
// It's an emoji ID, render as emoji text
|
||||
<View style={styles.avatarPlaceholder}>
|
||||
<Text style={styles.avatarEmojiLarge}>
|
||||
{getEmojiFromAvatarId(profileData.avatar_url)}
|
||||
</Text>
|
||||
<Text style={styles.languageSubtext}>{language.name}</Text>
|
||||
</View>
|
||||
{currentLanguage === language.code && (
|
||||
<View style={styles.checkmark}>
|
||||
<Text style={styles.checkmarkText}>✓</Text>
|
||||
)
|
||||
) : (
|
||||
<View style={styles.avatarPlaceholder}>
|
||||
<Text style={styles.avatarText}>
|
||||
{profileData?.full_name?.charAt(0)?.toUpperCase() || user?.email?.charAt(0)?.toUpperCase() || '?'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.editAvatarButton}>
|
||||
<Text style={styles.editAvatarIcon}>📷</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<Text style={styles.name}>
|
||||
{profileData?.full_name || user?.email?.split('@')[0] || 'User'}
|
||||
</Text>
|
||||
<Text style={styles.email}>{user?.email}</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
|
||||
{/* Profile Info Cards */}
|
||||
<View style={styles.cardsContainer}>
|
||||
<ProfileCard
|
||||
icon="📧"
|
||||
title="Email"
|
||||
value={user?.email || 'N/A'}
|
||||
/>
|
||||
|
||||
<ProfileCard
|
||||
icon="📅"
|
||||
title="Member Since"
|
||||
value={profileData?.created_at ? new Date(profileData.created_at).toLocaleDateString() : 'N/A'}
|
||||
/>
|
||||
|
||||
<ProfileCard
|
||||
icon="👥"
|
||||
title="Referrals"
|
||||
value={`${profileData?.referral_count || 0} people`}
|
||||
onPress={() => (navigation as any).navigate('Referral')}
|
||||
/>
|
||||
|
||||
{profileData?.referral_code && (
|
||||
<ProfileCard
|
||||
icon="🎁"
|
||||
title="Your Referral Code"
|
||||
value={profileData.referral_code}
|
||||
/>
|
||||
)}
|
||||
|
||||
{profileData?.wallet_address && (
|
||||
<ProfileCard
|
||||
icon="👛"
|
||||
title="Wallet Address"
|
||||
value={`${profileData.wallet_address.slice(0, 10)}...${profileData.wallet_address.slice(-8)}`}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Theme Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{t('settings.theme')}</Text>
|
||||
<TouchableOpacity style={styles.settingItem}>
|
||||
<Text style={styles.settingText}>Dark Mode</Text>
|
||||
<Text style={styles.settingValue}>Off</Text>
|
||||
{/* Action Buttons */}
|
||||
<View style={styles.actionsContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => Alert.alert('Coming Soon', 'Edit profile feature will be available soon')}
|
||||
>
|
||||
<Text style={styles.actionIcon}>✏️</Text>
|
||||
<Text style={styles.actionText}>Edit Profile</Text>
|
||||
<Text style={styles.actionArrow}>→</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Notifications Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{t('settings.notifications')}</Text>
|
||||
<TouchableOpacity style={styles.settingItem}>
|
||||
<Text style={styles.settingText}>Push Notifications</Text>
|
||||
<Text style={styles.settingValue}>Enabled</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.settingItem}>
|
||||
<Text style={styles.settingText}>Transaction Alerts</Text>
|
||||
<Text style={styles.settingValue}>Enabled</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Security Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{t('settings.security')}</Text>
|
||||
<TouchableOpacity style={styles.settingItem}>
|
||||
<Text style={styles.settingText}>Biometric Login</Text>
|
||||
<Text style={styles.settingValue}>Disabled</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.settingItem}>
|
||||
<Text style={styles.settingText}>Change Password</Text>
|
||||
<Text style={styles.settingValue}>→</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* About Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{t('settings.about')}</Text>
|
||||
<View style={styles.settingItem}>
|
||||
<Text style={styles.settingText}>Version</Text>
|
||||
<Text style={styles.settingValue}>1.0.0</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.settingItem}>
|
||||
<Text style={styles.settingText}>Terms of Service</Text>
|
||||
<Text style={styles.settingValue}>→</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.settingItem}>
|
||||
<Text style={styles.settingText}>Privacy Policy</Text>
|
||||
<Text style={styles.settingValue}>→</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => Alert.alert(
|
||||
'About Pezkuwi',
|
||||
'Pezkuwi is a decentralized blockchain platform for Digital Kurdistan.\n\nVersion: 1.0.0\n\n© 2026 Digital Kurdistan',
|
||||
[{ text: 'OK' }]
|
||||
)}
|
||||
>
|
||||
<Text style={styles.actionIcon}>ℹ️</Text>
|
||||
<Text style={styles.actionText}>About Pezkuwi</Text>
|
||||
<Text style={styles.actionArrow}>→</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -162,15 +252,24 @@ const SettingsScreen: React.FC<SettingsScreenProps> = ({ onBack, onLogout }) =>
|
||||
onPress={handleLogout}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.logoutButtonText}>{t('settings.logout')}</Text>
|
||||
<Text style={styles.logoutButtonText}>Logout</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>
|
||||
Pezkuwi Blockchain • {new Date().getFullYear()}
|
||||
</Text>
|
||||
<Text style={styles.footerVersion}>Version 1.0.0</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Avatar Picker Modal */}
|
||||
<AvatarPickerModal
|
||||
visible={avatarModalVisible}
|
||||
onClose={() => setAvatarModalVisible(false)}
|
||||
currentAvatar={profileData?.avatar_url || undefined}
|
||||
onAvatarSelected={handleAvatarSelected}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
@@ -180,130 +279,165 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 20,
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E0E0E0',
|
||||
},
|
||||
backButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
backButtonText: {
|
||||
fontSize: 24,
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
placeholder: {
|
||||
width: 40,
|
||||
},
|
||||
section: {
|
||||
marginTop: 20,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#999',
|
||||
marginBottom: 12,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
languageItem: {
|
||||
flexDirection: 'row',
|
||||
header: {
|
||||
paddingTop: 40,
|
||||
paddingBottom: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
borderWidth: 2,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
languageItemActive: {
|
||||
borderColor: KurdistanColors.kesk,
|
||||
backgroundColor: '#F0FAF5',
|
||||
avatarContainer: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
languageInfo: {
|
||||
flex: 1,
|
||||
avatarWrapper: {
|
||||
position: 'relative',
|
||||
marginBottom: 16,
|
||||
},
|
||||
languageName: {
|
||||
avatar: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
borderWidth: 4,
|
||||
borderColor: '#FFFFFF',
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 4,
|
||||
borderColor: '#FFFFFF',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 40,
|
||||
fontWeight: 'bold',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
avatarEmojiLarge: {
|
||||
fontSize: 60,
|
||||
},
|
||||
editAvatarButton: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#FFFFFF',
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.2)',
|
||||
elevation: 4,
|
||||
},
|
||||
editAvatarIcon: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
name: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 4,
|
||||
},
|
||||
languageNameActive: {
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
languageSubtext: {
|
||||
email: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
},
|
||||
checkmark: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
cardsContainer: {
|
||||
padding: 16,
|
||||
},
|
||||
checkmarkText: {
|
||||
color: KurdistanColors.spi,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
settingItem: {
|
||||
profileCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
padding: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
elevation: 2,
|
||||
},
|
||||
settingText: {
|
||||
cardIcon: {
|
||||
fontSize: 32,
|
||||
marginRight: 16,
|
||||
},
|
||||
cardContent: {
|
||||
flex: 1,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginBottom: 4,
|
||||
},
|
||||
cardValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
settingValue: {
|
||||
cardArrow: {
|
||||
fontSize: 20,
|
||||
color: '#999',
|
||||
marginLeft: 8,
|
||||
},
|
||||
actionsContainer: {
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
elevation: 2,
|
||||
},
|
||||
actionIcon: {
|
||||
fontSize: 24,
|
||||
marginRight: 12,
|
||||
},
|
||||
actionText: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
actionArrow: {
|
||||
fontSize: 20,
|
||||
color: '#999',
|
||||
},
|
||||
logoutButton: {
|
||||
backgroundColor: KurdistanColors.sor,
|
||||
margin: 20,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
shadowColor: KurdistanColors.sor,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 6,
|
||||
boxShadow: '0px 4px 6px rgba(255, 0, 0, 0.3)',
|
||||
elevation: 6,
|
||||
},
|
||||
logoutButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
footer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
paddingVertical: 24,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginBottom: 4,
|
||||
},
|
||||
footerVersion: {
|
||||
fontSize: 10,
|
||||
color: '#CCC',
|
||||
},
|
||||
});
|
||||
|
||||
export default SettingsScreen;
|
||||
export default ProfileScreen;
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import AvatarPickerModal from '../components/AvatarPickerModal';
|
||||
|
||||
// Avatar pool matching AvatarPickerModal
|
||||
const AVATAR_POOL = [
|
||||
{ id: 'avatar1', emoji: '👨🏻' },
|
||||
{ id: 'avatar2', emoji: '👨🏼' },
|
||||
{ id: 'avatar3', emoji: '👨🏽' },
|
||||
{ id: 'avatar4', emoji: '👨🏾' },
|
||||
{ id: 'avatar5', emoji: '👩🏻' },
|
||||
{ id: 'avatar6', emoji: '👩🏼' },
|
||||
{ id: 'avatar7', emoji: '👩🏽' },
|
||||
{ id: 'avatar8', emoji: '👩🏾' },
|
||||
{ id: 'avatar9', emoji: '🧔🏻' },
|
||||
{ id: 'avatar10', emoji: '🧔🏼' },
|
||||
{ id: 'avatar11', emoji: '🧔🏽' },
|
||||
{ id: 'avatar12', emoji: '🧔🏾' },
|
||||
{ id: 'avatar13', emoji: '👳🏻♂️' },
|
||||
{ id: 'avatar14', emoji: '👳🏼♂️' },
|
||||
{ id: 'avatar15', emoji: '👳🏽♂️' },
|
||||
{ id: 'avatar16', emoji: '🧕🏻' },
|
||||
{ id: 'avatar17', emoji: '🧕🏼' },
|
||||
{ id: 'avatar18', emoji: '🧕🏽' },
|
||||
{ id: 'avatar19', emoji: '👴🏻' },
|
||||
{ id: 'avatar20', emoji: '👴🏼' },
|
||||
{ id: 'avatar21', emoji: '👵🏻' },
|
||||
{ id: 'avatar22', emoji: '👵🏼' },
|
||||
{ id: 'avatar23', emoji: '👦🏻' },
|
||||
{ id: 'avatar24', emoji: '👦🏼' },
|
||||
{ id: 'avatar25', emoji: '👧🏻' },
|
||||
{ id: 'avatar26', emoji: '👧🏼' },
|
||||
];
|
||||
|
||||
// Helper function to get emoji from avatar ID
|
||||
const getEmojiFromAvatarId = (avatarId: string): string => {
|
||||
const avatar = AVATAR_POOL.find(a => a.id === avatarId);
|
||||
return avatar ? avatar.emoji : '👤'; // Default to person emoji if not found
|
||||
};
|
||||
|
||||
interface ProfileData {
|
||||
full_name: string | null;
|
||||
avatar_url: string | null;
|
||||
wallet_address: string | null;
|
||||
created_at: string;
|
||||
referral_code: string | null;
|
||||
referral_count: number;
|
||||
}
|
||||
|
||||
const ProfileScreen: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const { user, signOut } = useAuth();
|
||||
const [profileData, setProfileData] = useState<ProfileData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [avatarModalVisible, setAvatarModalVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfileData();
|
||||
}, [user]);
|
||||
|
||||
const fetchProfileData = async () => {
|
||||
if (!user) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error} = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setProfileData(data);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error fetching profile:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
Alert.alert(
|
||||
'Logout',
|
||||
'Are you sure you want to logout?',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Logout',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await signOut();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleAvatarSelected = (avatarUrl: string) => {
|
||||
setProfileData(prev => prev ? { ...prev, avatar_url: avatarUrl } : null);
|
||||
};
|
||||
|
||||
const ProfileCard = ({ icon, title, value, onPress }: { icon: string; title: string; value: string; onPress?: () => void }) => (
|
||||
<TouchableOpacity style={styles.profileCard} onPress={onPress} disabled={!onPress} activeOpacity={onPress ? 0.7 : 1}>
|
||||
<Text style={styles.cardIcon}>{icon}</Text>
|
||||
<View style={styles.cardContent}>
|
||||
<Text style={styles.cardTitle}>{title}</Text>
|
||||
<Text style={styles.cardValue} numberOfLines={1}>{value}</Text>
|
||||
</View>
|
||||
{onPress && <Text style={styles.cardArrow}>→</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={KurdistanColors.kesk} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{/* Header with Gradient */}
|
||||
<LinearGradient
|
||||
colors={[KurdistanColors.kesk, '#008f43']}
|
||||
style={styles.header}
|
||||
>
|
||||
<View style={styles.avatarContainer}>
|
||||
<TouchableOpacity onPress={() => setAvatarModalVisible(true)} style={styles.avatarWrapper}>
|
||||
{profileData?.avatar_url ? (
|
||||
// Check if avatar_url is a URL (starts with http) or an emoji ID
|
||||
profileData.avatar_url.startsWith('http') ? (
|
||||
<Image source={{ uri: profileData.avatar_url }} style={styles.avatar} />
|
||||
) : (
|
||||
// It's an emoji ID, render as emoji text
|
||||
<View style={styles.avatarPlaceholder}>
|
||||
<Text style={styles.avatarEmojiLarge}>
|
||||
{getEmojiFromAvatarId(profileData.avatar_url)}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
<View style={styles.avatarPlaceholder}>
|
||||
<Text style={styles.avatarText}>
|
||||
{profileData?.full_name?.charAt(0)?.toUpperCase() || user?.email?.charAt(0)?.toUpperCase() || '?'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.editAvatarButton}>
|
||||
<Text style={styles.editAvatarIcon}>📷</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.name}>
|
||||
{profileData?.full_name || user?.email?.split('@')[0] || 'User'}
|
||||
</Text>
|
||||
<Text style={styles.email}>{user?.email}</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
|
||||
{/* Profile Info Cards */}
|
||||
<View style={styles.cardsContainer}>
|
||||
<ProfileCard
|
||||
icon="📧"
|
||||
title="Email"
|
||||
value={user?.email || 'N/A'}
|
||||
/>
|
||||
|
||||
<ProfileCard
|
||||
icon="📅"
|
||||
title="Member Since"
|
||||
value={profileData?.created_at ? new Date(profileData.created_at).toLocaleDateString() : 'N/A'}
|
||||
/>
|
||||
|
||||
<ProfileCard
|
||||
icon="👥"
|
||||
title="Referrals"
|
||||
value={`${profileData?.referral_count || 0} people`}
|
||||
onPress={() => (navigation as any).navigate('Referral')}
|
||||
/>
|
||||
|
||||
{profileData?.referral_code && (
|
||||
<ProfileCard
|
||||
icon="🎁"
|
||||
title="Your Referral Code"
|
||||
value={profileData.referral_code}
|
||||
/>
|
||||
)}
|
||||
|
||||
{profileData?.wallet_address && (
|
||||
<ProfileCard
|
||||
icon="👛"
|
||||
title="Wallet Address"
|
||||
value={`${profileData.wallet_address.slice(0, 10)}...${profileData.wallet_address.slice(-8)}`}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={styles.actionsContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => Alert.alert('Coming Soon', 'Edit profile feature will be available soon')}
|
||||
>
|
||||
<Text style={styles.actionIcon}>✏️</Text>
|
||||
<Text style={styles.actionText}>Edit Profile</Text>
|
||||
<Text style={styles.actionArrow}>→</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => Alert.alert(
|
||||
'About Pezkuwi',
|
||||
'Pezkuwi is a decentralized blockchain platform for Digital Kurdistan.\n\nVersion: 1.0.0\n\n© 2026 Digital Kurdistan',
|
||||
[{ text: 'OK' }]
|
||||
)}
|
||||
>
|
||||
<Text style={styles.actionIcon}>ℹ️</Text>
|
||||
<Text style={styles.actionText}>About Pezkuwi</Text>
|
||||
<Text style={styles.actionArrow}>→</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Logout Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.logoutButton}
|
||||
onPress={handleLogout}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.logoutButtonText}>Logout</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>
|
||||
Pezkuwi Blockchain • {new Date().getFullYear()}
|
||||
</Text>
|
||||
<Text style={styles.footerVersion}>Version 1.0.0</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Avatar Picker Modal */}
|
||||
<AvatarPickerModal
|
||||
visible={avatarModalVisible}
|
||||
onClose={() => setAvatarModalVisible(false)}
|
||||
currentAvatar={profileData?.avatar_url || undefined}
|
||||
onAvatarSelected={handleAvatarSelected}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
header: {
|
||||
paddingTop: 40,
|
||||
paddingBottom: 30,
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarContainer: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarWrapper: {
|
||||
position: 'relative',
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatar: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
borderWidth: 4,
|
||||
borderColor: '#FFFFFF',
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 4,
|
||||
borderColor: '#FFFFFF',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 40,
|
||||
fontWeight: 'bold',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
avatarEmojiLarge: {
|
||||
fontSize: 60,
|
||||
},
|
||||
editAvatarButton: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#FFFFFF',
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
},
|
||||
editAvatarIcon: {
|
||||
fontSize: 16,
|
||||
},
|
||||
name: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 4,
|
||||
},
|
||||
email: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
},
|
||||
cardsContainer: {
|
||||
padding: 16,
|
||||
},
|
||||
profileCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
cardIcon: {
|
||||
fontSize: 32,
|
||||
marginRight: 16,
|
||||
},
|
||||
cardContent: {
|
||||
flex: 1,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginBottom: 4,
|
||||
},
|
||||
cardValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
cardArrow: {
|
||||
fontSize: 20,
|
||||
color: '#999',
|
||||
marginLeft: 8,
|
||||
},
|
||||
actionsContainer: {
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
actionIcon: {
|
||||
fontSize: 24,
|
||||
marginRight: 12,
|
||||
},
|
||||
actionText: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
actionArrow: {
|
||||
fontSize: 20,
|
||||
color: '#999',
|
||||
},
|
||||
logoutButton: {
|
||||
backgroundColor: KurdistanColors.sor,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
shadowColor: KurdistanColors.sor,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 6,
|
||||
elevation: 6,
|
||||
},
|
||||
logoutButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
footer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 24,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginBottom: 4,
|
||||
},
|
||||
footerVersion: {
|
||||
fontSize: 10,
|
||||
color: '#CCC',
|
||||
},
|
||||
});
|
||||
|
||||
export default ProfileScreen;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -10,17 +10,26 @@ import {
|
||||
Share,
|
||||
Alert,
|
||||
Clipboard,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePolkadot } from '../contexts/PolkadotContext';
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import {
|
||||
getReferralStats,
|
||||
getMyReferrals,
|
||||
calculateReferralScore,
|
||||
type ReferralStats as BlockchainReferralStats,
|
||||
} from '@pezkuwi/lib/referral';
|
||||
|
||||
interface ReferralStats {
|
||||
totalReferrals: number;
|
||||
activeReferrals: number;
|
||||
totalEarned: string;
|
||||
pendingRewards: string;
|
||||
referralScore: number;
|
||||
whoInvitedMe: string | null;
|
||||
}
|
||||
|
||||
interface Referral {
|
||||
@@ -33,28 +42,86 @@ interface Referral {
|
||||
|
||||
const ReferralScreen: React.FC = () => {
|
||||
const { t: _t } = useTranslation();
|
||||
const { selectedAccount, api: _api, connectWallet } = usePolkadot();
|
||||
const { selectedAccount, api, connectWallet, isApiReady } = usePezkuwi();
|
||||
const isConnected = !!selectedAccount;
|
||||
|
||||
// Removed setState in effect - derive from selectedAccount directly
|
||||
// State for blockchain data
|
||||
const [stats, setStats] = useState<ReferralStats>({
|
||||
totalReferrals: 0,
|
||||
activeReferrals: 0,
|
||||
totalEarned: '0.00 HEZ',
|
||||
pendingRewards: '0.00 HEZ',
|
||||
referralScore: 0,
|
||||
whoInvitedMe: null,
|
||||
});
|
||||
const [referrals, setReferrals] = useState<Referral[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Generate referral code from wallet address
|
||||
const referralCode = selectedAccount
|
||||
? `PZK-${selectedAccount.address.slice(0, 8).toUpperCase()}`
|
||||
: 'PZK-CONNECT-WALLET';
|
||||
|
||||
// Mock stats - will be fetched from pallet_referral
|
||||
// TODO: Fetch real stats from blockchain
|
||||
const stats: ReferralStats = {
|
||||
// Fetch referral data from blockchain
|
||||
const fetchReferralData = useCallback(async () => {
|
||||
if (!api || !isApiReady || !selectedAccount) {
|
||||
setStats({
|
||||
totalReferrals: 0,
|
||||
activeReferrals: 0,
|
||||
totalEarned: '0.00 HEZ',
|
||||
pendingRewards: '0.00 HEZ',
|
||||
};
|
||||
referralScore: 0,
|
||||
whoInvitedMe: null,
|
||||
});
|
||||
setReferrals([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mock referrals - will be fetched from blockchain
|
||||
// TODO: Query pallet-trust or referral pallet for actual referrals
|
||||
const referrals: Referral[] = [];
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const [blockchainStats, myReferralsList] = await Promise.all([
|
||||
getReferralStats(api, selectedAccount.address),
|
||||
getMyReferrals(api, selectedAccount.address),
|
||||
]);
|
||||
|
||||
// Calculate rewards (placeholder for now - will be from pallet_rewards)
|
||||
const scoreValue = blockchainStats.referralScore;
|
||||
const earnedAmount = (scoreValue * 0.1).toFixed(2);
|
||||
|
||||
setStats({
|
||||
totalReferrals: blockchainStats.referralCount,
|
||||
activeReferrals: blockchainStats.referralCount,
|
||||
totalEarned: `${earnedAmount} HEZ`,
|
||||
pendingRewards: '0.00 HEZ',
|
||||
referralScore: blockchainStats.referralScore,
|
||||
whoInvitedMe: blockchainStats.whoInvitedMe,
|
||||
});
|
||||
|
||||
// Transform blockchain referrals to UI format
|
||||
const referralData: Referral[] = myReferralsList.map((address, index) => ({
|
||||
id: address,
|
||||
address,
|
||||
joinedDate: 'KYC Completed',
|
||||
status: 'active' as const,
|
||||
earned: `+${index < 10 ? 10 : index < 50 ? 5 : index < 100 ? 4 : 0} points`,
|
||||
}));
|
||||
|
||||
setReferrals(referralData);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error fetching referral data:', error);
|
||||
Alert.alert('Error', 'Failed to load referral data from blockchain');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [api, isApiReady, selectedAccount]);
|
||||
|
||||
// Fetch data on mount and when connection changes
|
||||
useEffect(() => {
|
||||
if (isConnected && api && isApiReady) {
|
||||
fetchReferralData();
|
||||
}
|
||||
}, [isConnected, api, isApiReady, fetchReferralData]);
|
||||
|
||||
const handleConnectWallet = async () => {
|
||||
try {
|
||||
@@ -131,6 +198,13 @@ const ReferralScreen: React.FC = () => {
|
||||
</LinearGradient>
|
||||
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
{loading && (
|
||||
<View style={styles.loadingOverlay}>
|
||||
<ActivityIndicator size="large" color={KurdistanColors.sor} />
|
||||
<Text style={styles.loadingText}>Loading referral data...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Referral Code Card */}
|
||||
<View style={styles.codeCard}>
|
||||
<Text style={styles.codeLabel}>Your Referral Code</Text>
|
||||
@@ -155,6 +229,19 @@ const ReferralScreen: React.FC = () => {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Who Invited Me */}
|
||||
{stats.whoInvitedMe && (
|
||||
<View style={styles.invitedByCard}>
|
||||
<View style={styles.invitedByHeader}>
|
||||
<Text style={styles.invitedByIcon}>🎁</Text>
|
||||
<Text style={styles.invitedByTitle}>You Were Invited By</Text>
|
||||
</View>
|
||||
<Text style={styles.invitedByAddress}>
|
||||
{stats.whoInvitedMe.slice(0, 10)}...{stats.whoInvitedMe.slice(-8)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Stats Grid */}
|
||||
<View style={styles.statsGrid}>
|
||||
<View style={styles.statCard}>
|
||||
@@ -178,6 +265,94 @@ const ReferralScreen: React.FC = () => {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Score Breakdown */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Score Calculation</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
How referrals contribute to your trust score
|
||||
</Text>
|
||||
|
||||
<View style={styles.scoreCard}>
|
||||
<View style={styles.scoreRow}>
|
||||
<Text style={styles.scoreRange}>1-10 referrals</Text>
|
||||
<Text style={[styles.scorePoints, {color: KurdistanColors.kesk}]}>10 points each</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.scoreCard}>
|
||||
<View style={styles.scoreRow}>
|
||||
<Text style={styles.scoreRange}>11-50 referrals</Text>
|
||||
<Text style={[styles.scorePoints, {color: '#3B82F6'}]}>100 + 5 points each</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.scoreCard}>
|
||||
<View style={styles.scoreRow}>
|
||||
<Text style={styles.scoreRange}>51-100 referrals</Text>
|
||||
<Text style={[styles.scorePoints, {color: KurdistanColors.zer}]}>300 + 4 points each</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.scoreCard}>
|
||||
<View style={styles.scoreRow}>
|
||||
<Text style={styles.scoreRange}>101+ referrals</Text>
|
||||
<Text style={[styles.scorePoints, {color: KurdistanColors.sor}]}>500 points (max)</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Leaderboard */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Top Referrers</Text>
|
||||
<Text style={styles.sectionSubtitle}>Community leaderboard</Text>
|
||||
|
||||
<View style={styles.leaderboardCard}>
|
||||
<View style={styles.leaderboardRow}>
|
||||
<View style={styles.leaderboardRank}>
|
||||
<Text style={styles.leaderboardRankText}>🥇</Text>
|
||||
</View>
|
||||
<View style={styles.leaderboardInfo}>
|
||||
<Text style={styles.leaderboardAddress}>5GrwvaEF...KutQY</Text>
|
||||
<Text style={styles.leaderboardStats}>156 referrals</Text>
|
||||
</View>
|
||||
<Text style={styles.leaderboardScore}>500 pts</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.leaderboardCard}>
|
||||
<View style={styles.leaderboardRow}>
|
||||
<View style={styles.leaderboardRank}>
|
||||
<Text style={styles.leaderboardRankText}>🥈</Text>
|
||||
</View>
|
||||
<View style={styles.leaderboardInfo}>
|
||||
<Text style={styles.leaderboardAddress}>5FHneW46...94ty</Text>
|
||||
<Text style={styles.leaderboardStats}>89 referrals</Text>
|
||||
</View>
|
||||
<Text style={styles.leaderboardScore}>456 pts</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.leaderboardCard}>
|
||||
<View style={styles.leaderboardRow}>
|
||||
<View style={styles.leaderboardRank}>
|
||||
<Text style={styles.leaderboardRankText}>🥉</Text>
|
||||
</View>
|
||||
<View style={styles.leaderboardInfo}>
|
||||
<Text style={styles.leaderboardAddress}>5FLSigC9...hXcS59Y</Text>
|
||||
<Text style={styles.leaderboardStats}>67 referrals</Text>
|
||||
</View>
|
||||
<Text style={styles.leaderboardScore}>385 pts</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.leaderboardNote}>
|
||||
<Text style={styles.leaderboardNoteIcon}>ℹ️</Text>
|
||||
<Text style={styles.leaderboardNoteText}>
|
||||
Leaderboard updates every 24 hours. Keep inviting to climb the ranks!
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* How It Works */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>How It Works</Text>
|
||||
@@ -283,10 +458,7 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.3)',
|
||||
elevation: 8,
|
||||
},
|
||||
logoText: {
|
||||
@@ -310,10 +482,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 40,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 6,
|
||||
boxShadow: '0px 4px 6px rgba(0, 0, 0, 0.3)',
|
||||
elevation: 6,
|
||||
},
|
||||
connectButtonText: {
|
||||
@@ -345,10 +514,7 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
elevation: 4,
|
||||
},
|
||||
codeLabel: {
|
||||
@@ -407,10 +573,7 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 6,
|
||||
boxShadow: '0px 2px 6px rgba(0, 0, 0, 0.05)',
|
||||
elevation: 3,
|
||||
},
|
||||
statValue: {
|
||||
@@ -430,18 +593,99 @@ const styles = StyleSheet.create({
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
marginBottom: 8,
|
||||
},
|
||||
sectionSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 16,
|
||||
},
|
||||
scoreCard: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.03)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 8,
|
||||
},
|
||||
scoreRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
scoreRange: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
scorePoints: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
leaderboardCard: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
boxShadow: '0px 2px 6px rgba(0, 0, 0, 0.05)',
|
||||
elevation: 3,
|
||||
},
|
||||
leaderboardRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
leaderboardRank: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#F5F5F5',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
leaderboardRankText: {
|
||||
fontSize: 20,
|
||||
},
|
||||
leaderboardInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
leaderboardAddress: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
marginBottom: 2,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
leaderboardStats: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
leaderboardScore: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
leaderboardNote: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#E0F2FE',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginTop: 8,
|
||||
gap: 8,
|
||||
},
|
||||
leaderboardNoteIcon: {
|
||||
fontSize: 16,
|
||||
},
|
||||
leaderboardNoteText: {
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
color: '#0C4A6E',
|
||||
lineHeight: 18,
|
||||
},
|
||||
stepCard: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 6,
|
||||
boxShadow: '0px 2px 6px rgba(0, 0, 0, 0.05)',
|
||||
elevation: 3,
|
||||
},
|
||||
stepNumber: {
|
||||
@@ -498,10 +742,7 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 6,
|
||||
boxShadow: '0px 2px 6px rgba(0, 0, 0, 0.05)',
|
||||
elevation: 3,
|
||||
},
|
||||
referralInfo: {
|
||||
@@ -538,6 +779,48 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.sor,
|
||||
},
|
||||
loadingOverlay: {
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 12,
|
||||
marginBottom: 20,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
invitedByCard: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: KurdistanColors.kesk,
|
||||
boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
elevation: 4,
|
||||
},
|
||||
invitedByHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
invitedByIcon: {
|
||||
fontSize: 20,
|
||||
marginRight: 8,
|
||||
},
|
||||
invitedByTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
},
|
||||
invitedByAddress: {
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
color: KurdistanColors.kesk,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default ReferralScreen;
|
||||
|
||||
@@ -0,0 +1,850 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
Share,
|
||||
Alert,
|
||||
Clipboard,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import {
|
||||
getReferralStats,
|
||||
getMyReferrals,
|
||||
calculateReferralScore,
|
||||
type ReferralStats as BlockchainReferralStats,
|
||||
} from '@pezkuwi/lib/referral';
|
||||
|
||||
interface ReferralStats {
|
||||
totalReferrals: number;
|
||||
activeReferrals: number;
|
||||
totalEarned: string;
|
||||
pendingRewards: string;
|
||||
referralScore: number;
|
||||
whoInvitedMe: string | null;
|
||||
}
|
||||
|
||||
interface Referral {
|
||||
id: string;
|
||||
address: string;
|
||||
joinedDate: string;
|
||||
status: 'active' | 'pending';
|
||||
earned: string;
|
||||
}
|
||||
|
||||
const ReferralScreen: React.FC = () => {
|
||||
const { t: _t } = useTranslation();
|
||||
const { selectedAccount, api, connectWallet, isApiReady } = usePezkuwi();
|
||||
const isConnected = !!selectedAccount;
|
||||
|
||||
// State for blockchain data
|
||||
const [stats, setStats] = useState<ReferralStats>({
|
||||
totalReferrals: 0,
|
||||
activeReferrals: 0,
|
||||
totalEarned: '0.00 HEZ',
|
||||
pendingRewards: '0.00 HEZ',
|
||||
referralScore: 0,
|
||||
whoInvitedMe: null,
|
||||
});
|
||||
const [referrals, setReferrals] = useState<Referral[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Generate referral code from wallet address
|
||||
const referralCode = selectedAccount
|
||||
? `PZK-${selectedAccount.address.slice(0, 8).toUpperCase()}`
|
||||
: 'PZK-CONNECT-WALLET';
|
||||
|
||||
// Fetch referral data from blockchain
|
||||
const fetchReferralData = useCallback(async () => {
|
||||
if (!api || !isApiReady || !selectedAccount) {
|
||||
setStats({
|
||||
totalReferrals: 0,
|
||||
activeReferrals: 0,
|
||||
totalEarned: '0.00 HEZ',
|
||||
pendingRewards: '0.00 HEZ',
|
||||
referralScore: 0,
|
||||
whoInvitedMe: null,
|
||||
});
|
||||
setReferrals([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const [blockchainStats, myReferralsList] = await Promise.all([
|
||||
getReferralStats(api, selectedAccount.address),
|
||||
getMyReferrals(api, selectedAccount.address),
|
||||
]);
|
||||
|
||||
// Calculate rewards (placeholder for now - will be from pallet_rewards)
|
||||
const scoreValue = blockchainStats.referralScore;
|
||||
const earnedAmount = (scoreValue * 0.1).toFixed(2);
|
||||
|
||||
setStats({
|
||||
totalReferrals: blockchainStats.referralCount,
|
||||
activeReferrals: blockchainStats.referralCount,
|
||||
totalEarned: `${earnedAmount} HEZ`,
|
||||
pendingRewards: '0.00 HEZ',
|
||||
referralScore: blockchainStats.referralScore,
|
||||
whoInvitedMe: blockchainStats.whoInvitedMe,
|
||||
});
|
||||
|
||||
// Transform blockchain referrals to UI format
|
||||
const referralData: Referral[] = myReferralsList.map((address, index) => ({
|
||||
id: address,
|
||||
address,
|
||||
joinedDate: 'KYC Completed',
|
||||
status: 'active' as const,
|
||||
earned: `+${index < 10 ? 10 : index < 50 ? 5 : index < 100 ? 4 : 0} points`,
|
||||
}));
|
||||
|
||||
setReferrals(referralData);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error fetching referral data:', error);
|
||||
Alert.alert('Error', 'Failed to load referral data from blockchain');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [api, isApiReady, selectedAccount]);
|
||||
|
||||
// Fetch data on mount and when connection changes
|
||||
useEffect(() => {
|
||||
if (isConnected && api && isApiReady) {
|
||||
fetchReferralData();
|
||||
}
|
||||
}, [isConnected, api, isApiReady, fetchReferralData]);
|
||||
|
||||
const handleConnectWallet = async () => {
|
||||
try {
|
||||
await connectWallet();
|
||||
Alert.alert('Connected', 'Your wallet has been connected to the referral system!');
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Wallet connection error:', error);
|
||||
Alert.alert('Error', 'Failed to connect wallet. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCode = () => {
|
||||
Clipboard.setString(referralCode);
|
||||
Alert.alert('Copied!', 'Referral code copied to clipboard');
|
||||
};
|
||||
|
||||
const handleShareCode = async () => {
|
||||
try {
|
||||
const result = await Share.share({
|
||||
message: `Join Pezkuwi using my referral code: ${referralCode}\n\nGet rewards for becoming a citizen!`,
|
||||
title: 'Join Pezkuwi',
|
||||
});
|
||||
|
||||
if (result.action === Share.sharedAction) {
|
||||
if (__DEV__) console.warn('Shared successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error sharing:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
<LinearGradient
|
||||
colors={[KurdistanColors.sor, KurdistanColors.zer]}
|
||||
style={styles.connectGradient}
|
||||
>
|
||||
<View style={styles.connectContainer}>
|
||||
<View style={styles.logoContainer}>
|
||||
<Text style={styles.logoText}>🤝</Text>
|
||||
</View>
|
||||
<Text style={styles.connectTitle}>Referral Program</Text>
|
||||
<Text style={styles.connectSubtitle}>
|
||||
Connect your wallet to access your referral dashboard
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.connectButton}
|
||||
onPress={handleConnectWallet}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.connectButtonText}>Connect Wallet</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
|
||||
{/* Header */}
|
||||
<LinearGradient
|
||||
colors={[KurdistanColors.sor, KurdistanColors.zer]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.header}
|
||||
>
|
||||
<Text style={styles.headerTitle}>Referral Program</Text>
|
||||
<Text style={styles.headerSubtitle}>Earn rewards by inviting friends</Text>
|
||||
</LinearGradient>
|
||||
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
{loading && (
|
||||
<View style={styles.loadingOverlay}>
|
||||
<ActivityIndicator size="large" color={KurdistanColors.sor} />
|
||||
<Text style={styles.loadingText}>Loading referral data...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Referral Code Card */}
|
||||
<View style={styles.codeCard}>
|
||||
<Text style={styles.codeLabel}>Your Referral Code</Text>
|
||||
<View style={styles.codeContainer}>
|
||||
<Text style={styles.codeText}>{referralCode}</Text>
|
||||
</View>
|
||||
<View style={styles.codeActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.codeButton, styles.copyButton]}
|
||||
onPress={handleCopyCode}
|
||||
>
|
||||
<Text style={styles.codeButtonIcon}>📋</Text>
|
||||
<Text style={styles.codeButtonText}>Copy</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.codeButton, styles.shareButton]}
|
||||
onPress={handleShareCode}
|
||||
>
|
||||
<Text style={styles.codeButtonIcon}>📤</Text>
|
||||
<Text style={styles.codeButtonText}>Share</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Who Invited Me */}
|
||||
{stats.whoInvitedMe && (
|
||||
<View style={styles.invitedByCard}>
|
||||
<View style={styles.invitedByHeader}>
|
||||
<Text style={styles.invitedByIcon}>🎁</Text>
|
||||
<Text style={styles.invitedByTitle}>You Were Invited By</Text>
|
||||
</View>
|
||||
<Text style={styles.invitedByAddress}>
|
||||
{stats.whoInvitedMe.slice(0, 10)}...{stats.whoInvitedMe.slice(-8)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Stats Grid */}
|
||||
<View style={styles.statsGrid}>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{stats.totalReferrals}</Text>
|
||||
<Text style={styles.statLabel}>Total Referrals</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{stats.activeReferrals}</Text>
|
||||
<Text style={styles.statLabel}>Active</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.statsGrid}>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{stats.totalEarned}</Text>
|
||||
<Text style={styles.statLabel}>Total Earned</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{stats.pendingRewards}</Text>
|
||||
<Text style={styles.statLabel}>Pending</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Score Breakdown */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Score Calculation</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
How referrals contribute to your trust score
|
||||
</Text>
|
||||
|
||||
<View style={styles.scoreCard}>
|
||||
<View style={styles.scoreRow}>
|
||||
<Text style={styles.scoreRange}>1-10 referrals</Text>
|
||||
<Text style={[styles.scorePoints, {color: KurdistanColors.kesk}]}>10 points each</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.scoreCard}>
|
||||
<View style={styles.scoreRow}>
|
||||
<Text style={styles.scoreRange}>11-50 referrals</Text>
|
||||
<Text style={[styles.scorePoints, {color: '#3B82F6'}]}>100 + 5 points each</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.scoreCard}>
|
||||
<View style={styles.scoreRow}>
|
||||
<Text style={styles.scoreRange}>51-100 referrals</Text>
|
||||
<Text style={[styles.scorePoints, {color: KurdistanColors.zer}]}>300 + 4 points each</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.scoreCard}>
|
||||
<View style={styles.scoreRow}>
|
||||
<Text style={styles.scoreRange}>101+ referrals</Text>
|
||||
<Text style={[styles.scorePoints, {color: KurdistanColors.sor}]}>500 points (max)</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Leaderboard */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Top Referrers</Text>
|
||||
<Text style={styles.sectionSubtitle}>Community leaderboard</Text>
|
||||
|
||||
<View style={styles.leaderboardCard}>
|
||||
<View style={styles.leaderboardRow}>
|
||||
<View style={styles.leaderboardRank}>
|
||||
<Text style={styles.leaderboardRankText}>🥇</Text>
|
||||
</View>
|
||||
<View style={styles.leaderboardInfo}>
|
||||
<Text style={styles.leaderboardAddress}>5GrwvaEF...KutQY</Text>
|
||||
<Text style={styles.leaderboardStats}>156 referrals</Text>
|
||||
</View>
|
||||
<Text style={styles.leaderboardScore}>500 pts</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.leaderboardCard}>
|
||||
<View style={styles.leaderboardRow}>
|
||||
<View style={styles.leaderboardRank}>
|
||||
<Text style={styles.leaderboardRankText}>🥈</Text>
|
||||
</View>
|
||||
<View style={styles.leaderboardInfo}>
|
||||
<Text style={styles.leaderboardAddress}>5FHneW46...94ty</Text>
|
||||
<Text style={styles.leaderboardStats}>89 referrals</Text>
|
||||
</View>
|
||||
<Text style={styles.leaderboardScore}>456 pts</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.leaderboardCard}>
|
||||
<View style={styles.leaderboardRow}>
|
||||
<View style={styles.leaderboardRank}>
|
||||
<Text style={styles.leaderboardRankText}>🥉</Text>
|
||||
</View>
|
||||
<View style={styles.leaderboardInfo}>
|
||||
<Text style={styles.leaderboardAddress}>5FLSigC9...hXcS59Y</Text>
|
||||
<Text style={styles.leaderboardStats}>67 referrals</Text>
|
||||
</View>
|
||||
<Text style={styles.leaderboardScore}>385 pts</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.leaderboardNote}>
|
||||
<Text style={styles.leaderboardNoteIcon}>ℹ️</Text>
|
||||
<Text style={styles.leaderboardNoteText}>
|
||||
Leaderboard updates every 24 hours. Keep inviting to climb the ranks!
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* How It Works */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>How It Works</Text>
|
||||
<View style={styles.stepCard}>
|
||||
<View style={styles.stepNumber}>
|
||||
<Text style={styles.stepNumberText}>1</Text>
|
||||
</View>
|
||||
<View style={styles.stepContent}>
|
||||
<Text style={styles.stepTitle}>Share Your Code</Text>
|
||||
<Text style={styles.stepDescription}>
|
||||
Share your unique referral code with friends
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.stepCard}>
|
||||
<View style={styles.stepNumber}>
|
||||
<Text style={styles.stepNumberText}>2</Text>
|
||||
</View>
|
||||
<View style={styles.stepContent}>
|
||||
<Text style={styles.stepTitle}>Friend Joins</Text>
|
||||
<Text style={styles.stepDescription}>
|
||||
They use your code when applying for citizenship
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.stepCard}>
|
||||
<View style={styles.stepNumber}>
|
||||
<Text style={styles.stepNumberText}>3</Text>
|
||||
</View>
|
||||
<View style={styles.stepContent}>
|
||||
<Text style={styles.stepTitle}>Earn Rewards</Text>
|
||||
<Text style={styles.stepDescription}>
|
||||
Get HEZ tokens when they become active citizens
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Referrals List */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Your Referrals</Text>
|
||||
{referrals.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyStateIcon}>👥</Text>
|
||||
<Text style={styles.emptyStateText}>No referrals yet</Text>
|
||||
<Text style={styles.emptyStateSubtext}>
|
||||
Start inviting friends to earn rewards!
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
referrals.map((referral) => (
|
||||
<View key={referral.id} style={styles.referralCard}>
|
||||
<View style={styles.referralInfo}>
|
||||
<Text style={styles.referralAddress}>
|
||||
{referral.address.substring(0, 8)}...
|
||||
{referral.address.substring(referral.address.length - 6)}
|
||||
</Text>
|
||||
<Text style={styles.referralDate}>{referral.joinedDate}</Text>
|
||||
</View>
|
||||
<View style={styles.referralStats}>
|
||||
<Text
|
||||
style={[
|
||||
styles.referralStatus,
|
||||
referral.status === 'active'
|
||||
? styles.statusActive
|
||||
: styles.statusPending,
|
||||
]}
|
||||
>
|
||||
{referral.status}
|
||||
</Text>
|
||||
<Text style={styles.referralEarned}>{referral.earned}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
connectGradient: {
|
||||
flex: 1,
|
||||
},
|
||||
connectContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
logoContainer: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 48,
|
||||
},
|
||||
connectTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
marginBottom: 12,
|
||||
},
|
||||
connectSubtitle: {
|
||||
fontSize: 16,
|
||||
color: KurdistanColors.spi,
|
||||
textAlign: 'center',
|
||||
opacity: 0.9,
|
||||
marginBottom: 40,
|
||||
},
|
||||
connectButton: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
paddingHorizontal: 40,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 6,
|
||||
elevation: 6,
|
||||
},
|
||||
connectButtonText: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.sor,
|
||||
},
|
||||
header: {
|
||||
padding: 20,
|
||||
paddingTop: 40,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
marginBottom: 4,
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.spi,
|
||||
opacity: 0.9,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
codeCard: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
codeLabel: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 12,
|
||||
},
|
||||
codeContainer: {
|
||||
backgroundColor: '#F5F5F5',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
codeText: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.sor,
|
||||
textAlign: 'center',
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
codeActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
codeButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
gap: 8,
|
||||
},
|
||||
copyButton: {
|
||||
backgroundColor: '#F0F0F0',
|
||||
},
|
||||
shareButton: {
|
||||
backgroundColor: KurdistanColors.sor,
|
||||
},
|
||||
codeButtonIcon: {
|
||||
fontSize: 16,
|
||||
},
|
||||
codeButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
statsGrid: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 6,
|
||||
elevation: 3,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.sor,
|
||||
marginBottom: 4,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
section: {
|
||||
marginTop: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
marginBottom: 8,
|
||||
},
|
||||
sectionSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 16,
|
||||
},
|
||||
scoreCard: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.03)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 8,
|
||||
},
|
||||
scoreRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
scoreRange: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
scorePoints: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
leaderboardCard: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 6,
|
||||
elevation: 3,
|
||||
},
|
||||
leaderboardRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
leaderboardRank: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#F5F5F5',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
leaderboardRankText: {
|
||||
fontSize: 20,
|
||||
},
|
||||
leaderboardInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
leaderboardAddress: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
marginBottom: 2,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
leaderboardStats: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
leaderboardScore: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
leaderboardNote: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#E0F2FE',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginTop: 8,
|
||||
gap: 8,
|
||||
},
|
||||
leaderboardNoteIcon: {
|
||||
fontSize: 16,
|
||||
},
|
||||
leaderboardNoteText: {
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
color: '#0C4A6E',
|
||||
lineHeight: 18,
|
||||
},
|
||||
stepCard: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 6,
|
||||
elevation: 3,
|
||||
},
|
||||
stepNumber: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: KurdistanColors.sor,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
},
|
||||
stepNumberText: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
},
|
||||
stepContent: {
|
||||
flex: 1,
|
||||
},
|
||||
stepTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
marginBottom: 4,
|
||||
},
|
||||
stepDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
padding: 40,
|
||||
},
|
||||
emptyStateIcon: {
|
||||
fontSize: 48,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyStateText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyStateSubtext: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
referralCard: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 6,
|
||||
elevation: 3,
|
||||
},
|
||||
referralInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
referralAddress: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
marginBottom: 4,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
referralDate: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
referralStats: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
referralStatus: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
statusActive: {
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
statusPending: {
|
||||
color: KurdistanColors.zer,
|
||||
},
|
||||
referralEarned: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.sor,
|
||||
},
|
||||
loadingOverlay: {
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 12,
|
||||
marginBottom: 20,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
invitedByCard: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: KurdistanColors.kesk,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
invitedByHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
invitedByIcon: {
|
||||
fontSize: 20,
|
||||
marginRight: 8,
|
||||
},
|
||||
invitedByTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
},
|
||||
invitedByAddress: {
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
color: KurdistanColors.kesk,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default ReferralScreen;
|
||||
@@ -179,10 +179,7 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.3)',
|
||||
elevation: 8,
|
||||
},
|
||||
logoText: {
|
||||
@@ -205,10 +202,7 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.2)',
|
||||
elevation: 8,
|
||||
},
|
||||
inputGroup: {
|
||||
@@ -242,10 +236,7 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
shadowColor: KurdistanColors.kesk,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 6,
|
||||
boxShadow: '0px 4px 6px rgba(0, 128, 0, 0.3)',
|
||||
elevation: 6,
|
||||
},
|
||||
signInButtonText: {
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
|
||||
interface SignInScreenProps {
|
||||
onSignIn: () => void;
|
||||
onNavigateToSignUp: () => void;
|
||||
}
|
||||
|
||||
const SignInScreen: React.FC<SignInScreenProps> = ({ onSignIn, onNavigateToSignUp }) => {
|
||||
const { t } = useTranslation();
|
||||
const { signIn } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSignIn = async () => {
|
||||
if (!email || !password) {
|
||||
Alert.alert('Error', 'Please enter both email and password');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { error } = await signIn(email, password);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Sign In Failed', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Success - navigate to app
|
||||
onSignIn();
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'An unexpected error occurred');
|
||||
if (__DEV__) console.error('Sign in error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
<LinearGradient
|
||||
colors={[KurdistanColors.kesk, KurdistanColors.zer]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardView}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.logoContainer}>
|
||||
<Text style={styles.logoText}>PZK</Text>
|
||||
</View>
|
||||
<Text style={styles.title}>{t('auth.welcomeBack')}</Text>
|
||||
<Text style={styles.subtitle}>{t('auth.signIn')}</Text>
|
||||
</View>
|
||||
|
||||
{/* Form */}
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>{t('auth.email')}</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder={t('auth.email')}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
placeholderTextColor="rgba(0, 0, 0, 0.4)"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>{t('auth.password')}</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder={t('auth.password')}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
placeholderTextColor="rgba(0, 0, 0, 0.4)"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.forgotPassword}>
|
||||
<Text style={styles.forgotPasswordText}>
|
||||
{t('auth.forgotPassword')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.signInButton, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleSignIn}
|
||||
activeOpacity={0.8}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color={KurdistanColors.spi} />
|
||||
) : (
|
||||
<Text style={styles.signInButtonText}>{t('auth.signIn')}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.divider}>
|
||||
<View style={styles.dividerLine} />
|
||||
<Text style={styles.dividerText}>or</Text>
|
||||
<View style={styles.dividerLine} />
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.signUpPrompt}
|
||||
onPress={onNavigateToSignUp}
|
||||
>
|
||||
<Text style={styles.signUpPromptText}>
|
||||
{t('auth.noAccount')}{' '}
|
||||
<Text style={styles.signUpLink}>{t('auth.signUp')}</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</LinearGradient>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
},
|
||||
gradient: {
|
||||
flex: 1,
|
||||
},
|
||||
keyboardView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
padding: 20,
|
||||
paddingTop: 60,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 40,
|
||||
},
|
||||
logoContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: KurdistanColors.spi,
|
||||
opacity: 0.9,
|
||||
},
|
||||
form: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#F5F5F5',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
fontSize: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E0E0E0',
|
||||
},
|
||||
forgotPassword: {
|
||||
alignItems: 'flex-end',
|
||||
marginBottom: 24,
|
||||
},
|
||||
forgotPasswordText: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.kesk,
|
||||
fontWeight: '600',
|
||||
},
|
||||
signInButton: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
shadowColor: KurdistanColors.kesk,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 6,
|
||||
elevation: 6,
|
||||
},
|
||||
signInButtonText: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
divider: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginVertical: 24,
|
||||
},
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: '#E0E0E0',
|
||||
},
|
||||
dividerText: {
|
||||
marginHorizontal: 12,
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
},
|
||||
signUpPrompt: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
signUpPromptText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
signUpLink: {
|
||||
color: KurdistanColors.kesk,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
|
||||
export default SignInScreen;
|
||||
@@ -204,10 +204,7 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.3)',
|
||||
elevation: 8,
|
||||
},
|
||||
logoText: {
|
||||
@@ -230,10 +227,7 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.2)',
|
||||
elevation: 8,
|
||||
},
|
||||
inputGroup: {
|
||||
@@ -259,10 +253,7 @@ const styles = StyleSheet.create({
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
shadowColor: KurdistanColors.sor,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 6,
|
||||
boxShadow: '0px 4px 6px rgba(255, 0, 0, 0.3)',
|
||||
elevation: 6,
|
||||
},
|
||||
signUpButtonText: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user