Initial commit - PezkuwiChain Telegram MiniApp

This commit is contained in:
2026-02-05 10:48:14 +03:00
commit ddd28705c1
105 changed files with 29195 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# Supabase Configuration
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
# Telegram Bot (for Edge Functions only)
# TELEGRAM_BOT_TOKEN=your-bot-token
+90
View File
@@ -0,0 +1,90 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run typecheck
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run test:coverage
build:
name: Build
runs-on: ubuntu-latest
needs: [lint, typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy to VPS1
uses: appleboy/scp-action@v1.0.0
with:
host: ${{ secrets.VPS1_HOST }}
username: ${{ secrets.VPS1_USER }}
key: ${{ secrets.VPS1_SSH_KEY }}
source: 'dist/*'
target: '/var/www/telegram.pezkuwichain.io'
strip_components: 1
+101
View File
@@ -0,0 +1,101 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '35 22 * * 3'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- name: Run manual build steps
if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
+54
View File
@@ -0,0 +1,54 @@
# Dependencies
node_modules/
# Build output
dist/
build/
# Environment files
.env
.env.local
.env.production
.env.development
.env.*.local
!.env.example
# Security scripts (contain credentials)
scripts/fix-rls*.mjs
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
desktop.ini
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Testing
coverage/
.nyc_output/
# Cache
.cache/
.eslintcache
*.tsbuildinfo
# Misc
*.local
*.pem
# Supabase
supabase/.branches
supabase/.temp
CLAUDE.md
+3
View File
@@ -0,0 +1,3 @@
node scripts/bump-version.mjs
git add package.json src/version.json
npx lint-staged
+12
View File
@@ -0,0 +1,12 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"endOfLine": "lf"
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Pezkuwichain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+114
View File
@@ -0,0 +1,114 @@
# Pezkuwi Telegram Mini App
Telegram Mini App for Pezkuwichain - Forum, Announcements, Rewards, and Wallet integration.
## Tech Stack
- **Frontend**: React 18 + TypeScript + Vite
- **Styling**: Tailwind CSS + shadcn/ui patterns
- **State**: TanStack Query (React Query)
- **Backend**: Supabase (PostgreSQL + Auth + Edge Functions)
- **Testing**: Vitest + Testing Library
- **CI/CD**: GitHub Actions
## Getting Started
### Prerequisites
- Node.js >= 18
- npm >= 9
- Supabase account
### Installation
```bash
# Clone the repo
git clone https://github.com/pezkuwichain/pezkuwi-telegram-miniapp.git
cd pezkuwi-telegram-miniapp
# Install dependencies
npm install
# Copy environment template
cp .env.example .env
# Add your Supabase credentials to .env
```
### Development
```bash
# Start dev server
npm run dev
# Run tests
npm run test
# Type check
npm run typecheck
# Lint
npm run lint
# Format code
npm run format
# Full validation (typecheck + lint + test + build)
npm run validate
```
### Build
```bash
npm run build
```
Output will be in `dist/` directory.
## Project Structure
```
src/
├── contexts/ # React contexts (Auth)
├── hooks/ # Custom hooks (Supabase, Telegram)
├── lib/ # Utilities (env, supabase client)
├── sections/ # Main app sections
│ ├── Announcements.tsx
│ ├── Forum.tsx
│ ├── Rewards.tsx
│ └── Wallet.tsx
├── test/ # Test setup
├── types/ # TypeScript types
├── App.tsx # Main app component
└── main.tsx # Entry point
```
## Database Schema
Tables (prefixed with `tg_` to avoid conflicts):
- `tg_users` - Telegram users
- `tg_announcements` - Admin announcements
- `tg_announcement_reactions` - Like/dislike reactions
- `tg_threads` - Forum threads
- `tg_thread_likes` - Thread likes
- `tg_replies` - Thread replies
- `tg_reply_likes` - Reply likes
## Environment Variables
| Variable | Description |
| ------------------------ | ------------------------ |
| `VITE_SUPABASE_URL` | Supabase project URL |
| `VITE_SUPABASE_ANON_KEY` | Supabase anon/public key |
## Telegram Setup
1. Create bot via @BotFather
2. Enable Mini App in bot settings
3. Set Mini App URL to deployment URL
4. Deploy `telegram-auth` Edge Function to Supabase
## License
MIT
+83
View File
@@ -0,0 +1,83 @@
# Supabase Kurulumu
## 1. Supabase Projesi Oluştur
1. https://supabase.com adresine git
2. "New Project" tıkla
3. Proje adı: `pezkuwi-telegram`
4. Database password oluştur (sakla!)
5. Region: Frankfurt (eu-central-1) - Türkiye'ye yakın
## 2. Database Schema Oluştur
Supabase Dashboard'da:
1. SQL Editor'e git
2. `supabase/migrations/001_initial_schema.sql` dosyasının içeriğini kopyala
3. "Run" tıkla
4. `supabase/migrations/002_rpc_functions.sql` dosyasının içeriğini kopyala
5. "Run" tıkla
## 3. API Keys Al
Settings > API:
- `Project URL` → VITE_SUPABASE_URL
- `anon public` key → VITE_SUPABASE_ANON_KEY
## 4. .env Dosyası Oluştur
```bash
cp .env.example .env
```
.env dosyasını düzenle:
```
VITE_SUPABASE_URL=https://xxxxx.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
## 5. Edge Function Deploy (Telegram Auth)
```bash
# Supabase CLI yükle
npm install -g supabase
# Login
supabase login
# Link project
supabase link --project-ref YOUR_PROJECT_REF
# Secrets ekle
supabase secrets set TELEGRAM_BOT_TOKEN=your-bot-token
# Deploy
supabase functions deploy telegram-auth
```
## 6. Test
```bash
npm run dev
```
## Platform Entegrasyonu
Aynı Supabase projesi şu platformlarda kullanılacak:
| Platform | Repo | Durum |
| --------------------- | ------------------------ | ------------ |
| Telegram Mini App | pezkuwi-telegram-miniapp | ✅ Hazır |
| Web (pezkuwichain.io) | pwap | ⏳ Eklenecek |
| Android (pezWallet) | pezWallet | ⏳ Eklenecek |
Tüm platformlar aynı:
- Database
- Auth
- Real-time subscriptions
- RLS policies
kullanacak.
+269
View File
@@ -0,0 +1,269 @@
# P2P Fiat Trading E2E Test Scenarios
Bu belge, P2P fiat alım-satım sisteminin uçtan uca (E2E) test senaryolarını açıklar.
## Genel Bakış
Testler beş ana senaryoyu kapsar:
1. **Mutlu Yol (Happy Path)** - İki dürüst kullanıcı başarılı ticaret yapar
2. **Alıcı Dolandırıcılığı** - Alıcı ödeme yaptığını söyler ama yapmaz
3. **Satıcı Dolandırıcılığı** - Satıcı ödemeyi aldığına rağmen crypto'yu serbest bırakmaz
4. **İptal Senaryosu** - Alıcı ödeme yapmadan önce ticareti iptal eder
5. **Zaman Aşımı** - Ödeme süresi dolar
## Test Kullanıcıları
```
Alice - Telegram ID: 111111111 - Dürüst satıcı
Bob - Telegram ID: 222222222 - Dürüst alıcı
Scammer - Telegram ID: 333333333 - Dolandırıcı
```
## Senaryo 1: Mutlu Yol - İki Dürüst Kullanıcı
### Adımlar
1. **Alice teklif oluşturur**
- 5 HEZ satılık
- 100 TRY karşılığında
- Ödeme yöntemi: Banka havalesi
2. **Sistem escrow'u kilitler**
- Alice'in internal balance'ından 5 HEZ kilitlenir
- `lock_escrow_internal` RPC çağrılır
3. **Bob teklifi kabul eder**
- `accept_p2p_offer` RPC çağrılır
- Trade kaydı oluşturulur (status: pending)
4. **Bob ödemeyi yapar ve işaretler**
- Banka havalesi yapar
- "Ödeme yaptım" butonuna tıklar
- Trade status: payment_sent
5. **Alice ödemeyi teyit eder**
- Bankasını kontrol eder, 100 TRY gelmiş
- "Ödeme alındı" butonuna tıklar
- `release_escrow_internal` çağrılır
- Bob'un bakiyesine 5 HEZ eklenir
6. **Trade tamamlanır**
- Her iki tarafın reputation'ı artar
- Trade status: completed
### Beklenen Sonuç
- ✅ Alice: 100 TRY alır (banka hesabında)
- ✅ Bob: 5 HEZ alır (internal balance)
- ✅ Her ikisinin de reputation'ı artar
---
## Senaryo 2: Alıcı Dolandırıcılığı - "Sattım Desin Yollamasın"
### Durum
Scammer, ödeme yaptığını işaretler ama aslında hiç ödeme yapmamıştır.
### Adımlar
1. Alice 5 HEZ satış teklifi oluşturur
2. Scammer teklifi kabul eder
3. **Scammer ödeme yapmadan "Ödeme yaptım" der**
4. Alice bankasını kontrol eder - para YOK
5. **Alice escrow'u serbest BIRAKMAZ**
6. Alice dispute açar
### Dispute Çözümü
Admin inceleme yapar:
- Ödeme kanıtı var mı? ❌ HAYIR
- Banka hesabında para var mı? ❌ HAYIR
**Karar: Escrow Alice'e iade edilir**
```sql
-- Admin işlemi
UPDATE p2p_fiat_trades
SET status = 'refunded',
dispute_resolved_at = NOW(),
dispute_resolution = 'Refunded to seller - no payment proof'
WHERE id = 'trade-id';
-- Escrow iadesi
SELECT refund_escrow(
p_from_user_id := alice_id,
p_token := 'HEZ',
p_amount := 5
);
```
### Beklenen Sonuç
- ✅ Alice: 5 HEZ'i geri alır
- ❌ Scammer: Hiçbir şey alamaz
- ⚠️ Scammer'ın reputation'ı düşer
---
## Senaryo 3: Satıcı Dolandırıcılığı - "Param Gelmedi Release Etmiyorum"
### Durum
Scammer satıcıdır. Bob gerçekten ödeme yapar ama Scammer parayı almasına rağmen crypto'yu release etmez.
### Adımlar
1. Scammer 5 HEZ satış teklifi oluşturur
2. Bob teklifi kabul eder
3. **Bob gerçekten 100 TRY gönderir** (banka dekontu var)
4. Bob "Ödeme yaptım" der ve dekont yükler
5. **Scammer "Para gelmedi" diye yalan söyler ve release etmez**
6. Confirmation deadline geçer
7. Bob dispute açar
### Dispute Çözümü
Admin inceleme yapar:
- Ödeme kanıtı var mı? ✅ EVET (banka dekontu)
- Dekont doğrulanabilir mi? ✅ EVET (IBAN, tarih, miktar uyuşuyor)
**Karar: Admin zorla release yapar**
```sql
-- Admin zorla release
SELECT release_escrow_internal(
p_from_user_id := scammer_id,
p_to_user_id := bob_id,
p_token := 'HEZ',
p_amount := 5,
p_reference_type := 'admin_forced_release',
p_reference_id := 'trade-id'
);
-- Trade güncelleme
UPDATE p2p_fiat_trades
SET status = 'completed',
dispute_resolved_at = NOW(),
dispute_resolution = 'Admin forced release - valid payment verified'
WHERE id = 'trade-id';
-- Scammer cezası
UPDATE p2p_reputation
SET disputed_trades = disputed_trades + 1,
reputation_score = GREATEST(0, reputation_score - 20),
trust_level = 'new'
WHERE user_id = scammer_id;
```
### Beklenen Sonuç
- ✅ Bob: 5 HEZ alır (haklı taraf)
- ❌ Scammer: Parayı aldı ama reputation çöker
- ⚠️ Scammer hesabı: "new" trust level, düşük score
---
## Senaryo 4: Trade İptali
### Durum
Bob teklifi kabul eder ama ödeme yapmadan vazgeçer.
### Adımlar
1. Alice 5 HEZ satış teklifi oluşturur
2. Bob teklifi kabul eder (status: pending)
3. **Bob fikrini değiştirir ve iptal eder**
4. Offer tekrar "open" durumuna döner
### Beklenen Sonuç
- ✅ Alice: Teklifi hala açık, başkası kabul edebilir
- ⚠️ Bob: reputation hafifçe düşebilir (çok fazla iptal zararlı)
---
## Senaryo 5: Zaman Aşımı
### Durum
Bob teklifi kabul eder ama süre dolana kadar ödeme yapmaz.
### Adımlar
1. Alice 5 HEZ teklifi oluşturur (30 dk limit)
2. Bob kabul eder
3. **30 dakika geçer, Bob ödeme yapmaz**
4. Sistem otomatik iptal eder (cron job)
5. Offer tekrar açılır
### Otomatik İşlem (Cron Job)
```sql
-- Süresi dolmuş pending trade'leri iptal et
UPDATE p2p_fiat_trades
SET status = 'cancelled',
cancel_reason = 'Payment deadline expired'
WHERE status = 'pending'
AND payment_deadline < NOW();
-- Offer'ları tekrar aç
UPDATE p2p_fiat_offers o
SET remaining_amount = remaining_amount + t.crypto_amount,
status = 'open'
FROM p2p_fiat_trades t
WHERE t.offer_id = o.id
AND t.status = 'cancelled'
AND t.cancel_reason = 'Payment deadline expired';
```
---
## Testleri Çalıştırma
### Unit Testler (Service key gerekmez)
```bash
npm run test:unit
```
### E2E Testler (Service key gerekli)
```bash
export SUPABASE_SERVICE_ROLE_KEY="your-service-role-key"
npm run test:e2e
```
### Tüm P2P Testleri
```bash
npm run test:p2p
```
---
## Önemli Notlar
### Escrow Güvenliği
- Escrow internal ledger'da tutulur (blockchain'de değil)
- P2P ticareti sırasında blockchain transaction OLMAZ
- Blockchain sadece deposit/withdraw'da kullanılır
### Dispute Politikası
1. Ödeme kanıtı olmadan dispute açılamaz
2. Admin her iki tarafı da dinler
3. Banka kayıtları delil olarak kabul edilir
4. Haksız tarafın reputation'ı ciddi şekilde düşer
### Reputation Sistemi
- Her başarılı trade: +1 puan
- Her iptal: -2 puan
- Kaybedilen dispute: -20 puan
- Trust level'lar: new → basic → intermediate → advanced → verified
+182
View File
@@ -0,0 +1,182 @@
import js from '@eslint/js';
import typescript from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import prettier from 'eslint-plugin-prettier';
export default [
js.configs.recommended,
// TypeScript and React files (browser environment)
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
globals: {
// Browser globals
window: 'readonly',
document: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
fetch: 'readonly',
URLSearchParams: 'readonly',
Map: 'readonly',
Set: 'readonly',
Promise: 'readonly',
navigator: 'readonly',
localStorage: 'readonly',
crypto: 'readonly',
CryptoKey: 'readonly',
TextEncoder: 'readonly',
TextDecoder: 'readonly',
Uint8Array: 'readonly',
ArrayBuffer: 'readonly',
btoa: 'readonly',
atob: 'readonly',
BigInt: 'readonly',
URL: 'readonly',
Blob: 'readonly',
File: 'readonly',
FileReader: 'readonly',
FormData: 'readonly',
Headers: 'readonly',
Request: 'readonly',
Response: 'readonly',
AbortController: 'readonly',
AbortSignal: 'readonly',
Event: 'readonly',
CustomEvent: 'readonly',
EventTarget: 'readonly',
MutationObserver: 'readonly',
ResizeObserver: 'readonly',
IntersectionObserver: 'readonly',
requestAnimationFrame: 'readonly',
cancelAnimationFrame: 'readonly',
performance: 'readonly',
queueMicrotask: 'readonly',
structuredClone: 'readonly',
indexedDB: 'readonly',
IDBDatabase: 'readonly',
// Import.meta for Vite
ImportMeta: 'readonly',
},
},
plugins: {
'@typescript-eslint': typescript,
react,
'react-hooks': reactHooks,
prettier,
},
settings: {
react: { version: 'detect' },
},
rules: {
...typescript.configs.recommended.rules,
...react.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'prettier/prettier': 'error',
'no-console': ['warn', { allow: ['warn', 'error'] }],
// Additional rules for better code quality
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-non-null-assertion': 'warn',
'react/display-name': 'off',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
},
// Node.js script files (CommonJS - .js)
{
files: ['scripts/**/*.js'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'commonjs',
globals: {
// Node.js globals
process: 'readonly',
console: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
module: 'readonly',
require: 'readonly',
exports: 'readonly',
Buffer: 'readonly',
setTimeout: 'readonly',
setInterval: 'readonly',
clearTimeout: 'readonly',
clearInterval: 'readonly',
setImmediate: 'readonly',
clearImmediate: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
Promise: 'readonly',
Map: 'readonly',
Set: 'readonly',
BigInt: 'readonly',
TextEncoder: 'readonly',
TextDecoder: 'readonly',
},
},
rules: {
'no-console': 'off', // Allow console in scripts
'no-undef': 'error',
},
},
// Node.js script files (ES Modules - .mjs)
{
files: ['scripts/**/*.mjs'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
// Node.js globals
process: 'readonly',
console: 'readonly',
Buffer: 'readonly',
setTimeout: 'readonly',
setInterval: 'readonly',
clearTimeout: 'readonly',
clearInterval: 'readonly',
setImmediate: 'readonly',
clearImmediate: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
Promise: 'readonly',
Map: 'readonly',
Set: 'readonly',
BigInt: 'readonly',
TextEncoder: 'readonly',
TextDecoder: 'readonly',
fetch: 'readonly',
},
},
rules: {
'no-console': 'off', // Allow console in scripts
'no-undef': 'error',
},
},
// Ignore patterns
{
ignores: [
'dist/**',
'node_modules/**',
'coverage/**',
'supabase/functions/**', // Deno runtime, different globals
'*.config.js',
'*.config.ts',
'.*.js',
'.*.cjs',
],
},
];
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="ku" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Pezkuwichain</title>
<meta name="description" content="Pezkuwichain - Dewleta Dîjîtal a Kurd" />
<!-- Security Headers - Note: X-Frame-Options and frame-ancestors must be set via HTTP headers, not meta -->
<meta http-equiv="X-Content-Type-Options" content="nosniff" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://telegram.org 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; connect-src 'self' https://*.supabase.co wss://*.supabase.co wss://*.pezkuwichain.io https://*.pezkuwichain.io https://api.coingecko.com; font-src 'self' data:; worker-src 'self' blob:;" />
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<meta name="theme-color" content="#030712" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #root { height: 100%; width: 100%; overflow: hidden; }
body { background: #030712; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
.loading { display: flex; align-items: center; justify-content: center; height: 100%; }
.spinner { width: 32px; height: 32px; border: 3px solid #1f2937; border-top-color: #22c55e; border-radius: 50%; animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div id="root"><div class="loading"><div class="spinner"></div></div></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+10981
View File
File diff suppressed because it is too large Load Diff
+86
View File
@@ -0,0 +1,86 @@
{
"name": "pezkuwi-telegram-miniapp",
"version": "1.0.112",
"type": "module",
"description": "Pezkuwichain Telegram Mini App - Forum, Announcements, Rewards",
"author": "Pezkuwichain Team",
"license": "MIT",
"scripts": {
"dev": "vite",
"build": "node scripts/bump-version.mjs && tsc && vite build",
"build:major": "node scripts/bump-version.mjs major && tsc && vite build",
"build:minor": "node scripts/bump-version.mjs minor && tsc && vite build",
"build:patch": "node scripts/bump-version.mjs patch && tsc && vite build",
"build:no-bump": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 30",
"lint:fix": "eslint . --ext ts,tsx --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:run": "vitest run",
"test:unit": "vitest run --grep 'P2P Fiat Trading E2E' --invert",
"test:e2e": "./scripts/run-e2e-tests.sh",
"test:p2p": "vitest run --grep 'P2P'",
"test:coverage": "vitest run --coverage",
"prepare": "husky",
"validate": "npm run typecheck && npm run lint && npm run test:run && npm run build:no-bump",
"version": "node scripts/bump-version.mjs"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,css}": [
"prettier --write"
]
},
"dependencies": {
"@pezkuwi/api": "^16.5.36",
"@pezkuwi/keyring": "^14.0.25",
"@pezkuwi/util-crypto": "^14.0.25",
"@supabase/supabase-js": "^2.93.1",
"@tanstack/react-query": "^5.56.2",
"@types/qrcode": "^1.5.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"lucide-react": "^0.462.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"vite-plugin-node-polyfills": "^0.25.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"@vitest/coverage-v8": "^4.0.18",
"autoprefixer": "^10.4.20",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"husky": "^9.1.7",
"jsdom": "^27.4.0",
"lint-staged": "^16.2.7",
"pg": "^8.18.0",
"postcss": "^8.4.47",
"prettier": "^3.8.1",
"tailwindcss": "^3.4.11",
"typescript": "^5.5.3",
"vite": "^5.4.1",
"vitest": "^4.0.18"
},
"engines": {
"node": ">=18.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="ku">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Siyaseta Nepenîtiyê - Pezkuwichain</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; background: #030712; color: #e5e7eb; }
h1, h2 { color: #fff; }
a { color: #22d3ee; }
</style>
</head>
<body>
<h1>Siyaseta Nepenîtiyê</h1>
<p><strong>Dema dawî hate nûkirin:</strong> Çile 2025</p>
<h2>1. Berhevkirina Daneyan</h2>
<p>Em van daneyan berhev dikin:</p>
<ul>
<li><strong>Agahiyên Telegram:</strong> ID, navê bikarhêner, navê yekem, wêne (ji Telegram API)</li>
<li><strong>Daneyên Karanîna:</strong> Çalakiyên forumê, bertekên ragihandinê</li>
<li><strong>Daneyên Teknîkî:</strong> Tîpa cîhazê, guhertoya appê</li>
</ul>
<h2>2. Bikaranîna Daneyan</h2>
<p>Em daneyan bikar tînin ji bo:</p>
<ul>
<li>Pêşkêşkirina xizmetên appê</li>
<li>Baştirkirina ezmûna bikarhêner</li>
<li>Parastina ewlehiyê</li>
</ul>
<h2>3. Parvekirina Daneyan</h2>
<p>Em daneyên we bi aliyên sêyem re parve nakin, ji bilî:</p>
<ul>
<li>Dema ku hûn bi xwe razî bin</li>
<li>Ji bo pêdiviyên qanûnî</li>
</ul>
<h2>4. Ewlehiya Daneyan</h2>
<p>Em tedbîrên teknîkî û rêxistinî bikar tînin ji bo parastina daneyên we:</p>
<ul>
<li>Şîfrekirina SSL/TLS</li>
<li>Kontrolên gihîştinê</li>
<li>Şopandina ewlehiyê</li>
</ul>
<h2>5. Mafên We</h2>
<p>Hûn mafê xwe heye ku:</p>
<ul>
<li>Bigihîjin daneyên xwe</li>
<li>Daneyên xwe rast bikin</li>
<li>Jêbirina daneyên xwe bixwazin</li>
</ul>
<h2>6. Têkilî</h2>
<p>Ji bo pirsan: <a href="mailto:privacy@pezkuwichain.io">privacy@pezkuwichain.io</a></p>
<h2>7. Guhertin</h2>
<p>Em dikarin vê siyasetê biguhezînin. Guheztinên girîng dê li vir bêne ragihandin.</p>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="ku">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Şertên Bikaranînê - Pezkuwichain</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; background: #030712; color: #e5e7eb; }
h1, h2 { color: #fff; }
a { color: #22d3ee; }
</style>
</head>
<body>
<h1>Şertên Bikaranînê</h1>
<p><strong>Dema dawî hate nûkirin:</strong> Çile 2025</p>
<h2>1. Pejirandin</h2>
<p>Bi karanîna vê appê, hûn van şertan qebûl dikin. Heke hûn razî nebin, ji kerema xwe appê bikar neynin.</p>
<h2>2. Karûbarên Me</h2>
<p>Pezkuwichain Telegram Mini App van xizmetan pêşkêşî dike:</p>
<ul>
<li>Foruma civakê</li>
<li>Ragihandinên fermî</li>
<li>Sîstema xelatan</li>
<li>Berîka (wallet) - zû tê</li>
</ul>
<h2>3. Berpirsiyariyên Bikarhêner</h2>
<p>Hûn berpirsiyar in:</p>
<ul>
<li>Agahiyên rast bidin</li>
<li>Hesabê xwe ewle bihêlin</li>
<li>Qanûnên herêmî bişopînin</li>
<li>Naveroka qedexekirî parve nekin</li>
</ul>
<h2>4. Naveroka Qedexekirî</h2>
<p>Van tiştan qedexe ne:</p>
<ul>
<li>Naveroka neqanûnî</li>
<li>Spam û reklamên bêdestûr</li>
<li>Êrişên li ser kesên din</li>
<li>Agahiyên şaş an xapînok</li>
</ul>
<h2>5. Mafên Xwedan</h2>
<p>Hemû naveroka Pezkuwichain aîdî Pezkuwichain e. Hûn destûr distînin ku tenê bikar bînin, ne ku kopî bikin.</p>
<h2>6. Sînorkirina Berpirsiyariyê</h2>
<p>Pezkuwichain berpirsiyar nîne ji bo:</p>
<ul>
<li>Windakirina daneyan</li>
<li>Astengiyên teknîkî</li>
<li>Naveroka ku bikarhêner çêkirine</li>
</ul>
<h2>7. Guhertin</h2>
<p>Em dikarin van şertan biguhezînin. Guheztinên girîng dê 30 roj berê bêne ragihandin.</p>
<h2>8. Têkilî</h2>
<p>Ji bo pirsan: <a href="mailto:legal@pezkuwichain.io">legal@pezkuwichain.io</a></p>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env node
/**
* Auto-increment version on each build
* Usage: node scripts/bump-version.js [major|minor|patch]
* Default: patch
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packagePath = path.resolve(__dirname, '../package.json');
// Read package.json
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf-8'));
// Parse current version
const [major, minor, patch] = pkg.version.split('.').map(Number);
// Determine bump type from argument
const bumpType = process.argv[2] || 'patch';
let newVersion;
switch (bumpType) {
case 'major':
newVersion = `${major + 1}.0.0`;
break;
case 'minor':
newVersion = `${major}.${minor + 1}.0`;
break;
case 'patch':
default:
newVersion = `${major}.${minor}.${patch + 1}`;
break;
}
// Add build timestamp for cache busting
const buildTime = new Date().toISOString();
// Update package.json
pkg.version = newVersion;
fs.writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n');
// Write version info to a separate file for the app
const versionInfo = {
version: newVersion,
buildTime,
buildNumber: Date.now(),
};
const versionFilePath = path.resolve(__dirname, '../src/version.json');
fs.writeFileSync(versionFilePath, JSON.stringify(versionInfo, null, 2) + '\n');
console.log(`✓ Version bumped: ${major}.${minor}.${patch}${newVersion}`);
console.log(`✓ Build time: ${buildTime}`);
+141
View File
@@ -0,0 +1,141 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_KEY;
if (!supabaseUrl || !supabaseKey) {
console.error('Missing: SUPABASE_URL and SUPABASE_SERVICE_KEY required');
process.exit(1);
}
const supabase = createClient(supabaseUrl, supabaseKey);
async function createForumTables() {
console.log('Creating forum tables in DKSapp Supabase...\n');
// Create tables using raw SQL via Supabase's pg_catalog or rpc
// Since we can't run raw SQL directly, let's check if tables exist and create data
// First, let's try to query the tables to see if they exist
console.log('Checking existing tables...');
const { data: categories, error: catError } = await supabase
.from('forum_categories')
.select('*')
.limit(1);
if (catError && catError.message.includes('does not exist')) {
console.log('\n❌ Forum tables do not exist in this Supabase project.');
console.log('\nYou need to create them in Supabase SQL Editor:');
console.log('Go to your Supabase dashboard SQL editor\n');
console.log('Run this SQL:\n');
console.log(`
-- Forum Categories Table
CREATE TABLE IF NOT EXISTS forum_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT UNIQUE NOT NULL,
description TEXT,
icon TEXT DEFAULT '💬',
color TEXT DEFAULT '#3B82F6',
is_active BOOLEAN DEFAULT true,
display_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Forum Discussions Table
CREATE TABLE IF NOT EXISTS forum_discussions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
category_id UUID REFERENCES forum_categories(id),
proposal_id TEXT,
title TEXT NOT NULL,
content TEXT NOT NULL,
image_url TEXT,
author_id TEXT NOT NULL,
author_name TEXT NOT NULL,
author_address TEXT,
is_pinned BOOLEAN DEFAULT false,
is_locked BOOLEAN DEFAULT false,
views_count INTEGER DEFAULT 0,
replies_count INTEGER DEFAULT 0,
tags TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
last_activity_at TIMESTAMPTZ DEFAULT NOW()
);
-- Forum Replies Table
CREATE TABLE IF NOT EXISTS forum_replies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
discussion_id UUID REFERENCES forum_discussions(id) ON DELETE CASCADE,
parent_reply_id UUID REFERENCES forum_replies(id),
content TEXT NOT NULL,
author_id TEXT NOT NULL,
author_name TEXT NOT NULL,
author_address TEXT,
is_edited BOOLEAN DEFAULT false,
edited_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Forum Reactions Table
CREATE TABLE IF NOT EXISTS forum_reactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
discussion_id UUID REFERENCES forum_discussions(id) ON DELETE CASCADE,
reply_id UUID REFERENCES forum_replies(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
reaction_type TEXT NOT NULL CHECK (reaction_type IN ('upvote', 'downvote')),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(discussion_id, user_id),
UNIQUE(reply_id, user_id)
);
-- Admin Announcements Table
CREATE TABLE IF NOT EXISTS admin_announcements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
content TEXT NOT NULL,
type TEXT DEFAULT 'info' CHECK (type IN ('info', 'warning', 'success', 'critical')),
priority INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Enable RLS
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 forum_reactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE admin_announcements ENABLE ROW LEVEL SECURITY;
-- RLS Policies (allow all for now - can be restricted later)
CREATE POLICY "Allow all on forum_categories" ON forum_categories FOR ALL USING (true) WITH CHECK (true);
CREATE POLICY "Allow all on forum_discussions" ON forum_discussions FOR ALL USING (true) WITH CHECK (true);
CREATE POLICY "Allow all on forum_replies" ON forum_replies FOR ALL USING (true) WITH CHECK (true);
CREATE POLICY "Allow all on forum_reactions" ON forum_reactions FOR ALL USING (true) WITH CHECK (true);
CREATE POLICY "Allow all on admin_announcements" ON admin_announcements FOR ALL USING (true) WITH CHECK (true);
-- Create indexes
CREATE INDEX IF NOT EXISTS idx_discussions_category ON forum_discussions(category_id);
CREATE INDEX IF NOT EXISTS idx_discussions_pinned ON forum_discussions(is_pinned);
CREATE INDEX IF NOT EXISTS idx_discussions_activity ON forum_discussions(last_activity_at DESC);
CREATE INDEX IF NOT EXISTS idx_replies_discussion ON forum_replies(discussion_id);
CREATE INDEX IF NOT EXISTS idx_reactions_discussion ON forum_reactions(discussion_id);
CREATE INDEX IF NOT EXISTS idx_reactions_reply ON forum_reactions(reply_id);
`);
return false;
} else if (catError) {
console.error('Error checking tables:', catError.message);
return false;
} else {
console.log('✅ Forum tables exist!');
console.log(' Categories found:', categories?.length || 0);
return true;
}
}
createForumTables().then(exists => {
if (exists) {
console.log('\nTables ready. Now run seed-admin-post.mjs');
}
}).catch(console.error);
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
#
# P2P Fiat Trading E2E Test Runner
#
# This script runs end-to-end tests for the P2P trading system.
# E2E tests require:
# 1. SUPABASE_SERVICE_ROLE_KEY environment variable
# 2. Network access to Supabase
#
# Usage:
# ./scripts/run-e2e-tests.sh
#
# Or with service key:
# SUPABASE_SERVICE_ROLE_KEY="your-key" ./scripts/run-e2e-tests.sh
#
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}========================================${NC}"
echo -e "${YELLOW}P2P Fiat Trading E2E Test Suite${NC}"
echo -e "${YELLOW}========================================${NC}"
echo ""
# Check for service role key
if [ -z "$SUPABASE_SERVICE_ROLE_KEY" ]; then
echo -e "${RED}ERROR: SUPABASE_SERVICE_ROLE_KEY is not set${NC}"
echo ""
echo "E2E tests require the Supabase service role key."
echo "Get it from: https://supabase.com/dashboard/project/vbhftvdayqfmcgmzdxfv/settings/api"
echo ""
echo "Run with:"
echo " export SUPABASE_SERVICE_ROLE_KEY=\"your-key-here\""
echo " ./scripts/run-e2e-tests.sh"
echo ""
echo "Or:"
echo " SUPABASE_SERVICE_ROLE_KEY=\"your-key\" ./scripts/run-e2e-tests.sh"
echo ""
echo -e "${YELLOW}Running unit tests only...${NC}"
npm run test:run -- --grep "P2P Fiat Trading E2E" --invert
exit 0
fi
echo -e "${GREEN}Service role key found. Running full E2E test suite...${NC}"
echo ""
# Run all P2P tests including E2E
npm run test:run -- --grep "P2P"
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}E2E Tests Complete${NC}"
echo -e "${GREEN}========================================${NC}"
+152
View File
@@ -0,0 +1,152 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_KEY || process.env.VITE_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseKey) {
console.error('Missing environment variables: SUPABASE_URL and SUPABASE_SERVICE_KEY required');
process.exit(1);
}
const supabase = createClient(supabaseUrl, supabaseKey);
async function seedAdminPost() {
console.log('Seeding admin post...\n');
// 1. Check/Create category
console.log('1. Checking for Giştî category...');
let { data: existingCategory } = await supabase
.from('forum_categories')
.select('id')
.eq('name', 'Giştî')
.single();
let categoryId;
if (existingCategory) {
categoryId = existingCategory.id;
console.log(' Category exists:', categoryId);
} else {
console.log(' Creating category...');
const { data: newCategory, error: catError } = await supabase
.from('forum_categories')
.insert({
name: 'Giştî',
description: 'Mijarên giştî û nîqaşên civakî',
icon: '💬',
color: '#3B82F6',
is_active: true,
display_order: 1
})
.select()
.single();
if (catError) {
console.error(' Error creating category:', catError.message);
return;
}
categoryId = newCategory.id;
console.log(' Category created:', categoryId);
}
// 2. Check if welcome post exists
console.log('\n2. Checking for existing welcome post...');
const { data: existingPost } = await supabase
.from('forum_discussions')
.select('id')
.eq('author_id', 'admin')
.eq('is_pinned', true)
.single();
if (existingPost) {
console.log(' Welcome post already exists:', existingPost.id);
} else {
console.log(' Creating welcome post...');
const postContent = `Silav û rêz ji hemû welatiyên Kurd ên li seranserê cîhanê! 🌍
Pezkuwichain ne tenê blockchaineke e - ew xewna me ye ku Kurdên li her çar perçeyên Kurdistanê û li diasporayê bi hev re bigihîjin.
🔗 **Çima Pezkuwichain?**
Di vê serdema dîjîtal de, em Kurd hewce ne ku platformeke xwe hebe ku:
- Bi azadî bi hev re biaxivin
- Aboriya xwe bi hev re ava bikin
- Nasname û çanda xwe biparêzin
- Ji hêza teknolojiya blockchain ve ewlekariya xwe misoger bikin
🌐 **Armanca Me**
Em dixwazin platformeke ava bikin ku hemû Kurdên cîhanê dikarin:
- Bi hev re pêwendî daynin (connect)
- Projeyên hevbeş bi rê ve bibin
- Bi ziman û çanda xwe bi hev re têkildar bin
- Di aboriya dîjîtal de cîhê xwe bigirin
💪 **Hêza Civakê**
Blockchain ne tenê teknolojî ye - ew şanseke nû ye ji bo gelên wekî me ku di dîrokê de nekarîbûn dewleta xwe ava bikin. Niha em dikarin "Dewleta Dîjîtal"a xwe ava bikin!
Ev platform diyarîyeke ji me ye ji bo hemû Kurdên cîhanê. Bi hev re em dikarin!
#BiHevRe #Pezkuwichain #Blockchain #Kurd #DewletaDîjîtal`;
const { data: newPost, error: postError } = await supabase
.from('forum_discussions')
.insert({
category_id: categoryId,
title: 'Bi xêr hatî Pezkuwichain! 🎉 Dewleta Dîjîtal a Kurd',
content: postContent,
image_url: '/tokens/DKState.png',
author_id: 'admin',
author_name: 'Pezkuwichain Admin',
is_pinned: true,
is_locked: false,
views_count: 0,
replies_count: 0,
tags: ['BiHevRe', 'Pezkuwichain', 'Blockchain', 'Kurd', 'Civak'],
last_activity_at: new Date().toISOString()
})
.select()
.single();
if (postError) {
console.error(' Error creating post:', postError.message);
} else {
console.log(' Welcome post created:', newPost.id);
}
}
// 3. Check/Create announcement
console.log('\n3. Checking for welcome announcement...');
const { data: existingAnnouncement } = await supabase
.from('admin_announcements')
.select('id')
.eq('title', 'Bi xêr hatî Forum!')
.single();
if (existingAnnouncement) {
console.log(' Announcement already exists:', existingAnnouncement.id);
} else {
console.log(' Creating announcement...');
const { data: newAnnouncement, error: annError } = await supabase
.from('admin_announcements')
.insert({
title: 'Bi xêr hatî Forum!',
content: 'Ev foruma fermî ya Pezkuwichain e. Li vir tu dikarî mijarên nû vekî, bersivan bidî û bi civakê re têkiliyê ragirî!',
type: 'success',
priority: 100,
is_active: true
})
.select()
.single();
if (annError) {
console.error(' Error creating announcement:', annError.message);
} else {
console.log(' Announcement created:', newAnnouncement.id);
}
}
console.log('\n✅ Seeding complete!');
}
seedAdminPost().catch(console.error);
+113
View File
@@ -0,0 +1,113 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_KEY;
if (!supabaseUrl || !supabaseKey) {
console.error('Missing: SUPABASE_URL and SUPABASE_SERVICE_KEY required');
process.exit(1);
}
const supabase = createClient(supabaseUrl, supabaseKey);
async function seedRagihandin() {
console.log('Creating Ragihandin (Announcements) section...\n');
// 1. Create or get Ragihandin category
console.log('1. Checking for Ragihandin category...');
let { data: existingCategory } = await supabase
.from('forum_categories')
.select('id')
.eq('name', 'Ragihandin')
.single();
let categoryId;
if (existingCategory) {
categoryId = existingCategory.id;
console.log(' Category exists:', categoryId);
} else {
console.log(' Creating Ragihandin category...');
const { data: newCategory, error: catError } = await supabase
.from('forum_categories')
.insert({
name: 'Ragihandin',
description: 'Daxuyaniyên fermî yên Pezkuwichain',
icon: '📢',
color: '#EF4444',
is_active: true,
display_order: 0 // First in the list
})
.select()
.single();
if (catError) {
console.error(' Error creating category:', catError.message);
return;
}
categoryId = newCategory.id;
console.log(' Category created:', categoryId);
}
// 2. Create the pinned announcement post
console.log('\n2. Creating pinned announcement post...');
const { data: existingPost } = await supabase
.from('forum_discussions')
.select('id')
.eq('category_id', categoryId)
.eq('author_id', 'admin')
.eq('is_pinned', true)
.single();
if (existingPost) {
console.log(' Pinned announcement already exists:', existingPost.id);
} else {
const announcementContent = `Hûn bi xêr hatin rûpela daxuyaniyên fermî yên Pezkuwichain! 🎉
📢 **Ev Çi Ye?**
Ev rûpela yekem û yekane ya daxuyaniyên fermî yên Pezkuwichain e. Hemû nûçe, update, û daxuyaniyên girîng ên projeyê dê li vir werin weşandin.
✅ **Li Vir Hûn Dikarin:**
- Nûçeyên herî dawî yên Pezkuwichain bişopînin
- Updateyên teknîkî û pêşkeftinên nû bibînin
- Daxuyaniyên fermî yên tîmê bixwînin
- Pêşerojê û roadmapê bişopînin
⚠️ **Girîng:**
Tenê admin dikare li vê beşê post bike. Ev garantî dike ku hemû agahdarî rast û fermî ne.
🔔 Bi me re bimînin û nûçeyên herî dawî werin!
#Pezkuwichain #Ragihandin #Fermî #Daxuyanî`;
const { data: newPost, error: postError } = await supabase
.from('forum_discussions')
.insert({
category_id: categoryId,
title: '📢 Bi Xêr Hatî Rûpela Daxuyaniyên Fermî yên Pezkuwichain!',
content: announcementContent,
image_url: '/tokens/pezkuwichain_header.png',
author_id: 'admin',
author_name: 'Pezkuwichain Admin',
is_pinned: true,
is_locked: false,
views_count: 0,
replies_count: 0,
tags: ['Pezkuwichain', 'Ragihandin', 'Fermî', 'Daxuyanî'],
last_activity_at: new Date().toISOString()
})
.select()
.single();
if (postError) {
console.error(' Error creating post:', postError.message);
} else {
console.log(' Announcement post created:', newPost.id);
}
}
console.log('\n✅ Ragihandin section ready!');
}
seedRagihandin().catch(console.error);
+122
View File
@@ -0,0 +1,122 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_KEY;
if (!supabaseUrl || !supabaseKey) {
console.error('Missing: SUPABASE_URL and SUPABASE_SERVICE_KEY required');
process.exit(1);
}
const supabase = createClient(supabaseUrl, supabaseKey);
async function seedAnnouncements() {
console.log('Setting up Ragihandin (tg_announcements)...\n');
// 1. Check/create admin user
console.log('1. Checking for admin user...');
let { data: adminUser } = await supabase
.from('tg_users')
.select('*')
.eq('is_admin', true)
.single();
if (!adminUser) {
console.log(' No admin user found, checking all users...');
const { data: allUsers } = await supabase
.from('tg_users')
.select('*')
.limit(5);
console.log(' Found', allUsers?.length || 0, 'users');
if (allUsers && allUsers.length > 0) {
console.log(' Users:', allUsers.map(u => `${u.first_name} (${u.id})`).join(', '));
// Use the first user as admin for now
adminUser = allUsers[0];
console.log(' Using first user as admin:', adminUser.first_name);
} else {
// Create a system admin user
console.log(' Creating system admin user...');
const { data: newAdmin, error: adminError } = await supabase
.from('tg_users')
.insert({
telegram_id: 0,
username: 'pezkuwichain_admin',
first_name: 'Pezkuwichain',
last_name: 'Admin',
is_admin: true
})
.select()
.single();
if (adminError) {
console.error(' Error creating admin:', adminError.message);
return false;
}
adminUser = newAdmin;
console.log(' Admin created:', adminUser.id);
}
} else {
console.log(' Admin found:', adminUser.first_name, adminUser.id);
}
// 2. Check existing announcements
console.log('\n2. Checking existing announcements...');
const { data: existing } = await supabase
.from('tg_announcements')
.select('*')
.limit(5);
console.log(' Found', existing?.length || 0, 'announcements');
const ourAnnouncement = existing?.find(a =>
a.title?.includes('Bi Xêr Hatî') || a.title?.includes('Daxuyaniyên Fermî')
);
if (ourAnnouncement) {
console.log('\n ✅ Admin announcement already exists:', ourAnnouncement.id);
return true;
}
// 3. Create the admin announcement
console.log('\n3. Creating admin announcement...');
const announcementContent = `Hûn bi xêr hatin rûpela daxuyaniyên fermî yên Pezkuwichain!
📢 Ev rûpela yekem û yekane ya daxuyaniyên fermî yên Pezkuwichain e.
✅ Li vir hûn dikarin:
• Nûçeyên herî dawî yên Pezkuwichain bişopînin
• Updateyên teknîkî û pêşkeftinên nû bibînin
• Daxuyaniyên fermî yên tîmê bixwînin
• Roadmap û pêşerojê bişopînin
⚠️ Tenê admin dikare li vir post bike - hemû agahdarî rast û fermî ne.
🔔 Bi me re bimînin!`;
const { data: newAnn, error: annError } = await supabase
.from('tg_announcements')
.insert({
title: '📢 Bi Xêr Hatî Rûpela Daxuyaniyên Fermî!',
content: announcementContent,
image_url: '/tokens/pezkuwichain_header.png',
link_url: 'https://pezkuwichain.io',
author_id: adminUser.id,
is_published: true
})
.select()
.single();
if (annError) {
console.error(' Error creating announcement:', annError.message);
return false;
}
console.log(' ✅ Announcement created:', newAnn.id);
console.log('\n✅ Ragihandin setup complete!');
return true;
}
seedAnnouncements().catch(console.error);
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# PezkuwiChain Telegram Bot Webhook Setup
# Run this script from a machine that can access Telegram API
BOT_TOKEN="8548408481:AAEsoyiVBllk_x0T3Jelj8N8VrUiuc9jXQw"
WEBHOOK_URL="https://vbhftvdayqfmcgmzdxfv.supabase.co/functions/v1/telegram-bot"
echo "Setting up Telegram webhook..."
echo "Bot Token: ${BOT_TOKEN:0:10}..."
echo "Webhook URL: $WEBHOOK_URL"
echo ""
# Delete any existing webhook
echo "Removing existing webhook..."
curl -s "https://api.telegram.org/bot$BOT_TOKEN/deleteWebhook" | jq .
# Set new webhook
echo ""
echo "Setting new webhook..."
curl -s "https://api.telegram.org/bot$BOT_TOKEN/setWebhook" \
-d "url=$WEBHOOK_URL" \
-d "allowed_updates=[\"message\",\"callback_query\"]" | jq .
# Verify webhook
echo ""
echo "Verifying webhook..."
curl -s "https://api.telegram.org/bot$BOT_TOKEN/getWebhookInfo" | jq .
echo ""
echo "Done! Test the bot by sending /start to @pezkuwichain_bot"
+49
View File
@@ -0,0 +1,49 @@
-- Telegram Mini App Users Table
-- Run this in Supabase SQL Editor
-- Users table for Telegram authentication
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
telegram_id BIGINT UNIQUE NOT NULL,
username TEXT,
first_name TEXT NOT NULL,
last_name TEXT,
photo_url TEXT,
language_code TEXT DEFAULT 'ku',
wallet_address TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index for fast telegram_id lookups
CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id);
-- Index for wallet_address lookups
CREATE INDEX IF NOT EXISTS idx_users_wallet_address ON users(wallet_address);
-- RLS (Row Level Security)
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- Policy: Users can read their own data
CREATE POLICY "Users can read own data" ON users
FOR SELECT
USING (true);
-- Policy: Service role can do everything (for Edge Functions)
CREATE POLICY "Service role full access" ON users
FOR ALL
USING (auth.role() = 'service_role');
-- Updated_at trigger
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
+139
View File
@@ -0,0 +1,139 @@
import { useState, lazy, Suspense, useCallback } from 'react';
import { Megaphone, MessageCircle, Gift, Wallet, Loader2, ArrowLeftRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { UpdateNotification } from '@/components/UpdateNotification';
import { P2PModal } from '@/components/P2PModal';
import { useAuth } from '@/contexts/AuthContext';
import { useWallet } from '@/contexts/WalletContext';
// Lazy load sections for code splitting
const AnnouncementsSection = lazy(() =>
import('@/sections/Announcements').then((m) => ({ default: m.AnnouncementsSection }))
);
const ForumSection = lazy(() =>
import('@/sections/Forum').then((m) => ({ default: m.ForumSection }))
);
const RewardsSection = lazy(() =>
import('@/sections/Rewards').then((m) => ({ default: m.RewardsSection }))
);
const WalletSection = lazy(() =>
import('@/sections/Wallet').then((m) => ({ default: m.WalletSection }))
);
// Loading fallback component
function SectionLoader() {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-8 h-8 text-primary animate-spin" />
</div>
);
}
type Section = 'announcements' | 'forum' | 'rewards' | 'wallet';
type NavId = Section | 'p2p';
interface NavItem {
id: NavId;
icon: typeof Megaphone;
label: string;
isExternal?: boolean;
}
const NAV_ITEMS: NavItem[] = [
{ id: 'announcements', icon: Megaphone, label: 'Ragihandin' },
{ id: 'forum', icon: MessageCircle, label: 'Forum' },
{ id: 'rewards', icon: Gift, label: 'Xelat' },
{ id: 'p2p', icon: ArrowLeftRight, label: 'P2P', isExternal: true },
{ id: 'wallet', icon: Wallet, label: 'Berîk' },
];
// P2P Web App URL - Mobile-optimized P2P
const P2P_WEB_URL = 'https://telegram.pezkuwichain.io/p2p';
export default function App() {
const [activeSection, setActiveSection] = useState<Section>('announcements');
const [showP2PModal, setShowP2PModal] = useState(false);
const { user } = useAuth();
const { address } = useWallet();
// Open P2P in popup with auth params
const openP2P = useCallback(() => {
window.Telegram?.WebApp.HapticFeedback.impactOccurred('medium');
// Build auth URL with params
const params = new URLSearchParams();
if (user?.telegram_id) {
params.set('tg_id', user.telegram_id.toString());
}
if (address) {
params.set('wallet', address);
}
params.set('ts', Date.now().toString());
params.set('from', 'miniapp');
const url = `${P2P_WEB_URL}?${params.toString()}`;
// Open in new window/tab
window.open(url, '_blank');
}, [user, address]);
const handleNavClick = (item: NavItem) => {
window.Telegram?.WebApp.HapticFeedback.selectionChanged();
if (item.isExternal) {
// P2P opens modal first
if (item.id === 'p2p') {
setShowP2PModal(true);
}
} else {
setActiveSection(item.id as Section);
}
};
return (
<div className="flex flex-col h-screen bg-background">
{/* Content Area */}
<main className="flex-1 overflow-hidden">
<div className="h-full animate-in">
<Suspense fallback={<SectionLoader />}>
{activeSection === 'announcements' && <AnnouncementsSection />}
{activeSection === 'forum' && <ForumSection />}
{activeSection === 'rewards' && <RewardsSection />}
{activeSection === 'wallet' && <WalletSection />}
</Suspense>
</div>
</main>
{/* Update Notification */}
<UpdateNotification />
{/* P2P Modal */}
<P2PModal isOpen={showP2PModal} onClose={() => setShowP2PModal(false)} onOpenP2P={openP2P} />
{/* Bottom Navigation */}
<nav className="flex-shrink-0 bg-secondary/50 backdrop-blur-lg border-t border-border safe-area-bottom">
<div className="flex justify-around items-center h-16">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = !item.isExternal && activeSection === item.id;
return (
<button
key={item.id}
onClick={() => handleNavClick(item)}
className={cn(
'flex flex-col items-center justify-center w-full h-full gap-1 transition-colors',
isActive ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
item.isExternal && 'text-cyan-400 hover:text-cyan-300'
)}
>
<Icon className={cn('w-5 h-5', isActive && 'scale-110')} />
<span className="text-[10px] font-medium">{item.label}</span>
</button>
);
})}
</div>
</nav>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { trackError } from '@/lib/error-tracking';
interface Props {
children: ReactNode;
fallback?: ReactNode;
componentName?: string;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.setState({ errorInfo });
// Track error with context
trackError(error, {
component: this.props.componentName ?? 'ErrorBoundary',
action: 'component_crash',
extra: {
componentStack: errorInfo.componentStack,
},
});
}
handleRetry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex flex-col items-center justify-center min-h-screen p-6 bg-background text-foreground">
<div className="w-16 h-16 rounded-full bg-red-500/20 flex items-center justify-center mb-4">
<AlertTriangle className="w-8 h-8 text-red-400" />
</div>
<h1 className="text-xl font-semibold mb-2">Tiştek çewt çê</h1>
<p className="text-sm text-muted-foreground text-center mb-6 max-w-xs">
Bibore, pirsgirêkek teknîkî derket. Ji kerema xwe dîsa biceribîne.
</p>
<button
onClick={this.handleRetry}
className="flex items-center gap-2 px-4 py-2 bg-primary rounded-lg text-primary-foreground font-medium"
>
<RefreshCw className="w-4 h-4" />
Dîsa biceribîne
</button>
{import.meta.env.DEV && this.state.error && (
<div className="mt-6 w-full max-w-lg">
<pre className="p-4 bg-secondary rounded-lg text-xs text-red-400 overflow-auto max-h-48">
<strong>Error:</strong> {this.state.error.message}
{'\n\n'}
<strong>Stack:</strong>
{'\n'}
{this.state.error.stack}
</pre>
{this.state.errorInfo?.componentStack && (
<pre className="mt-2 p-4 bg-secondary rounded-lg text-xs text-yellow-400 overflow-auto max-h-32">
<strong>Component Stack:</strong>
{this.state.errorInfo.componentStack}
</pre>
)}
</div>
)}
</div>
);
}
return this.props.children;
}
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Kurdistan Sun Component
* Animated sun with 21 rays and rotating kesk-sor-zer halos
*/
interface KurdistanSunProps {
size?: number;
className?: string;
}
export function KurdistanSun({ size = 200, className = '' }: KurdistanSunProps) {
return (
<div className={`kurdistan-sun-container ${className}`} style={{ width: size, height: size }}>
{/* Rotating colored halos - Kesk u Sor u Zer */}
<div className="sun-halos">
{/* Green halo (outermost) - Kesk */}
<div className="halo halo-green" />
{/* Red halo (middle) - Sor */}
<div className="halo halo-red" />
{/* Yellow halo (inner) - Zer */}
<div className="halo halo-yellow" />
</div>
{/* Kurdistan Sun with 21 rays */}
<svg
viewBox="0 0 200 200"
className="kurdistan-sun-svg"
style={{ width: '100%', height: '100%' }}
>
{/* Sun rays (21 rays for Kurdistan flag) */}
<g className="sun-rays">
{Array.from({ length: 21 }).map((_, i) => {
const angle = (i * 360) / 21;
return (
<line
key={i}
x1="100"
y1="100"
x2="100"
y2="20"
stroke="rgba(255, 255, 255, 0.9)"
strokeWidth="3"
strokeLinecap="round"
transform={`rotate(${angle} 100 100)`}
className="ray"
style={{
animationDelay: `${i * 0.05}s`,
}}
/>
);
})}
</g>
{/* Central white circle */}
<circle cx="100" cy="100" r="35" fill="white" className="sun-center" />
{/* Inner glow */}
<circle cx="100" cy="100" r="35" fill="url(#sunGradient)" className="sun-glow" />
<defs>
<radialGradient id="sunGradient">
<stop offset="0%" stopColor="rgba(255, 255, 255, 0.8)" />
<stop offset="100%" stopColor="rgba(255, 255, 255, 0.2)" />
</radialGradient>
</defs>
</svg>
<style>{`
.kurdistan-sun-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.sun-halos {
position: absolute;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.halo {
position: absolute;
border-radius: 50%;
animation: rotate-halo 3s linear infinite;
}
.halo-green {
width: 100%;
height: 100%;
border: 4px solid transparent;
border-top-color: #00FF00;
border-bottom-color: #00FF00;
animation-duration: 3s;
}
.halo-red {
width: 80%;
height: 80%;
border: 4px solid transparent;
border-left-color: #FF0000;
border-right-color: #FF0000;
animation-duration: 2.5s;
animation-direction: reverse;
}
.halo-yellow {
width: 60%;
height: 60%;
border: 4px solid transparent;
border-top-color: #FFD700;
border-bottom-color: #FFD700;
animation-duration: 2s;
}
@keyframes rotate-halo {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.kurdistan-sun-svg {
position: relative;
z-index: 1;
filter: drop-shadow(0 0 20px rgba(255, 255, 255, 0.6));
}
.sun-rays {
animation: pulse-rays 2s ease-in-out infinite;
}
@keyframes pulse-rays {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.ray {
animation: ray-shine 2s ease-in-out infinite;
}
@keyframes ray-shine {
0%, 100% {
opacity: 0.9;
}
50% {
opacity: 0.5;
}
}
.sun-center {
filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.8));
}
.sun-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
@keyframes pulse-glow {
0%, 100% {
opacity: 0.6;
}
50% {
opacity: 0.3;
}
}
`}</style>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { Loader2 } from 'lucide-react';
interface LoadingScreenProps {
message?: string;
}
export function LoadingScreen({ message = 'Tê barkirin...' }: LoadingScreenProps) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-background">
<div className="relative">
<div className="w-16 h-16 rounded-full bg-primary/20 flex items-center justify-center">
<Loader2 className="w-8 h-8 text-primary animate-spin" />
</div>
</div>
<p className="mt-4 text-sm text-muted-foreground">{message}</p>
</div>
);
}
+171
View File
@@ -0,0 +1,171 @@
import { useState } from 'react';
import { X, ExternalLink, Info } from 'lucide-react';
import { cn } from '@/lib/utils';
interface P2PModalProps {
isOpen: boolean;
onClose: () => void;
onOpenP2P: () => void;
}
type Language = 'en' | 'ckb' | 'ku' | 'tr';
const LANGUAGES: { code: Language; label: string }[] = [
{ code: 'en', label: 'EN' },
{ code: 'ckb', label: 'سۆرانی' },
{ code: 'ku', label: 'Kurmancî' },
{ code: 'tr', label: 'TR' },
];
const CONTENT: Record<
Language,
{
title: string;
subtitle: string;
firstTime: string;
steps: string[];
note: string;
button: string;
}
> = {
en: {
title: 'P2P Exchange',
subtitle: 'Trade crypto peer-to-peer',
firstTime: 'First time using P2P?',
steps: [
'Click the button below to open the web app',
'Create an account or log in',
'Complete the P2P setup process',
'After setup, you can access P2P directly',
],
note: 'The web app will open in a new window. Complete the registration process there.',
button: 'Open P2P Platform',
},
ckb: {
title: 'P2P ئاڵۆگۆڕ',
subtitle: 'ئاڵۆگۆڕی کریپتۆ لە نێوان کەسەکاندا',
firstTime: 'یەکەم جار P2P بەکاردەهێنیت؟',
steps: [
'کلیک لە دوگمەی خوارەوە بکە بۆ کردنەوەی ماڵپەڕ',
'هەژمارێک دروست بکە یان بچۆ ژوورەوە',
'پرۆسەی دامەزراندنی P2P تەواو بکە',
'دوای دامەزراندن، دەتوانیت ڕاستەوخۆ بچیتە P2P',
],
note: 'ماڵپەڕ لە پەنجەرەیەکی نوێ دەکرێتەوە. پرۆسەی تۆمارکردن لەوێ تەواو بکە.',
button: 'کردنەوەی P2P',
},
ku: {
title: 'P2P Danûstandin',
subtitle: 'Danûstandina krîpto di navbera kesan de',
firstTime: 'Cara yekem P2P bikar tînin?',
steps: [
'Li bişkoja jêrîn bikirtînin da ku malpera webê vebike',
'Hesabek çêbikin an têkevin',
'Pêvajoya sazkirina P2P temam bikin',
'Piştî sazkirinê, hûn dikarin rasterast bigihîjin P2P',
],
note: 'Malpera webê di pencereyek nû de vedibe. Pêvajoya qeydkirinê li wir temam bikin.',
button: 'P2P Veke',
},
tr: {
title: 'P2P Borsa',
subtitle: 'Kullanıcılar arası kripto alım satım',
firstTime: "P2P'yi ilk kez mi kullanıyorsunuz?",
steps: [
'Web uygulamasını açmak için aşağıdaki butona tıklayın',
'Hesap oluşturun veya giriş yapın',
'P2P kurulum sürecini tamamlayın',
"Kurulumdan sonra P2P'ye doğrudan erişebilirsiniz",
],
note: 'Web uygulaması yeni bir pencerede açılacak. Kayıt işlemini orada tamamlayın.',
button: 'P2P Platformunu Aç',
},
};
export function P2PModal({ isOpen, onClose, onOpenP2P }: P2PModalProps) {
const [lang, setLang] = useState<Language>('en');
const content = CONTENT[lang];
const isRTL = lang === 'ckb';
if (!isOpen) return null;
const handleOpenP2P = () => {
onOpenP2P();
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div
className={cn(
'relative w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden',
isRTL && 'direction-rtl'
)}
dir={isRTL ? 'rtl' : 'ltr'}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground">{content.title}</h2>
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{/* Language Selector */}
<div className="flex gap-2 p-4 pb-0">
{LANGUAGES.map((l) => (
<button
key={l.code}
onClick={() => setLang(l.code)}
className={cn(
'px-3 py-1.5 rounded-full text-xs font-medium transition-colors',
lang === l.code
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
)}
>
{l.label}
</button>
))}
</div>
{/* Content */}
<div className="p-4 space-y-4">
<p className="text-sm text-muted-foreground">{content.subtitle}</p>
{/* First Time Info */}
<div className="bg-amber-500/10 border border-amber-500/30 rounded-xl p-4">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
<div className="space-y-2">
<p className="text-sm font-medium text-amber-400">{content.firstTime}</p>
<ol className="space-y-1.5">
{content.steps.map((step, i) => (
<li key={i} className="text-xs text-amber-200/80 flex gap-2">
<span className="font-semibold text-amber-400">{i + 1}.</span>
<span>{step}</span>
</li>
))}
</ol>
</div>
</div>
</div>
{/* Note */}
<p className="text-xs text-muted-foreground">{content.note}</p>
</div>
{/* Action Button */}
<div className="p-4 pt-0">
<button
onClick={handleOpenP2P}
className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-cyan-500 hover:bg-cyan-600 text-white font-medium rounded-xl transition-colors"
>
<ExternalLink className="w-5 h-5" />
{content.button}
</button>
</div>
</div>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { ExternalLink } from 'lucide-react';
import { useTelegram } from '@/hooks/useTelegram';
interface SocialLink {
name: string;
url: string;
icon: string;
color: string;
description: string;
}
const SOCIAL_LINKS: SocialLink[] = [
{
name: 'Instagram',
url: 'https://www.instagram.com/pezkuwichain',
icon: '📸',
color: 'from-pink-500 to-purple-600',
description: 'Wêne û Story',
},
{
name: 'TikTok',
url: 'https://www.tiktok.com/@pezkuwi.chain',
icon: '🎵',
color: 'from-gray-800 to-gray-900',
description: 'Vîdyoyên kurt',
},
{
name: 'Snapchat',
url: 'https://www.snapchat.com/add/pezkuwichain',
icon: '👻',
color: 'from-yellow-400 to-yellow-500',
description: 'Snap bike!',
},
{
name: 'Telegram',
url: 'https://t.me/pezkuwichain',
icon: '📢',
color: 'from-blue-400 to-blue-600',
description: 'Kanala fermî',
},
{
name: 'X (Twitter)',
url: 'https://x.com/pezkuwichain',
icon: '𝕏',
color: 'from-gray-700 to-gray-900',
description: 'Nûçeyên rojane',
},
{
name: 'YouTube',
url: 'https://www.youtube.com/@SatoshiQazi',
icon: '▶️',
color: 'from-red-500 to-red-700',
description: 'Vîdyoyên me',
},
{
name: 'Facebook',
url: 'https://www.facebook.com/people/Pezkuwi-Chain/61587122224932/',
icon: '📘',
color: 'from-blue-600 to-blue-800',
description: 'Rûpela fermî',
},
{
name: 'Discord',
url: 'https://discord.gg/Y3VyEC6h8W',
icon: '💬',
color: 'from-indigo-500 to-purple-600',
description: 'Civaka me',
},
];
export function SocialLinks() {
const { openLink, hapticImpact } = useTelegram();
const handleClick = (url: string) => {
hapticImpact('light');
openLink(url);
};
return (
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
<h3 className="font-medium text-foreground mb-3 flex items-center gap-2">
<ExternalLink className="w-4 h-4 text-primary" />
Me bişopîne
</h3>
<p className="text-sm text-muted-foreground mb-4">
Bi me re têkiliyê ragire û nûçeyên herî dawî bistîne!
</p>
<div className="grid grid-cols-2 gap-2">
{SOCIAL_LINKS.map((link) => (
<button
key={link.name}
onClick={() => handleClick(link.url)}
className={`
flex items-center gap-3 p-3 rounded-xl
bg-gradient-to-r ${link.color} bg-opacity-10
hover:opacity-90 transition-opacity
border border-white/10
`}
>
<span className="text-2xl">{link.icon}</span>
<div className="text-left">
<p className="text-sm font-medium text-white">{link.name}</p>
<p className="text-xs text-white/70">{link.description}</p>
</div>
</button>
))}
</div>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Update Notification Component
* Shows when a new version is available
*/
import { RefreshCw, X } from 'lucide-react';
import { useVersion } from '@/hooks/useVersion';
import { useTelegram } from '@/hooks/useTelegram';
export function UpdateNotification() {
const { hasUpdate, forceUpdate, dismissUpdate, currentVersion } = useVersion();
const { hapticImpact } = useTelegram();
if (!hasUpdate) return null;
const handleUpdate = () => {
hapticImpact('medium');
forceUpdate();
};
const handleDismiss = () => {
hapticImpact('light');
dismissUpdate();
};
return (
<div className="fixed bottom-20 left-4 right-4 z-50 animate-in slide-in-from-bottom-4 fade-in duration-300">
<div className="bg-primary text-primary-foreground rounded-xl p-4 shadow-lg border border-primary/20">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center flex-shrink-0">
<RefreshCw className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm">Guhertoya heye!</h4>
<p className="text-xs opacity-90 mt-0.5">
Ji bo taybetmendiyên û rastkirinên ewlehiyê nûve bike.
</p>
<p className="text-[10px] opacity-70 mt-1">v{currentVersion}</p>
</div>
<button
onClick={handleDismiss}
className="p-1 rounded-lg hover:bg-white/20 transition-colors flex-shrink-0"
aria-label="Dismiss"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex gap-2 mt-3">
<button
onClick={handleDismiss}
className="flex-1 py-2 px-3 rounded-lg bg-white/10 hover:bg-white/20 text-sm font-medium transition-colors"
>
Paşê
</button>
<button
onClick={handleUpdate}
className="flex-1 py-2 px-3 rounded-lg bg-white text-primary text-sm font-medium hover:bg-white/90 transition-colors flex items-center justify-center gap-2"
>
<RefreshCw className="w-4 h-4" />
Nûve bike
</button>
</div>
</div>
</div>
);
}
export default UpdateNotification;
+39
View File
@@ -0,0 +1,39 @@
/**
* Version Info Component
* Displays current app version
*/
import { Info } from 'lucide-react';
import { useVersion } from '@/hooks/useVersion';
interface VersionInfoProps {
className?: string;
showBuildTime?: boolean;
}
export function VersionInfo({ className = '', showBuildTime = false }: VersionInfoProps) {
const { currentVersion, buildTime } = useVersion();
const formattedBuildTime = new Date(buildTime).toLocaleDateString('ku', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
return (
<div className={`flex items-center gap-2 text-xs text-muted-foreground ${className}`}>
<Info className="w-3 h-3" />
<span>v{currentVersion}</span>
{showBuildTime && (
<>
<span className="opacity-50"></span>
<span className="opacity-70">{formattedBuildTime}</span>
</>
)}
</div>
);
}
export default VersionInfo;
+470
View File
@@ -0,0 +1,470 @@
/**
* Fund Fees Modal - XCM Teleport HEZ to Teyerchains
* Allows users to transfer HEZ from relay chain to Asset Hub or People chain for fees
*/
import { useState, useEffect } from 'react';
import { X, ArrowDown, Loader2, CheckCircle, AlertCircle, Fuel, Info } from 'lucide-react';
import { useWallet } from '@/contexts/WalletContext';
import { useTelegram } from '@/hooks/useTelegram';
type TargetChain = 'asset-hub' | 'people';
interface ChainInfo {
id: TargetChain;
name: string;
description: string;
teyrchainId: number;
color: string;
}
const TARGET_CHAINS: ChainInfo[] = [
{
id: 'asset-hub',
name: 'Asset Hub',
description: 'Ji bo PEZ veguheztin',
teyrchainId: 1000,
color: 'blue',
},
{
id: 'people',
name: 'People Chain',
description: 'Ji bo nasname',
teyrchainId: 1004,
color: 'purple',
},
];
interface Props {
isOpen: boolean;
onClose: () => void;
}
export function FundFeesModal({ isOpen, onClose }: Props) {
const { api, assetHubApi, peopleApi, address, keypair } = useWallet();
const { hapticImpact, showAlert } = useTelegram();
const [targetChain, setTargetChain] = useState<TargetChain>('asset-hub');
const [amount, setAmount] = useState('0.5');
const [isTransferring, setIsTransferring] = useState(false);
const [txStatus, setTxStatus] = useState<'idle' | 'signing' | 'pending' | 'success' | 'error'>(
'idle'
);
const [relayBalance, setRelayBalance] = useState<string>('--');
const [assetHubBalance, setAssetHubBalance] = useState<string>('--');
const [peopleBalance, setPeopleBalance] = useState<string>('--');
const selectedChain = TARGET_CHAINS.find((c) => c.id === targetChain) || TARGET_CHAINS[0];
// Fetch balances
useEffect(() => {
const fetchBalances = async () => {
if (!address) return;
// Relay chain balance
if (api) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const accountInfo = (await (api.query.system as any).account(address)) as {
data: { free: { toString(): string } };
};
const free = accountInfo.data.free.toString();
const balanceNum = Number(free) / 1e12;
setRelayBalance(balanceNum.toFixed(4));
} catch (err) {
console.error('Error fetching relay balance:', err);
setRelayBalance('0.0000');
}
} else {
setRelayBalance('--');
}
// Asset Hub balance
if (assetHubApi) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const accountInfo = (await (assetHubApi.query.system as any).account(address)) as {
data: { free: { toString(): string } };
};
const free = accountInfo.data.free.toString();
const balanceNum = Number(free) / 1e12;
setAssetHubBalance(balanceNum.toFixed(4));
} catch (err) {
console.error('Error fetching Asset Hub balance:', err);
setAssetHubBalance('0.0000');
}
} else {
setAssetHubBalance('--');
}
// People chain balance
if (peopleApi) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const accountInfo = (await (peopleApi.query.system as any).account(address)) as {
data: { free: { toString(): string } };
};
const free = accountInfo.data.free.toString();
const balanceNum = Number(free) / 1e12;
setPeopleBalance(balanceNum.toFixed(4));
} catch (err) {
console.error('Error fetching People chain balance:', err);
setPeopleBalance('0.0000');
}
} else {
setPeopleBalance('--');
}
};
if (isOpen) {
fetchBalances();
}
}, [api, assetHubApi, peopleApi, address, isOpen]);
const getTargetBalance = () => {
return targetChain === 'asset-hub' ? assetHubBalance : peopleBalance;
};
const handleTeleport = async () => {
if (!api || !address || !keypair) {
showAlert('Cizdan girêdayî nîne');
return;
}
if (!amount || parseFloat(amount) <= 0) {
showAlert('Mîqdarek rast binivîse');
return;
}
if (relayBalance === '--') {
showAlert('Relay Chain girêdayî nîne');
return;
}
const sendAmount = parseFloat(amount);
const currentBalance = parseFloat(relayBalance);
if (sendAmount > currentBalance) {
showAlert('Bakiye têrê nake');
return;
}
setIsTransferring(true);
setTxStatus('signing');
hapticImpact('medium');
try {
// Convert to smallest unit (12 decimals)
const amountInSmallestUnit = BigInt(Math.floor(parseFloat(amount) * 1e12));
// Get target teyrchain ID
const targetTeyrchainId = selectedChain.teyrchainId;
// Destination: Target teyrchain
const dest = {
V3: {
parents: 0,
interior: {
X1: { teyrchain: targetTeyrchainId },
},
},
};
// Beneficiary: Same account on target chain
const beneficiary = {
V3: {
parents: 0,
interior: {
X1: {
accountid32: {
network: null,
id: api.createType('AccountId32', address).toHex(),
},
},
},
},
};
// Assets: Native token (HEZ)
const assets = {
V3: [
{
id: {
Concrete: {
parents: 0,
interior: 'Here',
},
},
fun: {
Fungible: amountInSmallestUnit.toString(),
},
},
],
};
// Fee asset ID: Native HEZ token
const feeAssetId = {
V3: {
Concrete: {
parents: 0,
interior: 'Here',
},
},
};
const weightLimit = 'Unlimited';
// Create teleport transaction
const tx = api.tx.xcmPallet.limitedTeleportAssets(
dest,
beneficiary,
assets,
feeAssetId,
weightLimit
);
setTxStatus('pending');
const unsub = await tx.signAndSend(keypair, ({ status, dispatchError }) => {
if (status.isFinalized) {
if (dispatchError) {
let errorMessage = 'Teleport neserketî';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}`;
}
setTxStatus('error');
hapticImpact('heavy');
showAlert(errorMessage);
} else {
setTxStatus('success');
hapticImpact('medium');
// Reset after success
setTimeout(() => {
setAmount('0.5');
setTxStatus('idle');
onClose();
}, 2000);
}
setIsTransferring(false);
unsub();
}
});
} catch (error) {
console.error('Teleport error:', error);
setTxStatus('error');
setIsTransferring(false);
hapticImpact('heavy');
showAlert(error instanceof Error ? error.message : 'Çewtiyekî çêbû');
}
};
const setQuickAmount = (percent: number) => {
const balance = parseFloat(relayBalance);
if (balance > 0) {
const quickAmount = ((balance * percent) / 100).toFixed(4);
setAmount(quickAmount);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-md bg-background rounded-t-3xl p-6 pb-8 animate-slide-up">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-yellow-500/20 rounded-full">
<Fuel className="w-5 h-5 text-yellow-400" />
</div>
<div>
<h2 className="text-lg font-semibold">Fee Zêde Bike</h2>
<p className="text-xs text-muted-foreground">HEZ teleport</p>
</div>
</div>
<button
onClick={onClose}
disabled={isTransferring}
className="p-2 text-muted-foreground hover:text-white rounded-full"
>
<X className="w-5 h-5" />
</button>
</div>
{txStatus === 'success' ? (
<div className="py-8 text-center">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2">Serketî!</h3>
<p className="text-muted-foreground">
{amount} HEZ bo {selectedChain.name} hate şandin
</p>
</div>
) : txStatus === 'error' ? (
<div className="py-8 text-center">
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2">Neserketî</h3>
<button
onClick={() => setTxStatus('idle')}
className="mt-4 px-6 py-2 bg-muted rounded-lg"
>
Dîsa Biceribîne
</button>
</div>
) : (
<div className="space-y-4">
{/* Target Chain Selection */}
<div>
<label className="text-sm text-muted-foreground mb-2 block">Zincîra Armanc</label>
<div className="flex gap-2">
{TARGET_CHAINS.map((chain) => (
<button
key={chain.id}
onClick={() => {
setTargetChain(chain.id);
hapticImpact('light');
}}
className={`flex-1 p-3 rounded-xl border transition-all ${
targetChain === chain.id
? chain.id === 'asset-hub'
? 'border-blue-500 bg-blue-500/10'
: 'border-purple-500 bg-purple-500/10'
: 'border-border bg-muted/50'
}`}
>
<div
className={`w-2 h-2 rounded-full mb-1 mx-auto ${
chain.id === 'asset-hub' ? 'bg-blue-500' : 'bg-purple-500'
}`}
/>
<div className="text-sm font-medium">{chain.name}</div>
<div className="text-xs text-muted-foreground">{chain.description}</div>
</button>
))}
</div>
</div>
{/* Balance Display */}
<div className="bg-muted/50 rounded-xl p-4 space-y-3">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-sm text-muted-foreground">Relay Chain</span>
</div>
<span className="font-mono">{relayBalance} HEZ</span>
</div>
<div className="flex justify-center">
<ArrowDown className="w-5 h-5 text-yellow-500" />
</div>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
targetChain === 'asset-hub' ? 'bg-blue-500' : 'bg-purple-500'
}`}
/>
<span className="text-sm text-muted-foreground">{selectedChain.name}</span>
</div>
<span className="font-mono">{getTargetBalance()} HEZ</span>
</div>
</div>
{/* Info Box */}
<div
className={`p-3 rounded-lg flex gap-2 ${
targetChain === 'asset-hub'
? 'bg-blue-500/10 border border-blue-500/30'
: 'bg-purple-500/10 border border-purple-500/30'
}`}
>
<Info
className={`w-5 h-5 flex-shrink-0 ${
targetChain === 'asset-hub' ? 'text-blue-400' : 'text-purple-400'
}`}
/>
<p
className={`text-sm ${
targetChain === 'asset-hub' ? 'text-blue-400' : 'text-purple-400'
}`}
>
{selectedChain.description} kêmî 0.1 HEZ pêşniyarkirin.
</p>
</div>
{/* Amount Input */}
<div>
<label className="text-sm text-muted-foreground mb-2 block">Mîqdar (HEZ)</label>
<input
type="number"
step="0.0001"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.5"
className="w-full px-4 py-3 bg-muted rounded-xl text-lg font-mono"
disabled={isTransferring}
/>
{/* Quick Amount Buttons */}
<div className="flex gap-2 mt-2">
{[10, 25, 50, 100].map((percent) => (
<button
key={percent}
onClick={() => {
setQuickAmount(percent);
hapticImpact('light');
}}
className="flex-1 py-2 text-xs bg-muted hover:bg-muted/80 rounded-lg"
disabled={isTransferring}
>
{percent}%
</button>
))}
</div>
</div>
{/* Status Messages */}
{txStatus === 'signing' && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<p className="text-yellow-400 text-sm">Danûstandinê îmze bikin...</p>
</div>
)}
{txStatus === 'pending' && (
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<p className="text-blue-400 text-sm flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
XCM Teleport çêkirin...
</p>
</div>
)}
{/* Submit Button */}
<button
onClick={handleTeleport}
disabled={isTransferring || !amount || parseFloat(amount) <= 0}
className="w-full py-4 rounded-xl font-semibold bg-gradient-to-r from-green-600 to-yellow-500 text-white disabled:opacity-50 flex items-center justify-center gap-2"
>
{isTransferring ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
{txStatus === 'signing' ? 'Tê îmzekirin...' : 'Tê çêkirin...'}
</>
) : (
<>
<Fuel className="w-5 h-5" />
Bo {selectedChain.name} Bişîne
</>
)}
</button>
</div>
)}
</div>
</div>
);
}
+823
View File
@@ -0,0 +1,823 @@
/**
* Pools Modal Component
* Mobile-optimized liquidity pools interface for Telegram miniapp
*/
import { useState, useEffect } from 'react';
import { X, Droplets, Plus, Minus, AlertCircle, Check } from 'lucide-react';
import { useWallet } from '@/contexts/WalletContext';
import { useTelegram } from '@/hooks/useTelegram';
import { KurdistanSun } from '@/components/KurdistanSun';
interface PoolsModalProps {
isOpen: boolean;
onClose: () => void;
}
interface Pool {
id: string;
asset0: number;
asset1: number;
asset0Symbol: string;
asset1Symbol: string;
asset0Decimals: number;
asset1Decimals: number;
lpTokenId: number;
reserve0: number;
reserve1: number;
price: number;
userLpBalance?: number;
userShare?: number;
}
// Native token ID
const NATIVE_TOKEN_ID = -1;
// Token info mapping
const TOKEN_INFO: Record<number, { symbol: string; decimals: number; icon: string }> = {
[-1]: { symbol: 'HEZ', decimals: 12, icon: '/tokens/HEZ.png' },
1: { symbol: 'PEZ', decimals: 12, icon: '/tokens/PEZ.png' },
1000: { symbol: 'USDT', decimals: 6, icon: '/tokens/USDT.png' },
};
// Helper to convert asset ID to XCM Location format
const formatAssetLocation = (id: number) => {
if (id === NATIVE_TOKEN_ID) {
return { parents: 1, interior: 'Here' };
}
return { parents: 0, interior: { X2: [{ PalletInstance: 50 }, { GeneralIndex: id }] } };
};
export function PoolsModal({ isOpen, onClose }: PoolsModalProps) {
const { assetHubApi, keypair } = useWallet();
const { hapticImpact, hapticNotification } = useTelegram();
const [pools, setPools] = useState<Pool[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectedPool, setSelectedPool] = useState<Pool | null>(null);
const [isAddingLiquidity, setIsAddingLiquidity] = useState(false);
const [isRemovingLiquidity, setIsRemovingLiquidity] = useState(false);
const [amount0, setAmount0] = useState('');
const [amount1, setAmount1] = useState('');
const [lpAmountToRemove, setLpAmountToRemove] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [successMessage, setSuccessMessage] = useState('');
// Token balances
const [balances, setBalances] = useState<Record<string, string>>({
HEZ: '0',
PEZ: '0',
USDT: '0',
});
// Fetch balances and pools
useEffect(() => {
if (!isOpen || !assetHubApi || !keypair) return;
// Reset state when modal opens
setError('');
setAmount0('');
setAmount1('');
setLpAmountToRemove('');
let isCancelled = false;
const fetchData = async () => {
setIsLoading(true);
try {
// Add timeout wrapper for API calls
const withTimeout = <T,>(promise: Promise<T>, ms: number): Promise<T> => {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('API call timeout')), ms)
),
]);
};
// Fetch HEZ balance from Asset Hub (native token)
const hezAccount = (await withTimeout(
(assetHubApi.query.system as any).account(keypair.address),
10000
)) as any;
if (isCancelled) return;
const hezFree = hezAccount.data.free.toString();
setBalances((prev) => ({ ...prev, HEZ: (parseInt(hezFree) / 1e12).toFixed(4) }));
const pezResult = (await withTimeout(
(assetHubApi.query.assets as any).account(1, keypair.address),
10000
)) as any;
if (isCancelled) return;
if (pezResult.isSome) {
setBalances((prev) => ({
...prev,
PEZ: (parseInt(pezResult.unwrap().balance.toString()) / 1e12).toFixed(4),
}));
} else {
setBalances((prev) => ({ ...prev, PEZ: '0.0000' }));
}
const usdtResult = (await withTimeout(
(assetHubApi.query.assets as any).account(1000, keypair.address),
10000
)) as any;
if (isCancelled) return;
if (usdtResult.isSome) {
setBalances((prev) => ({
...prev,
USDT: (parseInt(usdtResult.unwrap().balance.toString()) / 1e6).toFixed(2),
}));
} else {
setBalances((prev) => ({ ...prev, USDT: '0.00' }));
}
// Fetch pools
const poolPairs: [number, number][] = [
[NATIVE_TOKEN_ID, 1], // HEZ-PEZ
[NATIVE_TOKEN_ID, 1000], // HEZ-USDT
];
const fetchedPools: Pool[] = [];
for (const [asset0, asset1] of poolPairs) {
if (isCancelled) return;
try {
const poolKey = [formatAssetLocation(asset0), formatAssetLocation(asset1)];
const poolInfo = await withTimeout(
assetHubApi.query.assetConversion.pools(poolKey),
10000
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (poolInfo && !(poolInfo as any).isEmpty) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const poolData = (poolInfo as any).unwrap().toJSON() as { lpToken: number };
const lpTokenId = poolData.lpToken;
const token0 = TOKEN_INFO[asset0];
const token1 = TOKEN_INFO[asset1];
// Get price quote
let price = 0;
let reserve0 = 0;
let reserve1 = 0;
try {
const oneUnit = BigInt(Math.pow(10, token0.decimals));
const quote = await withTimeout(
(assetHubApi.call as any).assetConversionApi.quotePriceExactTokensForTokens(
formatAssetLocation(asset0),
formatAssetLocation(asset1),
oneUnit.toString(),
true
),
10000
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (quote && !(quote as any).isNone) {
price =
Number(BigInt((quote as any).unwrap().toString())) /
Math.pow(10, token1.decimals);
// Estimate reserves from LP supply
const lpAsset = await withTimeout(
assetHubApi.query.poolAssets.asset(lpTokenId),
10000
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((lpAsset as any).isSome) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const lpSupply = Number((lpAsset as any).unwrap().toJSON().supply) / 1e12;
// Decimal correction factor for mixed-decimal pools
const decimalFactor = Math.pow(
10,
12 - (token0.decimals + token1.decimals) / 2
);
const sqrtPrice = Math.sqrt(price);
reserve0 = (lpSupply * decimalFactor) / sqrtPrice;
reserve1 = lpSupply * decimalFactor * sqrtPrice;
}
}
} catch (err) {
console.warn('Could not fetch price for pool:', err);
}
// Get user's LP balance
let userLpBalance = 0;
let userShare = 0;
try {
const userLp = await withTimeout(
assetHubApi.query.poolAssets.account(lpTokenId, keypair.address),
10000
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((userLp as any).isSome) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
userLpBalance = Number((userLp as any).unwrap().toJSON().balance) / 1e12;
const lpAsset = await withTimeout(
assetHubApi.query.poolAssets.asset(lpTokenId),
10000
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((lpAsset as any).isSome) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const totalSupply = Number((lpAsset as any).unwrap().toJSON().supply) / 1e12;
userShare = totalSupply > 0 ? (userLpBalance / totalSupply) * 100 : 0;
}
}
} catch (err) {
console.warn('Could not fetch user LP balance:', err);
}
fetchedPools.push({
id: `${asset0}-${asset1}`,
asset0,
asset1,
asset0Symbol: token0.symbol,
asset1Symbol: token1.symbol,
asset0Decimals: token0.decimals,
asset1Decimals: token1.decimals,
lpTokenId,
reserve0,
reserve1,
price,
userLpBalance,
userShare,
});
}
} catch (err) {
console.warn('Pool not found:', err);
}
}
if (!isCancelled) {
setPools(fetchedPools);
}
} catch (err) {
console.error('Failed to fetch pools:', err);
if (!isCancelled) {
setError('Bağlantı hatası - tekrar deneyin');
}
} finally {
if (!isCancelled) {
setIsLoading(false);
}
}
};
fetchData();
return () => {
isCancelled = true;
};
}, [isOpen, assetHubApi, keypair]);
// Auto-calculate amount1 based on pool price
useEffect(() => {
if (selectedPool && amount0 && selectedPool.price > 0) {
const calculated = parseFloat(amount0) * selectedPool.price;
setAmount1(calculated.toFixed(selectedPool.asset1Decimals === 6 ? 2 : 4));
} else {
setAmount1('');
}
}, [amount0, selectedPool]);
// Add liquidity
const handleAddLiquidity = async () => {
if (!assetHubApi || !keypair || !selectedPool || !amount0 || !amount1) return;
setIsSubmitting(true);
setError('');
try {
const amt0 = BigInt(
Math.floor(parseFloat(amount0) * Math.pow(10, selectedPool.asset0Decimals))
);
const amt1 = BigInt(
Math.floor(parseFloat(amount1) * Math.pow(10, selectedPool.asset1Decimals))
);
const minAmt0 = (amt0 * BigInt(90)) / BigInt(100); // 10% slippage
const minAmt1 = (amt1 * BigInt(90)) / BigInt(100);
const asset0Location = formatAssetLocation(selectedPool.asset0);
const asset1Location = formatAssetLocation(selectedPool.asset1);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tx = (assetHubApi.tx.assetConversion as any).addLiquidity(
asset0Location,
asset1Location,
amt0.toString(),
amt1.toString(),
minAmt0.toString(),
minAmt1.toString(),
keypair.address
);
// Wait for transaction to be finalized
await new Promise<void>((resolve, reject) => {
tx.signAndSend(
keypair,
({ status, dispatchError }: { status: any; dispatchError: any }) => {
if (status.isFinalized) {
if (dispatchError) {
let errorMsg = 'Zêdekirin neserketî';
if (dispatchError.isModule) {
const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule);
errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
} else if (dispatchError.toString) {
errorMsg = dispatchError.toString();
}
console.error('Add liquidity error:', errorMsg);
reject(new Error(errorMsg));
} else {
resolve();
}
}
}
).catch(reject);
});
setSuccessMessage(
`${amount0} ${selectedPool.asset0Symbol} + ${amount1} ${selectedPool.asset1Symbol} hate zêdekirin`
);
setSuccess(true);
hapticNotification('success');
setTimeout(() => {
setSuccess(false);
setIsAddingLiquidity(false);
setSelectedPool(null);
setAmount0('');
setAmount1('');
}, 2000);
} catch (err) {
console.error('Add liquidity failed:', err);
setError(err instanceof Error ? err.message : 'Zêdekirin neserketî');
hapticNotification('error');
} finally {
setIsSubmitting(false);
}
};
// Remove liquidity
const handleRemoveLiquidity = async () => {
if (!assetHubApi || !keypair || !selectedPool || !lpAmountToRemove) return;
const lpAmount = parseFloat(lpAmountToRemove);
if (lpAmount <= 0 || lpAmount > (selectedPool.userLpBalance || 0)) {
setError('Mîqdara LP ne derbasdar e');
hapticNotification('error');
return;
}
setIsSubmitting(true);
setError('');
try {
const lpAmountRaw = BigInt(Math.floor(lpAmount * 1e12));
// Calculate minimum amounts to receive (with 10% slippage)
const userShare =
((lpAmount / (selectedPool.userLpBalance || 1)) * (selectedPool.userShare || 0)) / 100;
const expectedAmt0 = selectedPool.reserve0 * userShare;
const expectedAmt1 = selectedPool.reserve1 * userShare;
const minAmt0 = BigInt(
Math.floor(expectedAmt0 * 0.9 * Math.pow(10, selectedPool.asset0Decimals))
);
const minAmt1 = BigInt(
Math.floor(expectedAmt1 * 0.9 * Math.pow(10, selectedPool.asset1Decimals))
);
const asset0Location = formatAssetLocation(selectedPool.asset0);
const asset1Location = formatAssetLocation(selectedPool.asset1);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tx = (assetHubApi.tx.assetConversion as any).removeLiquidity(
asset0Location,
asset1Location,
lpAmountRaw.toString(),
minAmt0.toString(),
minAmt1.toString(),
keypair.address
);
// Wait for transaction to be finalized
await new Promise<void>((resolve, reject) => {
tx.signAndSend(
keypair,
({ status, dispatchError }: { status: any; dispatchError: any }) => {
if (status.isFinalized) {
if (dispatchError) {
let errorMsg = 'Derxistin neserketî';
if (dispatchError.isModule) {
const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule);
errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
} else if (dispatchError.toString) {
errorMsg = dispatchError.toString();
}
console.error('Remove liquidity error:', errorMsg);
reject(new Error(errorMsg));
} else {
resolve();
}
}
}
).catch(reject);
});
setSuccessMessage(`${lpAmountToRemove} LP token hate vegerandin`);
setSuccess(true);
hapticNotification('success');
setTimeout(() => {
setSuccess(false);
setIsRemovingLiquidity(false);
setSelectedPool(null);
setLpAmountToRemove('');
}, 2000);
} catch (err) {
console.error('Remove liquidity failed:', err);
setError(err instanceof Error ? err.message : 'Derxistin neserketî');
hapticNotification('error');
} finally {
setIsSubmitting(false);
}
};
if (!isOpen) return null;
// Success screen
if (success) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="w-full max-w-md bg-card rounded-2xl p-6 text-center space-y-4">
<div className="w-16 h-16 mx-auto bg-green-500/20 rounded-full flex items-center justify-center">
<Check className="w-8 h-8 text-green-500" />
</div>
<h2 className="text-xl font-semibold">Serketî!</h2>
<p className="text-muted-foreground">{successMessage}</p>
</div>
</div>
);
}
// Add liquidity form
if (isAddingLiquidity && selectedPool) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<button
onClick={() => {
setIsAddingLiquidity(false);
setAmount0('');
setAmount1('');
setError('');
}}
className="text-muted-foreground"
>
Paş
</button>
<h2 className="text-lg font-semibold">Liquidity Zêde Bike</h2>
<div className="w-10" />
</div>
{/* Pool Info */}
<div className="p-4 bg-muted/30 border-b border-border">
<div className="flex items-center justify-center gap-2">
<span className="text-lg font-semibold">
{selectedPool.asset0Symbol}/{selectedPool.asset1Symbol}
</span>
</div>
<p className="text-center text-sm text-muted-foreground mt-1">
1 {selectedPool.asset0Symbol} = {selectedPool.price.toFixed(4)}{' '}
{selectedPool.asset1Symbol}
</p>
</div>
{/* Form */}
<div className="p-4 space-y-4">
{/* Amount 0 */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{selectedPool.asset0Symbol} Mîqdar</span>
<span className="text-muted-foreground">
Bakiye: {balances[selectedPool.asset0Symbol]}
</span>
</div>
<div className="flex gap-2">
<input
type="number"
value={amount0}
onChange={(e) => setAmount0(e.target.value)}
placeholder="0.00"
className="flex-1 px-4 py-3 bg-muted rounded-xl text-lg font-mono"
/>
<button
onClick={() => setAmount0(balances[selectedPool.asset0Symbol])}
className="px-3 py-2 bg-muted rounded-xl text-sm text-primary"
>
Max
</button>
</div>
</div>
<div className="flex justify-center">
<Plus className="w-5 h-5 text-muted-foreground" />
</div>
{/* Amount 1 */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{selectedPool.asset1Symbol} Mîqdar (otomatîk)
</span>
<span className="text-muted-foreground">
Bakiye: {balances[selectedPool.asset1Symbol]}
</span>
</div>
<input
type="text"
value={amount1}
readOnly
placeholder="0.00"
className="w-full px-4 py-3 bg-muted rounded-xl text-lg font-mono text-muted-foreground"
/>
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
{/* Submit Button or Loading Animation */}
{isSubmitting ? (
<div className="flex flex-col items-center justify-center py-4 space-y-3">
<KurdistanSun size={80} />
<p className="text-sm text-muted-foreground animate-pulse"> zêdekirin...</p>
</div>
) : (
<button
onClick={handleAddLiquidity}
disabled={!amount0 || !amount1 || parseFloat(amount0) <= 0}
className="w-full py-4 bg-gradient-to-r from-green-600 to-blue-600 text-white font-semibold rounded-xl disabled:opacity-50 flex items-center justify-center gap-2"
>
<Droplets className="w-5 h-5" />
Liquidity Zêde Bike
</button>
)}
</div>
</div>
</div>
);
}
// Remove liquidity form
if (isRemovingLiquidity && selectedPool) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<button
onClick={() => {
setIsRemovingLiquidity(false);
setLpAmountToRemove('');
setError('');
}}
className="text-muted-foreground"
>
Paş
</button>
<h2 className="text-lg font-semibold">Liquidity Derxe</h2>
<div className="w-10" />
</div>
{/* Pool Info */}
<div className="p-4 bg-muted/30 border-b border-border">
<div className="flex items-center justify-center gap-2">
<span className="text-lg font-semibold">
{selectedPool.asset0Symbol}/{selectedPool.asset1Symbol}
</span>
</div>
<p className="text-center text-sm text-muted-foreground mt-1">
LP Bakiye: {selectedPool.userLpBalance?.toFixed(4) || '0'} LP
</p>
</div>
{/* Form */}
<div className="p-4 space-y-4">
{/* LP Amount */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">LP Token Mîqdar</span>
<span className="text-muted-foreground">
Max: {selectedPool.userLpBalance?.toFixed(4) || '0'}
</span>
</div>
<div className="flex gap-2">
<input
type="number"
value={lpAmountToRemove}
onChange={(e) => setLpAmountToRemove(e.target.value)}
placeholder="0.00"
className="flex-1 px-4 py-3 bg-muted rounded-xl text-lg font-mono"
/>
<button
onClick={() => setLpAmountToRemove(selectedPool.userLpBalance?.toString() || '0')}
className="px-3 py-2 bg-muted rounded-xl text-sm text-primary"
>
Max
</button>
</div>
</div>
{/* Estimated Returns */}
{lpAmountToRemove && parseFloat(lpAmountToRemove) > 0 && (
<div className="bg-muted/50 rounded-xl p-3 space-y-2 text-sm">
<p className="text-muted-foreground">Texmînî vegerandin:</p>
<div className="flex justify-between">
<span>{selectedPool.asset0Symbol}</span>
<span className="font-mono">
~
{(
(((parseFloat(lpAmountToRemove) / (selectedPool.userLpBalance || 1)) *
(selectedPool.userShare || 0)) /
100) *
selectedPool.reserve0
).toFixed(4)}
</span>
</div>
<div className="flex justify-between">
<span>{selectedPool.asset1Symbol}</span>
<span className="font-mono">
~
{(
(((parseFloat(lpAmountToRemove) / (selectedPool.userLpBalance || 1)) *
(selectedPool.userShare || 0)) /
100) *
selectedPool.reserve1
).toFixed(selectedPool.asset1Decimals === 6 ? 2 : 4)}
</span>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
{/* Submit Button or Loading Animation */}
{isSubmitting ? (
<div className="flex flex-col items-center justify-center py-4 space-y-3">
<KurdistanSun size={80} />
<p className="text-sm text-muted-foreground animate-pulse"> derxistin...</p>
</div>
) : (
<button
onClick={handleRemoveLiquidity}
disabled={!lpAmountToRemove || parseFloat(lpAmountToRemove) <= 0}
className="w-full py-4 bg-gradient-to-r from-red-600 to-orange-600 text-white font-semibold rounded-xl disabled:opacity-50 flex items-center justify-center gap-2"
>
<Minus className="w-5 h-5" />
Liquidity Derxe
</button>
)}
</div>
</div>
</div>
);
}
// Pool list
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold">Liquidity Pools</h2>
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-3">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-8">
<KurdistanSun size={80} />
<p className="text-muted-foreground mt-3 animate-pulse"> barkirin...</p>
</div>
) : pools.length === 0 ? (
<div className="text-center py-8">
<Droplets className="w-12 h-12 mx-auto text-muted-foreground mb-2" />
<p className="text-muted-foreground">Pool tune</p>
</div>
) : (
pools.map((pool) => (
<div key={pool.id} className="bg-muted/50 rounded-xl p-4 border border-border">
{/* Pool Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="flex -space-x-2">
<img
src={TOKEN_INFO[pool.asset0]?.icon}
alt={pool.asset0Symbol}
className="w-8 h-8 rounded-full border-2 border-card"
/>
<img
src={TOKEN_INFO[pool.asset1]?.icon}
alt={pool.asset1Symbol}
className="w-8 h-8 rounded-full border-2 border-card"
/>
</div>
<span className="font-semibold">
{pool.asset0Symbol}/{pool.asset1Symbol}
</span>
</div>
</div>
{/* Pool Stats */}
<div className="grid grid-cols-2 gap-2 text-sm mb-3">
<div>
<span className="text-muted-foreground">Rezerv {pool.asset0Symbol}</span>
<p className="font-mono">
{pool.reserve0.toLocaleString('en-US', { maximumFractionDigits: 0 })}
</p>
</div>
<div>
<span className="text-muted-foreground">Rezerv {pool.asset1Symbol}</span>
<p className="font-mono">
{pool.reserve1.toLocaleString('en-US', { maximumFractionDigits: 0 })}
</p>
</div>
</div>
{/* User Position */}
{pool.userLpBalance && pool.userLpBalance > 0 && (
<div className="bg-green-500/10 border border-green-500/30 rounded-lg p-2 mb-3 text-sm">
<div className="flex justify-between">
<span className="text-green-400">Pozîsyona Te</span>
<span className="text-green-400 font-mono">
{pool.userShare?.toFixed(2)}%
</span>
</div>
<div className="text-xs text-muted-foreground mt-1">
LP Token: {pool.userLpBalance.toFixed(4)}
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2">
<button
onClick={() => {
hapticImpact('light');
setSelectedPool(pool);
setIsAddingLiquidity(true);
}}
className="flex-1 py-2 bg-gradient-to-r from-green-600/20 to-blue-600/20 border border-green-500/30 text-green-400 font-medium rounded-lg flex items-center justify-center gap-1 text-sm"
>
<Plus className="w-4 h-4" />
Zêde Bike
</button>
{pool.userLpBalance && pool.userLpBalance > 0 && (
<button
onClick={() => {
hapticImpact('light');
setSelectedPool(pool);
setIsRemovingLiquidity(true);
}}
className="flex-1 py-2 bg-gradient-to-r from-red-600/20 to-orange-600/20 border border-red-500/30 text-red-400 font-medium rounded-lg flex items-center justify-center gap-1 text-sm"
>
<Minus className="w-4 h-4" />
Derxe
</button>
)}
</div>
</div>
))
)}
</div>
</div>
</div>
);
}
+453
View File
@@ -0,0 +1,453 @@
/**
* Swap Modal Component
* Mobile-optimized token swap interface for Telegram miniapp
*/
import { useState, useEffect, useCallback } from 'react';
import { X, ArrowDownUp, RefreshCw, AlertCircle, Check } from 'lucide-react';
import { useWallet } from '@/contexts/WalletContext';
import { useTelegram } from '@/hooks/useTelegram';
import { KurdistanSun } from '@/components/KurdistanSun';
interface SwapModalProps {
isOpen: boolean;
onClose: () => void;
}
// Token configuration
const TOKENS = [
{ symbol: 'HEZ', name: 'Hezkurd', assetId: -1, decimals: 12, icon: '/tokens/HEZ.png' },
{ symbol: 'PEZ', name: 'Pezkuwi', assetId: 1, decimals: 12, icon: '/tokens/PEZ.png' },
{ symbol: 'USDT', name: 'Tether', assetId: 1000, decimals: 6, icon: '/tokens/USDT.png' },
];
// Native token ID for relay chain HEZ
const NATIVE_TOKEN_ID = -1;
// Helper to convert asset ID to XCM Location format
const formatAssetLocation = (id: number) => {
if (id === NATIVE_TOKEN_ID) {
return { parents: 1, interior: 'Here' };
}
return { parents: 0, interior: { X2: [{ PalletInstance: 50 }, { GeneralIndex: id }] } };
};
export function SwapModal({ isOpen, onClose }: SwapModalProps) {
const { assetHubApi, keypair } = useWallet();
const { hapticImpact, hapticNotification } = useTelegram();
const [fromToken, setFromToken] = useState(TOKENS[0]); // HEZ
const [toToken, setToToken] = useState(TOKENS[1]); // PEZ
const [fromAmount, setFromAmount] = useState('');
const [toAmount, setToAmount] = useState('');
const [exchangeRate, setExchangeRate] = useState<number | null>(null);
const [isLoadingRate, setIsLoadingRate] = useState(false);
const [isSwapping, setIsSwapping] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
// Token balances
const [balances, setBalances] = useState<Record<string, string>>({
HEZ: '0',
PEZ: '0',
USDT: '0',
});
// Fetch balances from Asset Hub (where swaps happen)
useEffect(() => {
if (!isOpen || !assetHubApi || !keypair) return;
// Reset state when modal opens
setError('');
setFromAmount('');
setToAmount('');
const fetchBalances = async () => {
try {
// HEZ balance from Asset Hub (native token for fees and swaps)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hezAccount = await (assetHubApi.query.system as any).account(keypair.address);
const hezFree = hezAccount.data.free.toString();
const hezBalance = (parseInt(hezFree) / 1e12).toFixed(4);
// PEZ balance (Asset 1)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pezResult = await (assetHubApi.query.assets as any).account(1, keypair.address);
const pezBalance = pezResult.isSome
? (parseInt(pezResult.unwrap().balance.toString()) / 1e12).toFixed(4)
: '0.0000';
// USDT balance (Asset 1000)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const usdtResult = await (assetHubApi.query.assets as any).account(1000, keypair.address);
const usdtBalance = usdtResult.isSome
? (parseInt(usdtResult.unwrap().balance.toString()) / 1e6).toFixed(2)
: '0.00';
// Update all balances at once
setBalances({
HEZ: hezBalance,
PEZ: pezBalance,
USDT: usdtBalance,
});
} catch (err) {
console.error('Failed to fetch balances:', err);
}
};
fetchBalances();
}, [assetHubApi, keypair, isOpen]);
// Fetch exchange rate
const fetchExchangeRate = useCallback(async () => {
if (!assetHubApi || fromToken.symbol === toToken.symbol) {
setExchangeRate(null);
return;
}
setIsLoadingRate(true);
try {
// Sort assets for pool query (native token first)
const fromId = fromToken.assetId;
const toId = toToken.assetId;
const [asset1, asset2] =
fromId === NATIVE_TOKEN_ID
? [fromId, toId]
: toId === NATIVE_TOKEN_ID
? [toId, fromId]
: fromId < toId
? [fromId, toId]
: [toId, fromId];
const poolKey = [formatAssetLocation(asset1), formatAssetLocation(asset2)];
// Check if pool exists
const poolInfo = await assetHubApi.query.assetConversion.pools(poolKey);
if (poolInfo && !poolInfo.isEmpty) {
// Get quote from runtime API
const decimals1 = asset1 === 1000 ? 6 : 12;
const decimals2 = asset2 === 1000 ? 6 : 12;
const oneUnit = BigInt(Math.pow(10, decimals1));
const quote = await (
assetHubApi.call as any
).assetConversionApi.quotePriceExactTokensForTokens(
formatAssetLocation(asset1),
formatAssetLocation(asset2),
oneUnit.toString(),
true
);
if (quote && !quote.isNone) {
const priceRaw = quote.unwrap().toString();
const price = Number(BigInt(priceRaw)) / Math.pow(10, decimals2);
// Calculate rate based on direction
const rate = fromId === asset1 ? price : 1 / price;
setExchangeRate(rate);
} else {
setExchangeRate(null);
}
} else {
setExchangeRate(null);
}
} catch (err) {
console.error('Failed to fetch exchange rate:', err);
setExchangeRate(null);
} finally {
setIsLoadingRate(false);
}
}, [assetHubApi, fromToken, toToken]);
// Fetch rate when tokens change
useEffect(() => {
if (isOpen) {
fetchExchangeRate();
}
}, [isOpen, fetchExchangeRate]);
// Calculate toAmount when fromAmount or rate changes
useEffect(() => {
if (fromAmount && exchangeRate) {
const calculated = parseFloat(fromAmount) * exchangeRate;
setToAmount(calculated.toFixed(toToken.decimals === 6 ? 2 : 4));
} else {
setToAmount('');
}
}, [fromAmount, exchangeRate, toToken.decimals]);
// Swap tokens
const handleSwapTokens = () => {
hapticImpact('light');
const temp = fromToken;
setFromToken(toToken);
setToToken(temp);
setFromAmount('');
setToAmount('');
};
// Execute swap
const handleSwap = async () => {
if (!assetHubApi || !keypair || !fromAmount || !exchangeRate) return;
const fromBalance = parseFloat(balances[fromToken.symbol] || '0');
const swapAmount = parseFloat(fromAmount);
if (swapAmount > fromBalance) {
setError('Bakiye têrê nake');
hapticNotification('error');
return;
}
setIsSwapping(true);
setError('');
try {
const amountIn = BigInt(Math.floor(swapAmount * Math.pow(10, fromToken.decimals)));
const minAmountOut = BigInt(
Math.floor(parseFloat(toAmount) * 0.95 * Math.pow(10, toToken.decimals))
); // 5% slippage
// Build swap path using XCM Locations
const fromLocation = formatAssetLocation(fromToken.assetId);
const toLocation = formatAssetLocation(toToken.assetId);
// Check if we need multi-hop (e.g., PEZ -> USDT needs PEZ -> HEZ -> USDT)
let path;
if (fromToken.assetId !== NATIVE_TOKEN_ID && toToken.assetId !== NATIVE_TOKEN_ID) {
// Multi-hop through native token
const nativeLocation = formatAssetLocation(NATIVE_TOKEN_ID);
path = [fromLocation, nativeLocation, toLocation];
} else {
path = [fromLocation, toLocation];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tx = (assetHubApi.tx.assetConversion as any).swapExactTokensForTokens(
path,
amountIn.toString(),
minAmountOut.toString(),
keypair.address,
true
);
// Wait for transaction to be finalized
await new Promise<void>((resolve, reject) => {
tx.signAndSend(
keypair,
({ status, dispatchError }: { status: any; dispatchError: any }) => {
if (status.isFinalized) {
if (dispatchError) {
let errorMsg = 'Swap neserketî';
if (dispatchError.isModule) {
const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule);
errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
} else if (dispatchError.toString) {
errorMsg = dispatchError.toString();
}
console.error('Swap error:', errorMsg);
reject(new Error(errorMsg));
} else {
resolve();
}
}
}
).catch(reject);
});
setSuccess(true);
hapticNotification('success');
// Reset after success
setTimeout(() => {
setSuccess(false);
setFromAmount('');
setToAmount('');
onClose();
}, 2000);
} catch (err) {
console.error('Swap failed:', err);
setError(err instanceof Error ? err.message : 'Swap neserketî');
hapticNotification('error');
} finally {
setIsSwapping(false);
}
};
if (!isOpen) return null;
if (success) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="w-full max-w-md bg-card rounded-2xl p-6 text-center space-y-4">
<div className="w-16 h-16 mx-auto bg-green-500/20 rounded-full flex items-center justify-center">
<Check className="w-8 h-8 text-green-500" />
</div>
<h2 className="text-xl font-semibold">Swap Serketî!</h2>
<p className="text-muted-foreground">
{fromAmount} {fromToken.symbol} {toAmount} {toToken.symbol}
</p>
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold">Token Swap</h2>
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* From Token */}
<div className="bg-muted/50 rounded-xl p-4 space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Ji (From)</span>
<select
value={fromToken.symbol}
onChange={(e) => {
const token = TOKENS.find((t) => t.symbol === e.target.value);
if (token) {
if (token.symbol === toToken.symbol) {
setToToken(fromToken);
}
setFromToken(token);
}
}}
className="bg-background border border-border rounded-lg px-3 py-1.5 text-sm font-medium"
>
{TOKENS.map((t) => (
<option key={t.symbol} value={t.symbol}>
{t.symbol}
</option>
))}
</select>
</div>
<input
type="number"
value={fromAmount}
onChange={(e) => setFromAmount(e.target.value)}
placeholder="0.00"
className="w-full bg-transparent text-2xl font-bold outline-none"
/>
<div className="flex justify-between items-center text-sm">
<button
onClick={() => setFromAmount(balances[fromToken.symbol])}
className="text-primary hover:underline"
>
Max
</button>
<span className="text-muted-foreground">
Bakiye: {balances[fromToken.symbol]} {fromToken.symbol}
</span>
</div>
</div>
{/* Swap Button */}
<div className="flex justify-center">
<button
onClick={handleSwapTokens}
className="p-2 bg-muted rounded-full hover:bg-muted/80 transition-colors"
>
<ArrowDownUp className="w-5 h-5" />
</button>
</div>
{/* To Token */}
<div className="bg-muted/50 rounded-xl p-4 space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Bo (To)</span>
<select
value={toToken.symbol}
onChange={(e) => {
const token = TOKENS.find((t) => t.symbol === e.target.value);
if (token) {
if (token.symbol === fromToken.symbol) {
setFromToken(toToken);
}
setToToken(token);
}
}}
className="bg-background border border-border rounded-lg px-3 py-1.5 text-sm font-medium"
>
{TOKENS.map((t) => (
<option key={t.symbol} value={t.symbol}>
{t.symbol}
</option>
))}
</select>
</div>
<input
type="text"
value={toAmount}
readOnly
placeholder="0.00"
className="w-full bg-transparent text-2xl font-bold outline-none text-muted-foreground"
/>
<div className="flex justify-end text-sm">
<span className="text-muted-foreground">
Bakiye: {balances[toToken.symbol]} {toToken.symbol}
</span>
</div>
</div>
{/* Exchange Rate */}
<div className="bg-muted/30 rounded-xl p-3 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Rêjeya Guherandinê</span>
<span className="flex items-center gap-2">
{isLoadingRate ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : exchangeRate ? (
`1 ${fromToken.symbol} = ${exchangeRate.toFixed(4)} ${toToken.symbol}`
) : (
<span className="text-yellow-500">Pool tune</span>
)}
<button onClick={fetchExchangeRate} className="p-1 hover:bg-muted rounded">
<RefreshCw className="w-3 h-3" />
</button>
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Slippage</span>
<span>5%</span>
</div>
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
{/* Swap Button */}
{/* Swap Button or Loading Animation */}
{isSwapping ? (
<div className="flex flex-col items-center justify-center py-4 space-y-3">
<KurdistanSun size={80} />
<p className="text-sm text-muted-foreground animate-pulse"> guhertin...</p>
</div>
) : (
<button
onClick={handleSwap}
disabled={!fromAmount || !exchangeRate || parseFloat(fromAmount) <= 0}
className="w-full py-4 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-xl disabled:opacity-50 flex items-center justify-center gap-2"
>
{!exchangeRate ? 'Pool Tune' : 'Swap Bike'}
</button>
)}
</div>
</div>
</div>
);
}
+941
View File
@@ -0,0 +1,941 @@
/**
* Tokens Card Component
* Token management with search, add/remove, show/hide functionality
* Fetches live data from blockchain and CoinGecko prices
*/
import { useState, useEffect, useCallback } from 'react';
import {
Coins,
Search,
Plus,
Settings,
Eye,
EyeOff,
X,
ChevronDown,
ChevronUp,
RefreshCw,
Send,
TrendingUp,
TrendingDown,
Fuel,
} from 'lucide-react';
import { useWallet } from '@/contexts/WalletContext';
import { useTelegram } from '@/hooks/useTelegram';
import {
subscribeToConnection,
getLastError,
getAssetHubAPI,
getPeopleAPI,
getConnectionState,
} from '@/lib/rpc-manager';
import { FundFeesModal } from './FundFeesModal';
// Asset IDs matching pwap/web configuration
const ASSET_IDS = {
WHEZ: 0, // Wrapped HEZ (12 decimals)
PEZ: 1, // PEZ token (12 decimals)
WUSDT: 1000, // Wrapped USDT (6 decimals) - displayed as USDT
BTC: 4, // wBTC
ETH: 5, // wETH
DOT: 6, // wDOT
BNB: 7, // wBNB (assumed)
};
// LP Token IDs (from poolAssets pallet)
const LP_TOKEN_IDS = {
HEZ_PEZ: 0, // HEZ-PEZ LP (12 decimals)
HEZ_USDT: 1, // HEZ-USDT LP (12 decimals)
};
// CoinGecko ID mapping for tokens
const COINGECKO_IDS: Record<string, string> = {
DOT: 'polkadot',
BTC: 'bitcoin',
ETH: 'ethereum',
BNB: 'binancecoin',
USDT: 'tether',
HEZ: 'hez-token', // Will fallback to DOT/3 if not found
PEZ: 'pez-token', // Will fallback to DOT/10 if not found
};
interface PriceData {
usd: number;
usd_24h_change: number;
}
// Token configurations
interface TokenConfig {
assetId: number;
symbol: string;
displaySymbol: string;
name: string;
decimals: number;
logo: string;
isDefault: boolean;
priority: number; // Lower = higher in list
}
const DEFAULT_TOKENS: TokenConfig[] = [
{
assetId: -1, // Special: native token
symbol: 'HEZ',
displaySymbol: 'HEZ',
name: 'HEZ Native Token',
decimals: 12,
logo: '/tokens/HEZ.png',
isDefault: true,
priority: 0,
},
{
assetId: ASSET_IDS.PEZ,
symbol: 'PEZ',
displaySymbol: 'PEZ',
name: 'PEZ Governance Token',
decimals: 12,
logo: '/tokens/PEZ.png',
isDefault: true,
priority: 1,
},
{
assetId: ASSET_IDS.WUSDT,
symbol: 'wUSDT',
displaySymbol: 'USDT', // User sees USDT, backend uses wUSDT
name: 'Tether USD',
decimals: 6,
logo: '/tokens/USDT.png',
isDefault: true,
priority: 2,
},
{
assetId: ASSET_IDS.DOT,
symbol: 'wDOT',
displaySymbol: 'DOT',
name: 'Polkadot',
decimals: 12,
logo: '/tokens/DOT.png',
isDefault: true,
priority: 3,
},
{
assetId: ASSET_IDS.BTC,
symbol: 'wBTC',
displaySymbol: 'BTC',
name: 'Bitcoin',
decimals: 12,
logo: '/tokens/BTC.png',
isDefault: true,
priority: 4,
},
{
assetId: ASSET_IDS.ETH,
symbol: 'wETH',
displaySymbol: 'ETH',
name: 'Ethereum',
decimals: 12,
logo: '/tokens/ETH.png',
isDefault: true,
priority: 5,
},
{
assetId: ASSET_IDS.BNB,
symbol: 'wBNB',
displaySymbol: 'BNB',
name: 'BNB',
decimals: 12,
logo: '/tokens/BNB.png',
isDefault: true,
priority: 6,
},
];
// LP Token configurations (separate from regular tokens)
const LP_TOKENS: TokenConfig[] = [
{
assetId: -100 - LP_TOKEN_IDS.HEZ_PEZ, // Use negative IDs starting from -100 to distinguish from regular tokens
symbol: 'HEZ-PEZ-LP',
displaySymbol: 'HEZ-PEZ LP',
name: 'HEZ-PEZ Liquidity Pool',
decimals: 12,
logo: '', // Uses initials fallback
isDefault: true,
priority: 10,
},
{
assetId: -100 - LP_TOKEN_IDS.HEZ_USDT, // -101
symbol: 'HEZ-USDT-LP',
displaySymbol: 'HEZ-USDT LP',
name: 'HEZ-USDT Liquidity Pool',
decimals: 12,
logo: '', // Uses initials fallback
isDefault: true,
priority: 11,
},
];
interface TokenBalance extends TokenConfig {
balance: string;
isHidden: boolean;
priceUsd?: number;
priceChange24h?: number;
valueUsd?: number;
}
interface Props {
onSendToken?: (token: TokenBalance) => void;
}
export function TokensCard({ onSendToken }: Props) {
const { address, balance: hezBalance } = useWallet();
const { hapticImpact } = useTelegram();
const [rpcConnected, setRpcConnected] = useState(false);
const [endpointName, setEndpointName] = useState<string | null>(null);
// Track RPC connection state
useEffect(() => {
// Get initial state
const initialState = getConnectionState();
setRpcConnected(initialState.isConnected);
setEndpointName(initialState.endpoint?.name || null);
// Subscribe to changes
const unsubscribe = subscribeToConnection((connected, endpoint) => {
setRpcConnected(connected);
setEndpointName(endpoint?.name || null);
});
return () => unsubscribe();
}, []);
// Fetch multi-chain HEZ balances (Asset Hub & People Chain)
useEffect(() => {
if (!address) return;
const fetchMultiChainBalances = async () => {
// Asset Hub HEZ balance
const assetHubApi = getAssetHubAPI();
if (assetHubApi) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const accountInfo = (await (assetHubApi.query.system as any).account(address)) as {
data: { free: { toString(): string } };
};
const free = accountInfo.data.free.toString();
const balanceNum = Number(free) / 1e12;
setAssetHubHezBalance(balanceNum.toFixed(4));
} catch (err) {
console.error('Error fetching Asset Hub HEZ balance:', err);
setAssetHubHezBalance('0.0000');
}
}
// People Chain HEZ balance
const peopleApi = getPeopleAPI();
if (peopleApi) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const accountInfo = (await (peopleApi.query.system as any).account(address)) as {
data: { free: { toString(): string } };
};
const free = accountInfo.data.free.toString();
const balanceNum = Number(free) / 1e12;
setPeopleHezBalance(balanceNum.toFixed(4));
} catch (err) {
console.error('Error fetching People Chain HEZ balance:', err);
setPeopleHezBalance('0.0000');
}
}
};
fetchMultiChainBalances();
// Refresh every 30 seconds
const interval = setInterval(fetchMultiChainBalances, 30000);
return () => clearInterval(interval);
}, [address, rpcConnected]);
// Initialize with default tokens immediately (no API required)
const [tokens, setTokens] = useState<TokenBalance[]>(() =>
DEFAULT_TOKENS.map((config) => ({
...config,
balance: '--', // Placeholder until connected
isHidden: false,
}))
);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [showSettings, setShowSettings] = useState(false);
const [showAddToken, setShowAddToken] = useState(false);
const [newAssetId, setNewAssetId] = useState('');
const [isExpanded, setIsExpanded] = useState(true);
const [hiddenTokens, setHiddenTokens] = useState<number[]>(() => {
const stored = localStorage.getItem('hiddenTokens');
return stored ? JSON.parse(stored) : [];
});
const [customTokenIds, setCustomTokenIds] = useState<number[]>(() => {
const stored = localStorage.getItem('customTokenIds');
return stored ? JSON.parse(stored) : [];
});
const [prices, setPrices] = useState<Record<string, PriceData>>({});
const [isPriceLoading, setIsPriceLoading] = useState(false);
// Multi-chain HEZ balances
const [assetHubHezBalance, setAssetHubHezBalance] = useState<string>('--');
const [peopleHezBalance, setPeopleHezBalance] = useState<string>('--');
const [showFundFeesModal, setShowFundFeesModal] = useState(false);
// Fetch prices from CoinGecko
const fetchPrices = useCallback(async () => {
setIsPriceLoading(true);
try {
// Fetch prices for known tokens
const ids = Object.values(COINGECKO_IDS).join(',');
const response = await fetch(
`https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=usd&include_24hr_change=true`
);
if (!response.ok) {
throw new Error('CoinGecko API error');
}
const data = await response.json();
// Map CoinGecko response to our token symbols
const priceMap: Record<string, PriceData> = {};
for (const [symbol, geckoId] of Object.entries(COINGECKO_IDS)) {
if (data[geckoId]) {
priceMap[symbol] = {
usd: data[geckoId].usd,
usd_24h_change: data[geckoId].usd_24h_change || 0,
};
}
}
// Calculate HEZ and PEZ prices from DOT if not available on CoinGecko
const dotPrice = priceMap['DOT'];
if (dotPrice) {
// HEZ = DOT / 3 (if not found on CoinGecko)
if (!priceMap['HEZ']) {
priceMap['HEZ'] = {
usd: dotPrice.usd / 3,
usd_24h_change: dotPrice.usd_24h_change, // Use DOT's change
};
}
// PEZ = DOT / 10 (if not found on CoinGecko)
if (!priceMap['PEZ']) {
priceMap['PEZ'] = {
usd: dotPrice.usd / 10,
usd_24h_change: dotPrice.usd_24h_change, // Use DOT's change
};
}
}
setPrices(priceMap);
} catch (err) {
console.error('Failed to fetch prices:', err);
} finally {
setIsPriceLoading(false);
}
}, []);
// Fetch prices on mount and every 60 seconds
useEffect(() => {
fetchPrices();
const interval = setInterval(fetchPrices, 60000);
return () => clearInterval(interval);
}, [fetchPrices]);
// Update tokens with price data (works without API)
const updateTokensWithPrices = useCallback(() => {
setTokens((prev) =>
prev.map((token) => {
const priceData = prices[token.displaySymbol];
const numBalance = parseFloat(token.balance) || 0;
return {
...token,
isHidden: hiddenTokens.includes(token.assetId),
priceUsd: priceData?.usd,
priceChange24h: priceData?.usd_24h_change,
valueUsd:
priceData?.usd && token.balance !== '--' ? numBalance * priceData.usd : undefined,
};
})
);
}, [prices, hiddenTokens]);
// Fetch token balances from blockchain (only when API is available)
const fetchTokenBalances = useCallback(async () => {
// Update prices even without API
updateTokensWithPrices();
// Get Asset Hub API from rpc-manager (PEZ, USDT etc are on Asset Hub)
// Note: Native HEZ balance comes from WalletContext (hezBalance), not directly from API
const assetHubApi = getAssetHubAPI();
// If no address, keep showing "--" for balances
if (!address) {
return;
}
setIsLoading(true);
try {
// Fetch all tokens (default + custom + LP tokens)
const allTokenConfigs = [
...DEFAULT_TOKENS,
...LP_TOKENS, // Include LP tokens
...customTokenIds
.filter((id) => !DEFAULT_TOKENS.find((t) => t.assetId === id))
.map((id) => ({
assetId: id,
symbol: `Token #${id}`,
displaySymbol: `Token #${id}`,
name: `Custom Token`,
decimals: 12,
logo: '',
isDefault: false,
priority: 100 + id,
})),
];
const tokenBalances: TokenBalance[] = [];
for (const config of allTokenConfigs) {
let balance = '0';
if (config.assetId === -1) {
// Native HEZ balance (from relay chain)
balance = hezBalance ?? '0.0000';
} else if (config.assetId <= -100) {
// LP Token balance (from poolAssets pallet)
// Convert back to pool asset ID: -100 - assetId
const poolAssetId = -100 - config.assetId;
if (!assetHubApi) {
balance = '--';
} else {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const lpBalance = await (assetHubApi.query.poolAssets as any).account(
poolAssetId,
address
);
if (lpBalance && lpBalance.isSome) {
const data = lpBalance.unwrap();
const rawBalance = data.balance.toString();
const numBalance = parseInt(rawBalance) / Math.pow(10, config.decimals);
// Show more decimals for LP tokens to catch small amounts
balance =
numBalance < 0.0001 && numBalance > 0
? numBalance.toExponential(2)
: numBalance.toFixed(4);
} else {
balance = '0.0000';
}
} catch {
balance = '0.0000';
}
}
} else {
// Asset balance - PEZ, USDT etc are on Asset Hub!
// Use Asset Hub API instead of relay chain API
if (!assetHubApi) {
balance = '--'; // Asset Hub not connected
} else {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const assetBalance = await (assetHubApi.query.assets as any).account(
config.assetId,
address
);
if (assetBalance && assetBalance.isSome) {
const data = assetBalance.unwrap();
const rawBalance = data.balance.toString();
const formatted = (parseInt(rawBalance) / Math.pow(10, config.decimals)).toFixed(
config.decimals === 6 ? 2 : 4
);
balance = formatted;
} else {
// User has no balance for this asset
balance = config.decimals === 6 ? '0.00' : '0.0000';
}
} catch {
// Token might not exist or user has no balance
balance = config.decimals === 6 ? '0.00' : '0.0000';
}
}
}
// Add price data
const priceData = prices[config.displaySymbol];
const numBalance = parseFloat(balance) || 0;
tokenBalances.push({
...config,
balance,
isHidden: hiddenTokens.includes(config.assetId),
priceUsd: priceData?.usd,
priceChange24h: priceData?.usd_24h_change,
valueUsd: priceData?.usd ? numBalance * priceData.usd : undefined,
});
}
// Sort by priority
tokenBalances.sort((a, b) => a.priority - b.priority);
setTokens(tokenBalances);
} catch (err) {
console.error('Failed to fetch token balances:', err);
} finally {
setIsLoading(false);
}
}, [address, hezBalance, hiddenTokens, customTokenIds, prices, updateTokensWithPrices]);
useEffect(() => {
fetchTokenBalances();
}, [fetchTokenBalances]);
// Toggle token visibility
const toggleTokenVisibility = (assetId: number) => {
hapticImpact('light');
setHiddenTokens((prev) => {
const newHidden = prev.includes(assetId)
? prev.filter((id) => id !== assetId)
: [...prev, assetId];
localStorage.setItem('hiddenTokens', JSON.stringify(newHidden));
return newHidden;
});
};
// Add custom token
const handleAddToken = () => {
const id = parseInt(newAssetId);
if (isNaN(id) || id < 0) return;
if (customTokenIds.includes(id) || DEFAULT_TOKENS.find((t) => t.assetId === id)) {
return; // Already exists
}
hapticImpact('medium');
const newIds = [...customTokenIds, id];
setCustomTokenIds(newIds);
localStorage.setItem('customTokenIds', JSON.stringify(newIds));
setNewAssetId('');
setShowAddToken(false);
fetchTokenBalances();
};
// Remove custom token
const removeCustomToken = (assetId: number) => {
hapticImpact('medium');
const newIds = customTokenIds.filter((id) => id !== assetId);
setCustomTokenIds(newIds);
localStorage.setItem('customTokenIds', JSON.stringify(newIds));
setTokens((prev) => prev.filter((t) => t.assetId !== assetId));
};
// Filter tokens based on search
const filteredTokens = tokens.filter((token) => {
if (!searchQuery) return !token.isHidden || showSettings;
const query = searchQuery.toLowerCase();
return (
token.displaySymbol.toLowerCase().includes(query) ||
token.name.toLowerCase().includes(query) ||
token.symbol.toLowerCase().includes(query)
);
});
// Get token gradient based on symbol
const getTokenGradient = (symbol: string) => {
const gradients: Record<string, string> = {
HEZ: 'from-green-500/20 to-yellow-500/20 border-green-500/30',
PEZ: 'from-blue-500/20 to-purple-500/20 border-blue-500/30',
USDT: 'from-emerald-500/20 to-teal-500/20 border-emerald-500/30',
DOT: 'from-pink-500/20 to-purple-500/20 border-pink-500/30',
BTC: 'from-orange-500/20 to-yellow-500/20 border-orange-500/30',
ETH: 'from-blue-500/20 to-indigo-500/20 border-blue-500/30',
BNB: 'from-yellow-500/20 to-orange-500/20 border-yellow-500/30',
'HEZ-PEZ LP': 'from-green-500/20 to-blue-500/20 border-cyan-500/30',
'HEZ-USDT LP': 'from-green-500/20 to-emerald-500/20 border-teal-500/30',
};
return gradients[symbol] || 'from-gray-500/20 to-gray-600/20 border-gray-500/30';
};
return (
<div className="bg-muted/50 border border-border rounded-xl overflow-hidden">
{/* Header */}
<div
className="p-4 flex items-center justify-between cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<Coins className="w-5 h-5 text-cyan-400" />
<h3 className="font-semibold">Tokens</h3>
<span className="text-xs text-muted-foreground">
({tokens.filter((t) => !t.isHidden).length})
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
hapticImpact('light');
fetchTokenBalances();
fetchPrices();
}}
disabled={isLoading || isPriceLoading}
className="p-1.5 text-muted-foreground hover:text-white rounded"
>
<RefreshCw className={`w-4 h-4 ${isLoading || isPriceLoading ? 'animate-spin' : ''}`} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
hapticImpact('light');
setShowSettings(!showSettings);
}}
className={`p-1.5 rounded ${showSettings ? 'text-cyan-400 bg-cyan-400/10' : 'text-muted-foreground hover:text-white'}`}
>
<Settings className="w-4 h-4" />
</button>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</div>
</div>
{isExpanded && (
<>
{/* Search & Add */}
<div className="px-4 pb-3 space-y-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Token bigere..."
className="w-full pl-9 pr-4 py-2 bg-background rounded-lg text-sm"
/>
</div>
{showSettings && (
<button
onClick={() => {
hapticImpact('light');
setShowAddToken(!showAddToken);
}}
className="w-full py-2 border border-dashed border-border rounded-lg text-sm text-muted-foreground hover:text-white hover:border-cyan-500/50 flex items-center justify-center gap-2"
>
<Plus className="w-4 h-4" />
Token Zêde Bike
</button>
)}
{showAddToken && (
<div className="p-3 bg-background rounded-lg space-y-2">
<input
type="number"
value={newAssetId}
onChange={(e) => setNewAssetId(e.target.value)}
placeholder="Asset ID binivîse (mînak: 3)"
className="w-full px-3 py-2 bg-muted rounded-lg text-sm"
min="0"
/>
<div className="flex gap-2">
<button
onClick={() => setShowAddToken(false)}
className="flex-1 py-2 bg-muted rounded-lg text-sm"
>
Betal
</button>
<button
onClick={handleAddToken}
disabled={!newAssetId}
className="flex-1 py-2 bg-cyan-600 rounded-lg text-sm disabled:opacity-50"
>
Zêde Bike
</button>
</div>
</div>
)}
</div>
{/* Connection Status Banner */}
{rpcConnected ? (
<div className="mx-4 mb-2 px-3 py-2 bg-green-500/10 border border-green-500/30 rounded-lg flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<div>
<p className="text-xs text-green-400 font-medium">Pezkuwichain Girêdayî</p>
{endpointName && <p className="text-[10px] text-green-400/70">{endpointName}</p>}
</div>
</div>
) : (
<div className="mx-4 mb-2 px-3 py-2 bg-yellow-500/10 border border-yellow-500/30 rounded-lg flex items-center gap-2">
<RefreshCw className="w-4 h-4 text-yellow-400 animate-spin flex-shrink-0" />
<div>
<p className="text-xs text-yellow-400 font-medium">Girêdana Blockchain...</p>
<p className="text-[10px] text-yellow-400/70">
{getLastError() || 'RPC serverê tê girêdan...'}
</p>
</div>
</div>
)}
{/* Token List */}
<div className="px-4 pb-4 space-y-2 max-h-[400px] overflow-y-auto">
{filteredTokens.length === 0 ? (
<div className="text-center py-8">
<Coins className="w-6 h-6 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">Token nehat dîtin</p>
</div>
) : (
filteredTokens.map((token) =>
// Special rendering for HEZ (multi-chain)
token.assetId === -1 ? (
<div
key={token.assetId}
className={`p-3 rounded-xl border bg-gradient-to-br ${getTokenGradient(token.displaySymbol)} ${
token.isHidden ? 'opacity-50' : ''
}`}
>
{/* HEZ Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<img
src={token.logo}
alt={token.displaySymbol}
className="w-10 h-10 rounded-full"
/>
<div>
<div className="flex items-center gap-2">
<span className="font-semibold">{token.displaySymbol}</span>
<span className="text-[10px] bg-green-500/20 text-green-400 px-1.5 py-0.5 rounded">
Multi-Chain
</span>
</div>
<div className="flex items-center gap-2">
{token.priceUsd !== undefined ? (
<>
<span className="text-xs text-muted-foreground">
${token.priceUsd.toFixed(token.priceUsd < 1 ? 4 : 2)}
</span>
{token.priceChange24h !== undefined && (
<span
className={`text-[10px] flex items-center gap-0.5 ${
token.priceChange24h >= 0 ? 'text-green-400' : 'text-red-400'
}`}
>
{token.priceChange24h >= 0 ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{Math.abs(token.priceChange24h).toFixed(2)}%
</span>
)}
</>
) : (
<span className="text-xs text-muted-foreground">{token.name}</span>
)}
</div>
</div>
</div>
<button
onClick={() => {
hapticImpact('light');
setShowFundFeesModal(true);
}}
className="px-3 py-1.5 bg-yellow-500/20 border border-yellow-500/30 rounded-lg flex items-center gap-1.5 text-yellow-400 text-xs font-medium hover:bg-yellow-500/30 transition-colors"
>
<Fuel className="w-3.5 h-3.5" />
Add Fee
</button>
</div>
{/* Multi-chain balances */}
<div className="space-y-2 mt-2">
{/* Relay Chain */}
<div className="flex items-center justify-between py-1.5 px-2 bg-black/20 rounded-lg">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
<span className="text-xs text-muted-foreground">Relay Chain</span>
</div>
<span className="text-sm font-mono">{token.balance} HEZ</span>
</div>
{/* Asset Hub */}
<div className="flex items-center justify-between py-1.5 px-2 bg-black/20 rounded-lg">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-blue-500" />
<span className="text-xs text-muted-foreground">Asset Hub</span>
{parseFloat(assetHubHezBalance) < 0.1 && assetHubHezBalance !== '--' && (
<span className="text-[10px] text-yellow-400"></span>
)}
</div>
<span className="text-sm font-mono">{assetHubHezBalance} HEZ</span>
</div>
{/* People Chain */}
<div className="flex items-center justify-between py-1.5 px-2 bg-black/20 rounded-lg">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-purple-500" />
<span className="text-xs text-muted-foreground">People Chain</span>
{parseFloat(peopleHezBalance) < 0.1 && peopleHezBalance !== '--' && (
<span className="text-[10px] text-yellow-400"></span>
)}
</div>
<span className="text-sm font-mono">{peopleHezBalance} HEZ</span>
</div>
</div>
{/* Total Value */}
{token.valueUsd !== undefined && token.balance !== '--' && (
<div className="mt-2 pt-2 border-t border-white/10 flex justify-between items-center">
<span className="text-xs text-muted-foreground">Toplam</span>
<span className="text-sm font-semibold">
$
{(
(parseFloat(token.balance) +
parseFloat(assetHubHezBalance === '--' ? '0' : assetHubHezBalance) +
parseFloat(peopleHezBalance === '--' ? '0' : peopleHezBalance)) *
(token.priceUsd || 0)
).toFixed(2)}
</span>
</div>
)}
</div>
) : (
// Regular token card
<div
key={token.assetId}
className={`p-3 rounded-xl border bg-gradient-to-br ${getTokenGradient(token.displaySymbol)} ${
token.isHidden ? 'opacity-50' : ''
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{token.logo ? (
<img
src={token.logo}
alt={token.displaySymbol}
className="w-10 h-10 rounded-full"
/>
) : (
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
<span className="text-xs font-bold">
{token.displaySymbol.slice(0, 2)}
</span>
</div>
)}
<div>
<div className="flex items-center gap-2">
<span className="font-semibold">{token.displaySymbol}</span>
{token.assetId <= -100 && (
<span className="text-[10px] bg-purple-500/20 text-purple-400 px-1.5 py-0.5 rounded">
LP
</span>
)}
{!token.isDefault && (
<span className="text-[10px] bg-cyan-500/20 text-cyan-400 px-1.5 py-0.5 rounded">
Custom
</span>
)}
</div>
<div className="flex items-center gap-2">
{token.priceUsd !== undefined ? (
<>
<span className="text-xs text-muted-foreground">
${token.priceUsd.toFixed(token.priceUsd < 1 ? 4 : 2)}
</span>
{token.priceChange24h !== undefined && (
<span
className={`text-[10px] flex items-center gap-0.5 ${
token.priceChange24h >= 0 ? 'text-green-400' : 'text-red-400'
}`}
>
{token.priceChange24h >= 0 ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{Math.abs(token.priceChange24h).toFixed(2)}%
</span>
)}
</>
) : (
<span className="text-xs text-muted-foreground">{token.name}</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<p
className={`font-semibold font-mono ${token.balance === '--' ? 'text-muted-foreground' : ''}`}
>
{token.balance}
</p>
{token.balance === '--' ? (
<p className="text-xs text-muted-foreground"> barkirin...</p>
) : token.valueUsd !== undefined ? (
<p className="text-xs text-muted-foreground">
${token.valueUsd.toFixed(2)}
</p>
) : (
<p className="text-xs text-muted-foreground">{token.displaySymbol}</p>
)}
</div>
{showSettings ? (
<div className="flex items-center gap-1">
<button
onClick={() => toggleTokenVisibility(token.assetId)}
className="p-1.5 text-muted-foreground hover:text-white rounded"
>
{token.isHidden ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
{!token.isDefault && (
<button
onClick={() => removeCustomToken(token.assetId)}
className="p-1.5 text-red-400 hover:text-red-300 rounded"
>
<X className="w-4 h-4" />
</button>
)}
</div>
) : (
onSendToken &&
token.balance !== '--' &&
parseFloat(token.balance) > 0 && (
<button
onClick={() => {
hapticImpact('light');
onSendToken(token);
}}
className="p-2 text-muted-foreground hover:text-white hover:bg-white/10 rounded-lg"
>
<Send className="w-4 h-4" />
</button>
)
)}
</div>
</div>
</div>
)
)
)}
</div>
</>
)}
{/* Fund Fees Modal for XCM Teleport */}
<FundFeesModal isOpen={showFundFeesModal} onClose={() => setShowFundFeesModal(false)} />
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Wallet Connect Component
* Unlock wallet with password
*/
import { useState } from 'react';
import { Eye, EyeOff, Wallet, Unlock, Trash2 } from 'lucide-react';
import { useWallet } from '@/contexts/WalletContext';
import { useTelegram } from '@/hooks/useTelegram';
import { formatAddress } from '@/lib/wallet-service';
interface Props {
onConnected: () => void;
onDelete: () => void;
}
export function WalletConnect({ onConnected, onDelete }: Props) {
const { address, connect, error: walletError } = useWallet();
const { hapticImpact, hapticNotification } = useTelegram();
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const handleConnect = async () => {
if (!password) {
setError('Şîfre (password) binivîse');
return;
}
setIsLoading(true);
setError('');
hapticImpact('medium');
try {
await connect(password);
hapticNotification('success');
onConnected();
} catch (err) {
setError(err instanceof Error ? err.message : 'Şîfre (password) çewt e');
hapticNotification('error');
} finally {
setIsLoading(false);
}
};
const handleDelete = () => {
hapticImpact('heavy');
onDelete();
};
if (showDeleteConfirm) {
return (
<div className="p-4 space-y-6">
<div className="text-center">
<div className="w-16 h-16 mx-auto bg-red-500/20 rounded-full flex items-center justify-center mb-4">
<Trash2 className="w-8 h-8 text-red-500" />
</div>
<h2 className="text-xl font-semibold mb-2">Wallet Bibe?</h2>
<p className="text-muted-foreground text-sm">
Ev çalakî nayê paşvekişandin. Eger seed phrase&apos;ê te tune be, tu nikarî gihîştina
wallet&apos;ê xwe bistînî.
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="flex-1 py-3 bg-muted rounded-xl font-semibold"
>
Betal
</button>
<button
onClick={handleDelete}
className="flex-1 py-3 bg-red-500 text-white rounded-xl font-semibold"
>
Bibe
</button>
</div>
</div>
);
}
return (
<div className="p-4 space-y-6">
<div className="text-center">
<div className="w-16 h-16 mx-auto bg-primary/20 rounded-full flex items-center justify-center mb-4">
<Wallet className="w-8 h-8 text-primary" />
</div>
<h2 className="text-xl font-semibold mb-2">Wallet Veke</h2>
{address && (
<p className="text-muted-foreground text-sm font-mono">{formatAddress(address)}</p>
)}
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm text-muted-foreground">Şîfre (Password)</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleConnect()}
className="w-full px-4 py-3 bg-muted rounded-xl pr-12"
placeholder="Şîfre (password) binivîse"
autoFocus
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
{(error || walletError) && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error || walletError}
</div>
)}
<button
onClick={handleConnect}
disabled={isLoading || !password}
className="w-full py-3 bg-primary text-primary-foreground rounded-xl font-semibold disabled:opacity-50 flex items-center justify-center gap-2"
>
{isLoading ? (
'Tê vekirin...'
) : (
<>
<Unlock className="w-4 h-4" />
Connect
</>
)}
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
className="w-full py-3 text-red-400 text-sm"
>
Wallet bibe
</button>
</div>
</div>
);
}
+562
View File
@@ -0,0 +1,562 @@
/**
* Wallet Create Component
* Multi-step wallet creation flow following pezWallet architecture
* Flow: Password → Backup (show mnemonic + 3 conditions) → Verify (word ordering) → Complete
*/
import { useState } from 'react';
import {
Eye,
EyeOff,
Copy,
Check,
AlertTriangle,
ArrowRight,
ArrowLeft,
RotateCcw,
} from 'lucide-react';
import { useWallet } from '@/contexts/WalletContext';
import { useTelegram } from '@/hooks/useTelegram';
type Step = 'password' | 'backup' | 'verify' | 'complete';
interface MnemonicWord {
id: number;
content: string;
removed: boolean;
}
interface Props {
onComplete: () => void;
onBack: () => void;
}
export function WalletCreate({ onComplete, onBack }: Props) {
const { generateNewWallet, confirmWallet, isInitialized } = useWallet();
const { hapticImpact, hapticNotification } = useTelegram();
const [step, setStep] = useState<Step>('password');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [mnemonic, setMnemonic] = useState('');
const [address, setAddress] = useState('');
const [copied, setCopied] = useState(false);
// Backup conditions (3 checkboxes - all must be checked)
const [conditions, setConditions] = useState({
writtenDown: false,
neverShare: false,
lossRisk: false,
});
// Verify step - word ordering
const [sourceWords, setSourceWords] = useState<MnemonicWord[]>([]);
const [destinationWords, setDestinationWords] = useState<MnemonicWord[]>([]);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
// Password strength validation rules (must match crypto.ts validatePassword)
const passwordRules = {
minLength: password.length >= 12,
hasLowercase: /[a-z]/.test(password),
hasUppercase: /[A-Z]/.test(password),
hasNumber: /[0-9]/.test(password),
hasSpecialChar: /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password),
passwordsMatch: password === confirmPassword && password.length > 0,
};
const allPasswordRulesPass =
passwordRules.minLength &&
passwordRules.hasLowercase &&
passwordRules.hasUppercase &&
passwordRules.hasNumber &&
passwordRules.hasSpecialChar &&
passwordRules.passwordsMatch;
// Check if all conditions are met
const allConditionsChecked =
conditions.writtenDown && conditions.neverShare && conditions.lossRisk;
// Step 1: Password - validate and generate wallet (NOT saved yet)
const handlePasswordSubmit = () => {
setError('');
if (!isInitialized) {
setError('Wallet service amade nîne. Ji kerema xwe bisekinin.');
return;
}
if (!allPasswordRulesPass) {
setError('Ji kerema xwe hemû şertên şîfre (password) bicîh bînin');
hapticNotification('error');
return;
}
hapticImpact('medium');
try {
// Generate wallet but DON'T save yet - user must verify backup first
const result = generateNewWallet();
setMnemonic(result.mnemonic);
setAddress(result.address);
setStep('backup');
} catch (err) {
console.error('Wallet generation error:', err);
setError(
err instanceof Error ? err.message : 'Wallet çênebû. Ji kerema xwe dîsa biceribînin'
);
hapticNotification('error');
}
};
// Step 2: Backup - Copy mnemonic
const handleCopyMnemonic = () => {
navigator.clipboard.writeText(mnemonic);
setCopied(true);
hapticNotification('success');
setTimeout(() => setCopied(false), 2000);
};
// Step 2: Backup - Proceed to verify (only if all conditions checked)
const handleBackupContinue = () => {
if (!allConditionsChecked) {
setError('Ji kerema xwe hemû şertan bipejirînin');
return;
}
hapticImpact('light');
// Initialize verification step with shuffled words
const words = mnemonic.split(' ');
const shuffled = [...words]
.map((word, idx) => ({ id: idx, content: word, removed: false }))
.sort(() => Math.random() - 0.5);
setSourceWords(shuffled);
setDestinationWords([]);
setError('');
setStep('verify');
};
// Step 3: Verify - Source word clicked (add to destination)
const handleSourceWordClick = (word: MnemonicWord) => {
if (word.removed) return;
hapticImpact('light');
// Mark as removed in source
setSourceWords((prev) => prev.map((w) => (w.id === word.id ? { ...w, removed: true } : w)));
// Add to destination
setDestinationWords((prev) => [...prev, { ...word, removed: false }]);
};
// Step 3: Verify - Destination word clicked (return to source)
const handleDestinationWordClick = (word: MnemonicWord) => {
hapticImpact('light');
// Remove from destination
setDestinationWords((prev) => prev.filter((w) => w.id !== word.id));
// Restore in source
setSourceWords((prev) => prev.map((w) => (w.id === word.id ? { ...w, removed: false } : w)));
};
// Step 3: Verify - Reset
const handleReset = () => {
hapticImpact('medium');
setSourceWords((prev) => prev.map((w) => ({ ...w, removed: false })));
setDestinationWords([]);
setError('');
};
// Step 3: Verify - Check and create wallet
const handleVerify = async () => {
const originalWords = mnemonic.split(' ');
const enteredWords = destinationWords.map((w) => w.content);
// Check if order matches
const isCorrect =
originalWords.length === enteredWords.length &&
originalWords.every((word, idx) => word === enteredWords[idx]);
if (!isCorrect) {
setError('Rêza peyvan ne rast e. Ji kerema xwe dîsa biceribînin');
hapticNotification('error');
return;
}
setIsLoading(true);
setError('');
try {
// NOW save the wallet after user has verified backup
await confirmWallet(mnemonic, password);
hapticNotification('success');
setStep('complete');
} catch (err) {
setError(err instanceof Error ? err.message : 'Wallet çênebû');
hapticNotification('error');
} finally {
setIsLoading(false);
}
};
// Can continue in verify step only when all words are placed
const canVerify = destinationWords.length === 12;
// Render based on step
if (step === 'password') {
return (
<div className="p-4 space-y-6">
<button onClick={onBack} className="flex items-center gap-2 text-muted-foreground">
<ArrowLeft className="w-4 h-4" />
<span>Paş</span>
</button>
<div className="text-center">
<h2 className="text-xl font-semibold mb-2">Şîfre (Password) Diyar Bike</h2>
<p className="text-muted-foreground text-sm">
Ev şîfre (password) ji bo vekirina wallet&apos;ê were bikaranîn
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm text-muted-foreground">Şîfre (Password)</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 bg-muted rounded-xl pr-12"
placeholder="Herî kêm 12 tîp (min 12 characters)"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div className="space-y-2">
<label className="text-sm text-muted-foreground">Şîfre Dubare (Confirm Password)</label>
<input
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-3 bg-muted rounded-xl"
placeholder="Şîfre dubare binivîse (confirm password)"
/>
</div>
{/* Real-time password strength indicator */}
{password.length > 0 && (
<div className="p-3 bg-muted/50 rounded-xl space-y-2">
<p className="text-xs text-muted-foreground font-medium">Şertên Şîfre (Password):</p>
<div className="grid grid-cols-1 gap-1.5 text-xs">
<div
className={`flex items-center gap-2 ${passwordRules.minLength ? 'text-green-400' : 'text-red-400'}`}
>
{passwordRules.minLength ? (
<Check className="w-3 h-3" />
) : (
<AlertTriangle className="w-3 h-3" />
)}
<span>Herî kêm 12 tîp (min 12 characters)</span>
</div>
<div
className={`flex items-center gap-2 ${passwordRules.hasLowercase ? 'text-green-400' : 'text-red-400'}`}
>
{passwordRules.hasLowercase ? (
<Check className="w-3 h-3" />
) : (
<AlertTriangle className="w-3 h-3" />
)}
<span>Herî kêm 1 tîpa biçûk (a-z)</span>
</div>
<div
className={`flex items-center gap-2 ${passwordRules.hasUppercase ? 'text-green-400' : 'text-red-400'}`}
>
{passwordRules.hasUppercase ? (
<Check className="w-3 h-3" />
) : (
<AlertTriangle className="w-3 h-3" />
)}
<span>Herî kêm 1 tîpa mezin (A-Z)</span>
</div>
<div
className={`flex items-center gap-2 ${passwordRules.hasNumber ? 'text-green-400' : 'text-red-400'}`}
>
{passwordRules.hasNumber ? (
<Check className="w-3 h-3" />
) : (
<AlertTriangle className="w-3 h-3" />
)}
<span>Herî kêm 1 hejmar (0-9)</span>
</div>
<div
className={`flex items-center gap-2 ${passwordRules.hasSpecialChar ? 'text-green-400' : 'text-red-400'}`}
>
{passwordRules.hasSpecialChar ? (
<Check className="w-3 h-3" />
) : (
<AlertTriangle className="w-3 h-3" />
)}
<span>Herî kêm 1 sembola taybetî (!@#$%...)</span>
</div>
{confirmPassword.length > 0 && (
<div
className={`flex items-center gap-2 ${passwordRules.passwordsMatch ? 'text-green-400' : 'text-red-400'}`}
>
{passwordRules.passwordsMatch ? (
<Check className="w-3 h-3" />
) : (
<AlertTriangle className="w-3 h-3" />
)}
<span>Şîfre (password) hev digirin</span>
</div>
)}
</div>
</div>
)}
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
<button
onClick={handlePasswordSubmit}
disabled={isLoading || !allPasswordRulesPass || !isInitialized}
className="w-full py-3 bg-primary text-primary-foreground rounded-xl font-semibold disabled:opacity-50 flex items-center justify-center gap-2"
>
{!isInitialized
? 'Tê amadekirin...'
: isLoading
? 'Tê çêkirin...'
: allPasswordRulesPass
? 'Berdewam'
: 'Şertên şîfre (password) bicîh bînin'}
{!isLoading && allPasswordRulesPass && isInitialized && (
<ArrowRight className="w-4 h-4" />
)}
</button>
</div>
</div>
);
}
if (step === 'backup') {
const words = mnemonic.split(' ');
return (
<div className="p-4 space-y-6">
<div className="text-center">
<h2 className="text-xl font-semibold mb-2">Seed Phrase Paşguh Bike</h2>
<p className="text-muted-foreground text-sm">
Ev 12 peyv wallet&apos;ê te ne. Wan li cihekî ewle binivîse!
</p>
</div>
<div className="p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-xl flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-yellow-500 shrink-0 mt-0.5" />
<p className="text-sm text-yellow-200">
<strong>Girîng:</strong> Ev peyvan tenê yek car têne xuyang kirin. Eger te ev peyv winda
bikin, tu nikarî gihîştina wallet&apos;ê xwe bistînî.
</p>
</div>
<div className="grid grid-cols-3 gap-2">
{words.map((word, idx) => (
<div key={idx} className="px-3 py-2 bg-muted rounded-lg text-center text-sm font-mono">
<span className="text-muted-foreground mr-1">{idx + 1}.</span>
{word}
</div>
))}
</div>
<button
onClick={handleCopyMnemonic}
className="w-full py-3 bg-muted rounded-xl flex items-center justify-center gap-2"
>
{copied ? (
<>
<Check className="w-4 h-4 text-green-400" />
<span className="text-green-400">Hat kopîkirin!</span>
</>
) : (
<>
<Copy className="w-4 h-4" />
<span>Kopî Bike</span>
</>
)}
</button>
{/* 3 Condition Checkboxes */}
<div className="space-y-3">
<label className="flex items-start gap-3 p-3 bg-muted rounded-xl cursor-pointer">
<input
type="checkbox"
checked={conditions.writtenDown}
onChange={(e) =>
setConditions((prev) => ({ ...prev, writtenDown: e.target.checked }))
}
className="mt-1 w-5 h-5 accent-primary"
/>
<span className="text-sm">Min ev 12 peyv li cihekî ewle nivîsandine</span>
</label>
<label className="flex items-start gap-3 p-3 bg-muted rounded-xl cursor-pointer">
<input
type="checkbox"
checked={conditions.neverShare}
onChange={(e) => setConditions((prev) => ({ ...prev, neverShare: e.target.checked }))}
className="mt-1 w-5 h-5 accent-primary"
/>
<span className="text-sm">
Ez fêm dikim ku ez nikarim ev peyvan bi kesî re parve bikim
</span>
</label>
<label className="flex items-start gap-3 p-3 bg-muted rounded-xl cursor-pointer">
<input
type="checkbox"
checked={conditions.lossRisk}
onChange={(e) => setConditions((prev) => ({ ...prev, lossRisk: e.target.checked }))}
className="mt-1 w-5 h-5 accent-primary"
/>
<span className="text-sm">
Ez fêm dikim ku eger van peyvan winda bikim ez nikarim gihîştina wallet&apos;ê xwe
bistînim
</span>
</label>
</div>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
<button
onClick={handleBackupContinue}
disabled={!allConditionsChecked}
className="w-full py-3 bg-primary text-primary-foreground rounded-xl font-semibold disabled:opacity-50 flex items-center justify-center gap-2"
>
{allConditionsChecked ? 'Berdewam' : 'Hemû şertan bipejirînin'}
{allConditionsChecked && <ArrowRight className="w-4 h-4" />}
</button>
</div>
);
}
if (step === 'verify') {
return (
<div className="p-4 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Peyvan Verast Bike</h2>
<button
onClick={handleReset}
className="flex items-center gap-1 text-sm text-muted-foreground"
>
<RotateCcw className="w-4 h-4" />
<span>Reset</span>
</button>
</div>
<p className="text-muted-foreground text-sm text-center">
Ji kerema xwe peyvan bi rêza rast bixin nav qutîkê
</p>
{/* Destination area - where user builds the correct order */}
<div className="min-h-[120px] p-4 bg-muted/50 border-2 border-dashed border-border rounded-xl">
{destinationWords.length === 0 ? (
<p className="text-center text-muted-foreground text-sm">Peyvan li vir bixin...</p>
) : (
<div className="flex flex-wrap gap-2">
{destinationWords.map((word, idx) => (
<button
key={word.id}
onClick={() => handleDestinationWordClick(word)}
className="px-3 py-2 bg-primary/20 border border-primary/40 rounded-lg text-sm font-mono flex items-center gap-1 hover:bg-primary/30 transition-colors"
>
<span className="text-primary text-xs">{idx + 1}.</span>
{word.content}
</button>
))}
</div>
)}
</div>
{/* Source area - shuffled words to pick from */}
<div className="flex flex-wrap gap-2 justify-center">
{sourceWords.map((word) => (
<button
key={word.id}
onClick={() => handleSourceWordClick(word)}
disabled={word.removed}
className={`px-3 py-2 rounded-lg text-sm font-mono transition-all ${
word.removed
? 'bg-muted/30 text-muted-foreground/30 cursor-not-allowed'
: 'bg-muted hover:bg-muted/80 cursor-pointer'
}`}
>
{word.content}
</button>
))}
</div>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
<button
onClick={handleVerify}
disabled={isLoading || !canVerify}
className="w-full py-3 bg-primary text-primary-foreground rounded-xl font-semibold disabled:opacity-50"
>
{isLoading
? 'Tê tomarkirin...'
: canVerify
? 'Verast Bike'
: `${destinationWords.length}/12 peyv`}
</button>
</div>
);
}
// Complete
return (
<div className="p-4 space-y-6 text-center">
<div className="w-20 h-20 mx-auto bg-green-500/20 rounded-full flex items-center justify-center">
<Check className="w-10 h-10 text-green-500" />
</div>
<div>
<h2 className="text-xl font-semibold mb-2">Wallet Hat Çêkirin!</h2>
<p className="text-muted-foreground text-sm">Wallet&apos;ê te amade ye</p>
</div>
<div className="p-4 bg-muted rounded-xl">
<p className="text-xs text-muted-foreground mb-1">Navnîşana te</p>
<p className="font-mono text-sm break-all">{address}</p>
</div>
<button
onClick={onComplete}
className="w-full py-3 bg-primary text-primary-foreground rounded-xl font-semibold"
>
Dest Bike
</button>
</div>
);
}
File diff suppressed because it is too large Load Diff
+233
View File
@@ -0,0 +1,233 @@
/**
* Wallet Import Component
* Import existing wallet with seed phrase
*/
import { useState } from 'react';
import { Eye, EyeOff, ArrowLeft, ArrowRight, Check, AlertTriangle } from 'lucide-react';
import { useWallet } from '@/contexts/WalletContext';
import { useTelegram } from '@/hooks/useTelegram';
import { validatePassword } from '@/lib/crypto';
interface Props {
onComplete: () => void;
onBack: () => void;
}
export function WalletImport({ onComplete, onBack }: Props) {
const { importWallet } = useWallet();
const { hapticImpact, hapticNotification } = useTelegram();
const [mnemonic, setMnemonic] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
// Password strength validation rules (must match crypto.ts validatePassword)
const passwordRules = {
minLength: password.length >= 12,
hasLowercase: /[a-z]/.test(password),
hasUppercase: /[A-Z]/.test(password),
hasNumber: /[0-9]/.test(password),
hasSpecialChar: /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password),
passwordsMatch: password === confirmPassword && password.length > 0,
};
const allPasswordRulesPass =
passwordRules.minLength &&
passwordRules.hasLowercase &&
passwordRules.hasUppercase &&
passwordRules.hasNumber &&
passwordRules.hasSpecialChar &&
passwordRules.passwordsMatch;
const handleImport = async () => {
setError('');
// Validate mnemonic
const words = mnemonic.trim().split(/\s+/);
if (words.length !== 12 && words.length !== 24) {
setError('Seed phrase divê 12 an 24 peyv be');
return;
}
// Validate password using crypto.ts rules
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
setError(passwordValidation.message || 'Şîfre (password) ne derbasdar e');
return;
}
if (password !== confirmPassword) {
setError('Şîfre (password) hev nagirin');
return;
}
setIsLoading(true);
hapticImpact('medium');
try {
await importWallet(mnemonic.trim().toLowerCase(), password);
hapticNotification('success');
onComplete();
} catch (err) {
setError(err instanceof Error ? err.message : 'Import neserketî');
hapticNotification('error');
} finally {
setIsLoading(false);
}
};
return (
<div className="p-4 space-y-6">
<button onClick={onBack} className="flex items-center gap-2 text-muted-foreground">
<ArrowLeft className="w-4 h-4" />
<span>Paş</span>
</button>
<div className="text-center">
<h2 className="text-xl font-semibold mb-2">Wallet Import Bike</h2>
<p className="text-muted-foreground text-sm">
Seed phrase&apos;ê wallet&apos;ê xwe heyî binivîse
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm text-muted-foreground">Seed Phrase (12 an 24 peyv)</label>
<textarea
value={mnemonic}
onChange={(e) => setMnemonic(e.target.value)}
className="w-full px-4 py-3 bg-muted rounded-xl resize-none h-28 font-mono text-sm"
placeholder="Peyvên xwe bi valahî cuda binivîse..."
/>
<p className="text-xs text-muted-foreground">
{mnemonic.trim().split(/\s+/).filter(Boolean).length} / 12 peyv
</p>
</div>
<div className="space-y-2">
<label className="text-sm text-muted-foreground">Şîfreya (New Password)</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 bg-muted rounded-xl pr-12"
placeholder="Herî kêm 12 tîp (min 12 characters)"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div className="space-y-2">
<label className="text-sm text-muted-foreground">Şîfre Dubare (Confirm Password)</label>
<input
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-3 bg-muted rounded-xl"
placeholder="Şîfre dubare binivîse (confirm password)"
/>
</div>
{/* Real-time password strength indicator */}
{password.length > 0 && (
<div className="p-3 bg-muted/50 rounded-xl space-y-2">
<p className="text-xs text-muted-foreground font-medium">Şertên Şîfre (Password):</p>
<div className="grid grid-cols-1 gap-1.5 text-xs">
<div
className={`flex items-center gap-2 ${passwordRules.minLength ? 'text-green-400' : 'text-red-400'}`}
>
{passwordRules.minLength ? (
<Check className="w-3 h-3" />
) : (
<AlertTriangle className="w-3 h-3" />
)}
<span>Herî kêm 12 tîp (min 12 characters)</span>
</div>
<div
className={`flex items-center gap-2 ${passwordRules.hasLowercase ? 'text-green-400' : 'text-red-400'}`}
>
{passwordRules.hasLowercase ? (
<Check className="w-3 h-3" />
) : (
<AlertTriangle className="w-3 h-3" />
)}
<span>Herî kêm 1 tîpa biçûk (a-z)</span>
</div>
<div
className={`flex items-center gap-2 ${passwordRules.hasUppercase ? 'text-green-400' : 'text-red-400'}`}
>
{passwordRules.hasUppercase ? (
<Check className="w-3 h-3" />
) : (
<AlertTriangle className="w-3 h-3" />
)}
<span>Herî kêm 1 tîpa mezin (A-Z)</span>
</div>
<div
className={`flex items-center gap-2 ${passwordRules.hasNumber ? 'text-green-400' : 'text-red-400'}`}
>
{passwordRules.hasNumber ? (
<Check className="w-3 h-3" />
) : (
<AlertTriangle className="w-3 h-3" />
)}
<span>Herî kêm 1 hejmar (0-9)</span>
</div>
<div
className={`flex items-center gap-2 ${passwordRules.hasSpecialChar ? 'text-green-400' : 'text-red-400'}`}
>
{passwordRules.hasSpecialChar ? (
<Check className="w-3 h-3" />
) : (
<AlertTriangle className="w-3 h-3" />
)}
<span>Herî kêm 1 sembola taybetî (!@#$%...)</span>
</div>
{confirmPassword.length > 0 && (
<div
className={`flex items-center gap-2 ${passwordRules.passwordsMatch ? 'text-green-400' : 'text-red-400'}`}
>
{passwordRules.passwordsMatch ? (
<Check className="w-3 h-3" />
) : (
<AlertTriangle className="w-3 h-3" />
)}
<span>Şîfre (password) hev digirin</span>
</div>
)}
</div>
</div>
)}
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
<button
onClick={handleImport}
disabled={isLoading || !mnemonic || !allPasswordRulesPass}
className="w-full py-3 bg-primary text-primary-foreground rounded-xl font-semibold disabled:opacity-50 flex items-center justify-center gap-2"
>
{isLoading
? 'Tê import kirin...'
: allPasswordRulesPass
? 'Import Bike'
: 'Şertên şîfre (password) bicîh bînin'}
{!isLoading && allPasswordRulesPass && <ArrowRight className="w-4 h-4" />}
</button>
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Wallet Setup Component
* Initial screen for wallet creation or import
*/
import { Wallet, Plus, Download } from 'lucide-react';
interface Props {
onCreate: () => void;
onImport: () => void;
}
export function WalletSetup({ onCreate, onImport }: Props) {
return (
<div className="p-4 space-y-8">
<div className="text-center pt-8">
<div className="w-20 h-20 mx-auto bg-primary/20 rounded-full flex items-center justify-center mb-6">
<Wallet className="w-10 h-10 text-primary" />
</div>
<h1 className="text-2xl font-bold mb-2">Pezkuwi Wallet</h1>
<p className="text-muted-foreground">Berîka fermî ya Pezkuwichain</p>
</div>
<div className="space-y-3">
<button
onClick={onCreate}
className="w-full p-4 bg-primary text-primary-foreground rounded-xl flex items-center gap-4"
>
<div className="w-12 h-12 bg-white/20 rounded-full flex items-center justify-center">
<Plus className="w-6 h-6" />
</div>
<div className="text-left">
<p className="font-semibold">Wallet Çêbike</p>
<p className="text-sm opacity-80">Wallet&apos;ekî bi seed phrase çêbike</p>
</div>
</button>
<button
onClick={onImport}
className="w-full p-4 bg-muted rounded-xl flex items-center gap-4"
>
<div className="w-12 h-12 bg-primary/20 rounded-full flex items-center justify-center">
<Download className="w-6 h-6 text-primary" />
</div>
<div className="text-left">
<p className="font-semibold">Wallet Import Bike</p>
<p className="text-sm text-muted-foreground">
Seed phrase&apos;ê xwe heyî bi kar bîne
</p>
</div>
</button>
</div>
<p className="text-center text-xs text-muted-foreground px-4">
Wallet&apos;ê te bi ewlehî li cîhaza te hilanîn. Em tu carî gihîştina mifteyên te tune.
</p>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
export { WalletSetup } from './WalletSetup';
export { WalletCreate } from './WalletCreate';
export { WalletImport } from './WalletImport';
export { WalletConnect } from './WalletConnect';
export { WalletDashboard } from './WalletDashboard';
export { TokensCard } from './TokensCard';
+66
View File
@@ -0,0 +1,66 @@
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { signInWithTelegram } from '@/lib/supabase';
import type { User } from '@/hooks/useSupabase';
interface AuthContextType {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
signIn: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const signIn = async () => {
const tg = window.Telegram?.WebApp;
if (!tg?.initData) {
setIsLoading(false);
return;
}
try {
const result = await signInWithTelegram(tg.initData);
if (result?.user) {
setUser(result.user);
}
} catch (error) {
// Auth failed silently - user will see unauthenticated state
if (import.meta.env.DEV) {
console.error('[Auth] Error:', error);
}
} finally {
setIsLoading(false);
}
};
useEffect(() => {
// Auto sign-in when in Telegram
signIn();
}, []);
return (
<AuthContext.Provider
value={{
user,
isLoading,
isAuthenticated: !!user,
signIn,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
+107
View File
@@ -0,0 +1,107 @@
/**
* Referral Context for Telegram Mini App
* Provides referral stats using blockchain data from People Chain
* (pallet_referral is on People Chain, connected to KYC via OnKycApproved hook)
*/
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import { useWallet } from '@/contexts/WalletContext';
import { useTelegram } from '@/hooks/useTelegram';
import {
getReferralStats,
getMyReferrals,
subscribeToReferralEvents,
type ReferralStats,
} from '@/lib/referral';
interface ReferralContextValue {
stats: ReferralStats | null;
myReferrals: string[];
loading: boolean;
refreshStats: () => Promise<void>;
}
const ReferralContext = createContext<ReferralContextValue | undefined>(undefined);
export function ReferralProvider({ children }: { children: ReactNode }) {
// Use peopleApi for referral queries - pallet_referral is on People Chain
const { peopleApi, address } = useWallet();
const { hapticNotification, showAlert } = useTelegram();
const [stats, setStats] = useState<ReferralStats | null>(null);
const [myReferrals, setMyReferrals] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
// Fetch referral statistics from People Chain
const fetchStats = useCallback(async () => {
if (!peopleApi || !address) {
setStats(null);
setMyReferrals([]);
setLoading(false);
return;
}
try {
setLoading(true);
const [fetchedStats, fetchedReferrals] = await Promise.all([
getReferralStats(peopleApi, address),
getMyReferrals(peopleApi, address),
]);
setStats(fetchedStats);
setMyReferrals(fetchedReferrals);
} catch (error) {
console.error('Error fetching referral stats:', error);
showAlert('Referral stats bar nekirin');
} finally {
setLoading(false);
}
}, [peopleApi, address, showAlert]);
// Initial fetch
useEffect(() => {
fetchStats();
}, [fetchStats]);
// Subscribe to referral events for real-time updates on People Chain
useEffect(() => {
if (!peopleApi || !address) return;
let unsub: (() => void) | undefined;
subscribeToReferralEvents(peopleApi, (event) => {
// If this user is involved in the event, refresh stats
if (event.referrer === address || event.referred === address) {
if (event.type === 'confirmed') {
hapticNotification('success');
showAlert(`Referral hat pejirandin! Hejmara te: ${event.count}`);
}
fetchStats();
}
}).then((unsubFn) => {
unsub = unsubFn;
});
return () => {
if (unsub) unsub();
};
}, [peopleApi, address, hapticNotification, showAlert, fetchStats]);
const value: ReferralContextValue = {
stats,
myReferrals,
loading,
refreshStats: fetchStats,
};
return <ReferralContext.Provider value={value}>{children}</ReferralContext.Provider>;
}
export function useReferral() {
const context = useContext(ReferralContext);
if (context === undefined) {
throw new Error('useReferral must be used within a ReferralProvider');
}
return context;
}
+318
View File
@@ -0,0 +1,318 @@
/**
* Wallet Context
* Manages wallet state and operations
*/
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { ApiPromise } from '@pezkuwi/api';
import type { KeyringPair } from '@pezkuwi/keyring/types';
import {
initWalletService,
generateMnemonic,
getAddressFromMnemonic,
createKeypair,
validateMnemonic,
} from '@/lib/wallet-service';
import {
hasStoredWallet,
getStoredAddress,
saveWallet,
unlockWallet,
deleteWallet,
syncWalletToSupabase,
} from '@/lib/wallet-storage';
import { validatePassword } from '@/lib/crypto';
import { supabase } from '@/lib/supabase';
import { useAuth } from './AuthContext';
import {
initRPCConnection,
subscribeToConnection,
initAssetHubConnection,
initPeopleConnection,
} from '@/lib/rpc-manager';
interface WalletContextType {
// State
isInitialized: boolean;
isConnected: boolean;
isLoading: boolean;
address: string | null;
balance: string | null;
error: string | null;
// Wallet management
hasWallet: boolean;
generateNewWallet: () => { mnemonic: string; address: string };
confirmWallet: (mnemonic: string, password: string) => Promise<void>;
importWallet: (mnemonic: string, password: string) => Promise<string>;
connect: (password: string) => Promise<void>;
disconnect: () => void;
deleteWalletData: () => void;
// API
api: ApiPromise | null;
assetHubApi: ApiPromise | null;
peopleApi: ApiPromise | null;
keypair: KeyringPair | null;
}
const WalletContext = createContext<WalletContextType | undefined>(undefined);
export function WalletProvider({ children }: { children: React.ReactNode }) {
const { user, isAuthenticated } = useAuth();
// State
const [isInitialized, setIsInitialized] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [address, setAddress] = useState<string | null>(null);
const [balance, setBalance] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [api, setApi] = useState<ApiPromise | null>(null);
const [assetHubApi, setAssetHubApi] = useState<ApiPromise | null>(null);
const [peopleApi, setPeopleApi] = useState<ApiPromise | null>(null);
const [keypair, setKeypair] = useState<KeyringPair | null>(null);
const hasWallet = hasStoredWallet();
// Initialize wallet service and API
useEffect(() => {
const init = async () => {
try {
// Init crypto
await initWalletService();
// Load stored address
const storedAddress = getStoredAddress();
if (storedAddress) {
setAddress(storedAddress);
}
// Mark as initialized immediately - wallet can work without RPC
setIsInitialized(true);
setIsLoading(false);
// Connect to RPC in background (non-blocking)
initRPCConnection()
.then((apiInstance) => {
setApi(apiInstance);
})
.catch((err) => {
console.error('RPC connection error:', err);
// Don't set error - wallet still works offline for create/import
});
// Connect to Asset Hub for PEZ token (non-blocking)
initAssetHubConnection()
.then((assetHubInstance) => {
setAssetHubApi(assetHubInstance);
})
.catch((err) => {
console.error('Asset Hub connection error:', err);
// Don't set error - PEZ features just won't work
});
// Connect to People Chain for identity (non-blocking)
initPeopleConnection()
.then((peopleInstance) => {
setPeopleApi(peopleInstance);
})
.catch((err) => {
console.error('People Chain connection error:', err);
// Don't set error - Identity features just won't work
});
} catch (err) {
console.error('Wallet init error:', err);
setError('Wallet dest pê nekir');
setIsLoading(false);
}
};
init();
// Subscribe to connection state changes (only show error if already was connected)
let wasConnected = false;
const unsubscribe = subscribeToConnection((connected, endpoint) => {
if (connected && endpoint) {
wasConnected = true;
setError(null);
} else if (wasConnected && !connected) {
// Only show disconnect error if we were previously connected
setError('Têkiliya RPC qut bû. Dîsa girêdan tê kirin...');
}
});
return () => {
unsubscribe();
};
}, []);
// Subscribe to balance changes when connected
useEffect(() => {
let unsubscribe: (() => void) | undefined;
if (!api || !address || !isConnected) {
setBalance(null);
return;
}
const subscribeToBalance = async () => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const unsub = await (api.query.system.account as any)(
address,
(accountInfo: { data: { free: { toString: () => string } } }) => {
const free = accountInfo.data.free.toString();
// Convert from smallest unit (12 decimals)
const balanceNum = Number(free) / 1e12;
setBalance(balanceNum.toFixed(4));
}
);
unsubscribe = unsub;
} catch (err) {
console.error('Balance subscription error:', err);
}
};
subscribeToBalance();
return () => {
if (unsubscribe) {
unsubscribe();
}
};
}, [api, address, isConnected]);
// Generate new wallet (does NOT save - just creates mnemonic)
const generateNewWallet = useCallback((): { mnemonic: string; address: string } => {
const mnemonic = generateMnemonic();
const walletAddress = getAddressFromMnemonic(mnemonic);
return { mnemonic, address: walletAddress };
}, []);
// Confirm wallet after user has backed up seed phrase
const confirmWallet = useCallback(
async (mnemonic: string, password: string): Promise<void> => {
// User must be authenticated first
if (!isAuthenticated || !user?.telegram_id) {
throw new Error('Ji kerema xwe pêşî têkeve');
}
const validation = validatePassword(password);
if (!validation.valid) {
throw new Error(validation.message);
}
const walletAddress = getAddressFromMnemonic(mnemonic);
// Save encrypted locally - ONLY after user confirmed backup
await saveWallet(mnemonic, walletAddress, password);
// Sync wallet address to Supabase
await syncWalletToSupabase(supabase, user.telegram_id, walletAddress);
setAddress(walletAddress);
},
[user, isAuthenticated]
);
// Import existing wallet
const importWallet = useCallback(
async (mnemonic: string, password: string): Promise<string> => {
// User must be authenticated first
if (!isAuthenticated || !user?.telegram_id) {
throw new Error('Ji kerema xwe pêşî têkeve');
}
if (!validateMnemonic(mnemonic)) {
throw new Error('Seed phrase ne derbasdar e');
}
const validation = validatePassword(password);
if (!validation.valid) {
throw new Error(validation.message);
}
const walletAddress = getAddressFromMnemonic(mnemonic);
// Save encrypted locally
await saveWallet(mnemonic, walletAddress, password);
// Sync wallet address to Supabase
await syncWalletToSupabase(supabase, user.telegram_id, walletAddress);
setAddress(walletAddress);
return walletAddress;
},
[user, isAuthenticated]
);
// Connect (unlock) wallet
const connect = useCallback(async (password: string): Promise<void> => {
setIsLoading(true);
setError(null);
try {
const mnemonic = await unlockWallet(password);
const pair = createKeypair(mnemonic);
setKeypair(pair);
setIsConnected(true);
} catch (err) {
const message = err instanceof Error ? err.message : 'Girêdan neserketî';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
}, []);
// Disconnect
const disconnect = useCallback(() => {
setKeypair(null);
setIsConnected(false);
setBalance(null);
}, []);
// Delete wallet
const deleteWalletData = useCallback(() => {
deleteWallet();
setAddress(null);
setKeypair(null);
setIsConnected(false);
setBalance(null);
}, []);
return (
<WalletContext.Provider
value={{
isInitialized,
isConnected,
isLoading,
address,
balance,
error,
hasWallet,
generateNewWallet,
confirmWallet,
importWallet,
connect,
disconnect,
deleteWalletData,
api,
assetHubApi,
peopleApi,
keypair,
}}
>
{children}
</WalletContext.Provider>
);
}
export function useWallet() {
const context = useContext(WalletContext);
if (!context) {
throw new Error('useWallet must be used within WalletProvider');
}
return context;
}
+481
View File
@@ -0,0 +1,481 @@
/**
* Forum Hook - Fetches forum data from Supabase
* Copied from pwap/web and adapted for Telegram Mini App
*/
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/lib/supabase';
export interface AdminAnnouncement {
id: string;
title: string;
content: string;
type: 'info' | 'warning' | 'success' | 'critical';
priority: number;
created_at: string;
expires_at?: string;
}
export interface ForumCategory {
id: string;
name: string;
description: string;
icon: string;
color: string;
discussion_count?: number;
}
export interface ForumDiscussion {
id: string;
category_id: string;
category?: ForumCategory;
proposal_id?: string;
title: string;
content: string;
image_url?: string;
author_id: string;
author_name: string;
author_address?: string;
is_pinned: boolean;
is_locked: boolean;
views_count: number;
replies_count: number;
tags: string[];
created_at: string;
updated_at: string;
last_activity_at: string;
upvotes: number;
downvotes: number;
userVote?: 'upvote' | 'downvote' | null;
}
export interface ForumReply {
id: string;
discussion_id: string;
parent_reply_id?: string;
content: string;
author_id: string;
author_name: string;
author_address?: string;
is_edited: boolean;
edited_at?: string;
created_at: string;
upvotes: number;
downvotes: number;
userVote?: 'upvote' | 'downvote' | null;
}
interface CreateDiscussionParams {
title: string;
content: string;
category_id: string;
author_id: string;
author_name: string;
author_address?: string;
tags?: string[];
image_url?: string;
}
interface CreateReplyParams {
discussion_id: string;
content: string;
author_id: string;
author_name: string;
author_address?: string;
parent_reply_id?: string;
}
export function useForum(userId?: string) {
const [announcements, setAnnouncements] = useState<AdminAnnouncement[]>([]);
const [categories, setCategories] = useState<ForumCategory[]>([]);
const [discussions, setDiscussions] = useState<ForumDiscussion[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchAnnouncements = useCallback(async () => {
try {
const { data, error } = await supabase
.from('admin_announcements')
.select('*')
.eq('is_active', true)
.or(`expires_at.is.null,expires_at.gt.${new Date().toISOString()}`)
.order('priority', { ascending: false })
.order('created_at', { ascending: false })
.limit(3);
if (error) throw error;
setAnnouncements(data || []);
} catch (err) {
console.error('Error fetching announcements:', err);
}
}, []);
const fetchCategories = useCallback(async () => {
try {
const { data, error } = await supabase
.from('forum_categories')
.select('*')
.eq('is_active', true)
.order('display_order');
if (error) throw error;
setCategories(data || []);
} catch (err) {
console.error('Error fetching categories:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch categories');
}
}, []);
const fetchDiscussions = useCallback(async () => {
try {
const { data, error } = await supabase
.from('forum_discussions')
.select(
`
*,
category:forum_categories(*)
`
)
.order('is_pinned', { ascending: false })
.order('last_activity_at', { ascending: false })
.limit(50);
if (error) throw error;
// Fetch reaction counts and user votes for each discussion
const discussionsWithReactions = await Promise.all(
(data || []).map(async (discussion) => {
const { data: reactions } = await supabase
.from('forum_reactions')
.select('reaction_type, user_id')
.eq('discussion_id', discussion.id);
const upvotes = reactions?.filter((r) => r.reaction_type === 'upvote').length || 0;
const downvotes = reactions?.filter((r) => r.reaction_type === 'downvote').length || 0;
const userVote = userId
? reactions?.find((r) => r.user_id === userId)?.reaction_type || null
: null;
return {
...discussion,
upvotes,
downvotes,
userVote,
};
})
);
setDiscussions(discussionsWithReactions);
} catch (err) {
console.error('Error fetching discussions:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch discussions');
}
}, [userId]);
const fetchForumData = useCallback(async () => {
setLoading(true);
setError(null);
await Promise.all([fetchAnnouncements(), fetchCategories(), fetchDiscussions()]);
setLoading(false);
}, [fetchAnnouncements, fetchCategories, fetchDiscussions]);
// Fetch replies for a specific discussion
const fetchReplies = useCallback(
async (discussionId: string): Promise<ForumReply[]> => {
try {
const { data, error } = await supabase
.from('forum_replies')
.select('*')
.eq('discussion_id', discussionId)
.order('created_at', { ascending: true });
if (error) throw error;
// Fetch reaction counts for replies
const repliesWithReactions = await Promise.all(
(data || []).map(async (reply) => {
const { data: reactions } = await supabase
.from('forum_reactions')
.select('reaction_type, user_id')
.eq('reply_id', reply.id);
const upvotes = reactions?.filter((r) => r.reaction_type === 'upvote').length || 0;
const downvotes = reactions?.filter((r) => r.reaction_type === 'downvote').length || 0;
const userVote = userId
? reactions?.find((r) => r.user_id === userId)?.reaction_type || null
: null;
return {
...reply,
upvotes,
downvotes,
userVote,
};
})
);
return repliesWithReactions;
} catch (err) {
console.error('Error fetching replies:', err);
return [];
}
},
[userId]
);
// Create a new discussion
const createDiscussion = useCallback(
async (params: CreateDiscussionParams): Promise<ForumDiscussion | null> => {
try {
const { data, error } = await supabase
.from('forum_discussions')
.insert({
title: params.title,
content: params.content,
category_id: params.category_id,
author_id: params.author_id,
author_name: params.author_name,
author_address: params.author_address,
tags: params.tags || [],
image_url: params.image_url,
is_pinned: false,
is_locked: false,
views_count: 0,
replies_count: 0,
last_activity_at: new Date().toISOString(),
})
.select()
.single();
if (error) throw error;
// Refresh discussions
await fetchDiscussions();
return data;
} catch (err) {
console.error('Error creating discussion:', err);
throw err;
}
},
[fetchDiscussions]
);
// Create a new reply
const createReply = useCallback(async (params: CreateReplyParams): Promise<ForumReply | null> => {
try {
const { data, error } = await supabase
.from('forum_replies')
.insert({
discussion_id: params.discussion_id,
content: params.content,
author_id: params.author_id,
author_name: params.author_name,
author_address: params.author_address,
parent_reply_id: params.parent_reply_id,
is_edited: false,
})
.select()
.single();
if (error) throw error;
// Update discussion's reply count and last_activity_at
await supabase
.from('forum_discussions')
.update({
replies_count: supabase.rpc('increment_replies', { discussion_id: params.discussion_id }),
last_activity_at: new Date().toISOString(),
})
.eq('id', params.discussion_id);
return data;
} catch (err) {
console.error('Error creating reply:', err);
throw err;
}
}, []);
// Vote on a discussion
const voteOnDiscussion = useCallback(
async (
discussionId: string,
visitorUserId: string,
voteType: 'upvote' | 'downvote'
): Promise<void> => {
try {
// Check if user already voted
const { data: existingVote } = await supabase
.from('forum_reactions')
.select('*')
.eq('discussion_id', discussionId)
.eq('user_id', visitorUserId)
.single();
if (existingVote) {
if (existingVote.reaction_type === voteType) {
// Remove vote if same type
await supabase.from('forum_reactions').delete().eq('id', existingVote.id);
} else {
// Update vote if different type
await supabase
.from('forum_reactions')
.update({ reaction_type: voteType })
.eq('id', existingVote.id);
}
} else {
// Create new vote
await supabase.from('forum_reactions').insert({
discussion_id: discussionId,
user_id: visitorUserId,
reaction_type: voteType,
});
}
// Refresh discussions to update counts
await fetchDiscussions();
} catch (err) {
console.error('Error voting on discussion:', err);
throw err;
}
},
[fetchDiscussions]
);
// Vote on a reply
const voteOnReply = useCallback(
async (
replyId: string,
visitorUserId: string,
voteType: 'upvote' | 'downvote'
): Promise<void> => {
try {
// Check if user already voted
const { data: existingVote } = await supabase
.from('forum_reactions')
.select('*')
.eq('reply_id', replyId)
.eq('user_id', visitorUserId)
.single();
if (existingVote) {
if (existingVote.reaction_type === voteType) {
// Remove vote if same type
await supabase.from('forum_reactions').delete().eq('id', existingVote.id);
} else {
// Update vote if different type
await supabase
.from('forum_reactions')
.update({ reaction_type: voteType })
.eq('id', existingVote.id);
}
} else {
// Create new vote
await supabase.from('forum_reactions').insert({
reply_id: replyId,
user_id: visitorUserId,
reaction_type: voteType,
});
}
} catch (err) {
console.error('Error voting on reply:', err);
throw err;
}
},
[]
);
// Increment view count
const incrementViewCount = useCallback(async (discussionId: string): Promise<void> => {
try {
const { data } = await supabase
.from('forum_discussions')
.select('views_count')
.eq('id', discussionId)
.single();
if (data) {
await supabase
.from('forum_discussions')
.update({ views_count: (data.views_count || 0) + 1 })
.eq('id', discussionId);
}
} catch (err) {
console.error('Error incrementing view count:', err);
}
}, []);
// Initial fetch
useEffect(() => {
fetchForumData();
}, [fetchForumData]);
// Subscribe to real-time updates
useEffect(() => {
const discussionsSubscription = supabase
.channel('forum_discussions')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'forum_discussions',
},
() => {
fetchDiscussions();
}
)
.subscribe();
const announcementsSubscription = supabase
.channel('admin_announcements')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'admin_announcements',
},
() => {
fetchAnnouncements();
}
)
.subscribe();
const repliesSubscription = supabase
.channel('forum_replies')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'forum_replies',
},
() => {
fetchDiscussions();
}
)
.subscribe();
return () => {
discussionsSubscription.unsubscribe();
announcementsSubscription.unsubscribe();
repliesSubscription.unsubscribe();
};
}, [fetchDiscussions, fetchAnnouncements]);
return {
announcements,
categories,
discussions,
loading,
error,
refreshData: fetchForumData,
fetchReplies,
createDiscussion,
createReply,
voteOnDiscussion,
voteOnReply,
incrementViewCount,
};
}
+629
View File
@@ -0,0 +1,629 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/lib/supabase';
import type {
DbUser,
DbAnnouncementWithAuthor,
DbAnnouncementReaction,
DbThreadWithAuthor,
DbReplyWithAuthor,
AnnouncementCounters,
ThreadCounters,
ReplyCounters,
} from '@/types/database';
// ==================== PUBLIC TYPES ====================
export interface User {
id: string;
telegram_id: number;
username: string | null;
first_name: string;
last_name: string | null;
photo_url: string | null;
is_admin: boolean;
}
export interface Announcement {
id: string;
title: string;
content: string;
image_url: string | null;
link_url: string | null;
author_id: string;
likes: number;
dislikes: number;
views: number;
created_at: string;
author: { username: string | null; first_name: string; photo_url: string | null } | null;
user_reaction: 'like' | 'dislike' | null;
}
export interface Thread {
id: string;
title: string;
content: string;
author_id: string;
reply_count: number;
likes: number;
views: number;
last_activity: string;
created_at: string;
author: { username: string | null; first_name: string; photo_url: string | null } | null;
user_liked: boolean;
}
export interface Reply {
id: string;
thread_id: string;
content: string;
author_id: string;
likes: number;
created_at: string;
author: { username: string | null; first_name: string; photo_url: string | null } | null;
user_liked: boolean;
}
// ==================== HELPER TYPES ====================
interface ReactionRecord {
announcement_id: string;
reaction: 'like' | 'dislike';
}
interface ThreadLikeRecord {
thread_id: string;
}
interface ReplyLikeRecord {
reply_id: string;
}
interface IdRecord {
id: string;
}
// ==================== USER ====================
export function useCurrentUser() {
return useQuery({
queryKey: ['currentUser'],
queryFn: async (): Promise<User | null> => {
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return null;
const { data } = await supabase.from('tg_users').select('*').eq('id', user.id).single();
return data as DbUser | null;
},
});
}
// ==================== ANNOUNCEMENTS ====================
export function useAnnouncements() {
const { data: currentUser } = useCurrentUser();
return useQuery({
queryKey: ['announcements'],
queryFn: async (): Promise<Announcement[]> => {
const { data, error } = await supabase
.from('tg_announcements')
.select(
`
*,
author:tg_users!author_id(username, first_name, photo_url)
`
)
.eq('is_published', true)
.order('created_at', { ascending: false })
.limit(50);
if (error) throw error;
const announcements = (data || []) as DbAnnouncementWithAuthor[];
// Get user's reactions if logged in
if (currentUser && announcements.length > 0) {
const { data: reactions } = await supabase
.from('tg_announcement_reactions')
.select('announcement_id, reaction')
.eq('user_id', currentUser.id);
const reactionMap = new Map<string, 'like' | 'dislike'>(
((reactions || []) as ReactionRecord[]).map((r) => [r.announcement_id, r.reaction])
);
return announcements.map((a) => ({
...a,
user_reaction: reactionMap.get(a.id) || null,
}));
}
return announcements.map((a) => ({ ...a, user_reaction: null }));
},
});
}
export function useAnnouncementReaction() {
const queryClient = useQueryClient();
const { data: currentUser } = useCurrentUser();
return useMutation({
mutationFn: async ({
announcementId,
reaction,
}: {
announcementId: string;
reaction: 'like' | 'dislike';
}) => {
if (!currentUser) throw new Error('Not authenticated');
// Check existing reaction
const { data: existing } = await supabase
.from('tg_announcement_reactions')
.select('*')
.eq('announcement_id', announcementId)
.eq('user_id', currentUser.id)
.single();
const existingReaction = existing as DbAnnouncementReaction | null;
if (existingReaction) {
if (existingReaction.reaction === reaction) {
// Remove reaction
await supabase.from('tg_announcement_reactions').delete().eq('id', existingReaction.id);
// Decrement counter
const { data: ann } = await supabase
.from('tg_announcements')
.select(reaction === 'like' ? 'likes' : 'dislikes')
.eq('id', announcementId)
.single();
const counters = ann as Partial<AnnouncementCounters> | null;
const currentCount = counters?.[reaction === 'like' ? 'likes' : 'dislikes'] ?? 0;
await supabase
.from('tg_announcements')
.update({ [reaction === 'like' ? 'likes' : 'dislikes']: Math.max(0, currentCount - 1) })
.eq('id', announcementId);
} else {
// Change reaction
const oldReaction = existingReaction.reaction;
await supabase
.from('tg_announcement_reactions')
.update({ reaction })
.eq('id', existingReaction.id);
// Update counters
const { data: ann } = await supabase
.from('tg_announcements')
.select('likes, dislikes')
.eq('id', announcementId)
.single();
const counters = ann as AnnouncementCounters | null;
const updates: Partial<AnnouncementCounters> = {};
if (oldReaction === 'like') {
updates.likes = Math.max(0, (counters?.likes ?? 0) - 1);
} else {
updates.dislikes = Math.max(0, (counters?.dislikes ?? 0) - 1);
}
if (reaction === 'like') {
updates.likes = (counters?.likes ?? 0) + (oldReaction === 'like' ? 0 : 1);
} else {
updates.dislikes = (counters?.dislikes ?? 0) + (oldReaction === 'dislike' ? 0 : 1);
}
await supabase.from('tg_announcements').update(updates).eq('id', announcementId);
}
} else {
// Add new reaction
await supabase.from('tg_announcement_reactions').insert({
announcement_id: announcementId,
user_id: currentUser.id,
reaction,
});
// Increment counter
const { data: ann } = await supabase
.from('tg_announcements')
.select(reaction === 'like' ? 'likes' : 'dislikes')
.eq('id', announcementId)
.single();
const counters = ann as Partial<AnnouncementCounters> | null;
const currentCount = counters?.[reaction === 'like' ? 'likes' : 'dislikes'] ?? 0;
await supabase
.from('tg_announcements')
.update({ [reaction === 'like' ? 'likes' : 'dislikes']: currentCount + 1 })
.eq('id', announcementId);
}
},
onMutate: async ({ announcementId, reaction }) => {
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
await queryClient.cancelQueries({ queryKey: ['announcements'] });
// Snapshot the previous value
const previousAnnouncements = queryClient.getQueryData<Announcement[]>(['announcements']);
// Optimistically update to the new value
queryClient.setQueryData<Announcement[]>(['announcements'], (old) => {
if (!old) return [];
return old.map((ann) => {
if (ann.id === announcementId) {
const currentReaction = ann.user_reaction;
let newLikes = ann.likes;
let newDislikes = ann.dislikes;
let newReaction: 'like' | 'dislike' | null = reaction;
if (currentReaction === reaction) {
// Toggling off
newReaction = null;
if (reaction === 'like') newLikes--;
else newDislikes--;
} else {
// Changing reaction or adding new
if (currentReaction === 'like') newLikes--;
if (currentReaction === 'dislike') newDislikes--;
if (reaction === 'like') newLikes++;
else newDislikes++;
}
return {
...ann,
user_reaction: newReaction,
likes: Math.max(0, newLikes),
dislikes: Math.max(0, newDislikes),
};
}
return ann;
});
});
// Return a context object with the snapshotted value
return { previousAnnouncements };
},
onError: (_err, _newTodo, context) => {
// If the mutation fails, use the context returned from onMutate to roll back
if (context?.previousAnnouncements) {
queryClient.setQueryData(['announcements'], context.previousAnnouncements);
}
},
onSettled: () => {
// Always refetch after error or success:
queryClient.invalidateQueries({ queryKey: ['announcements'] });
},
});
}
// ==================== FORUM ====================
export function useThreads(sort: 'latest' | 'popular' | 'hot' = 'latest') {
const { data: currentUser } = useCurrentUser();
return useQuery({
queryKey: ['threads', sort],
queryFn: async (): Promise<Thread[]> => {
let query = supabase
.from('tg_threads')
.select(
`
*,
author:tg_users!author_id(username, first_name, photo_url)
`
)
.limit(50);
if (sort === 'latest') {
query = query.order('created_at', { ascending: false });
} else if (sort === 'popular') {
query = query.order('reply_count', { ascending: false });
} else if (sort === 'hot') {
query = query.order('last_activity', { ascending: false });
}
const { data, error } = await query;
if (error) throw error;
const threads = (data || []) as DbThreadWithAuthor[];
// Get user's likes if logged in
if (currentUser && threads.length > 0) {
const { data: likes } = await supabase
.from('tg_thread_likes')
.select('thread_id')
.eq('user_id', currentUser.id);
const likedIds = new Set(((likes || []) as ThreadLikeRecord[]).map((l) => l.thread_id));
return threads.map((t) => ({
...t,
user_liked: likedIds.has(t.id),
}));
}
return threads.map((t) => ({ ...t, user_liked: false }));
},
});
}
export function useThread(threadId: string | null) {
const { data: currentUser } = useCurrentUser();
return useQuery({
queryKey: ['thread', threadId],
queryFn: async (): Promise<{ thread: Thread; replies: Reply[] } | null> => {
if (!threadId) return null;
const { data: thread, error } = await supabase
.from('tg_threads')
.select(
`
*,
author:tg_users!author_id(username, first_name, photo_url)
`
)
.eq('id', threadId)
.single();
if (error) throw error;
const threadData = thread as DbThreadWithAuthor;
const { data: replies } = await supabase
.from('tg_replies')
.select(
`
*,
author:tg_users!author_id(username, first_name, photo_url)
`
)
.eq('thread_id', threadId)
.order('created_at', { ascending: true });
const repliesData = (replies || []) as DbReplyWithAuthor[];
// Increment view count
await supabase
.from('tg_threads')
.update({ views: (threadData.views ?? 0) + 1 })
.eq('id', threadId);
// Get user's likes
let userLikedThread = false;
let likedReplyIds = new Set<string>();
if (currentUser) {
const { data: threadLike } = await supabase
.from('tg_thread_likes')
.select('id')
.eq('thread_id', threadId)
.eq('user_id', currentUser.id)
.single();
userLikedThread = !!threadLike;
if (repliesData.length > 0) {
const { data: replyLikes } = await supabase
.from('tg_reply_likes')
.select('reply_id')
.eq('user_id', currentUser.id)
.in(
'reply_id',
repliesData.map((r) => r.id)
);
likedReplyIds = new Set(((replyLikes || []) as ReplyLikeRecord[]).map((l) => l.reply_id));
}
}
return {
thread: { ...threadData, user_liked: userLikedThread },
replies: repliesData.map((r) => ({
...r,
user_liked: likedReplyIds.has(r.id),
})),
};
},
enabled: !!threadId,
});
}
export function useCreateThread() {
const queryClient = useQueryClient();
const { data: currentUser } = useCurrentUser();
return useMutation({
mutationFn: async ({ title, content }: { title: string; content: string }): Promise<Thread> => {
if (!currentUser) throw new Error('Not authenticated');
const { data, error } = await supabase
.from('tg_threads')
.insert({
title,
content,
author_id: currentUser.id,
last_activity: new Date().toISOString(),
})
.select(
`
*,
author:tg_users!author_id(username, first_name, photo_url)
`
)
.single();
if (error) throw error;
const threadData = data as DbThreadWithAuthor;
return { ...threadData, user_liked: false };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['threads'] });
},
});
}
export function useCreateReply() {
const queryClient = useQueryClient();
const { data: currentUser } = useCurrentUser();
return useMutation({
mutationFn: async ({
threadId,
content,
}: {
threadId: string;
content: string;
}): Promise<Reply> => {
if (!currentUser) throw new Error('Not authenticated');
const { data, error } = await supabase
.from('tg_replies')
.insert({
thread_id: threadId,
content,
author_id: currentUser.id,
})
.select(
`
*,
author:tg_users!author_id(username, first_name, photo_url)
`
)
.single();
if (error) throw error;
// Update thread reply count and last activity
const { data: thread } = await supabase
.from('tg_threads')
.select('reply_count')
.eq('id', threadId)
.single();
const threadCounters = thread as Partial<ThreadCounters> | null;
await supabase
.from('tg_threads')
.update({
reply_count: (threadCounters?.reply_count ?? 0) + 1,
last_activity: new Date().toISOString(),
})
.eq('id', threadId);
const replyData = data as DbReplyWithAuthor;
return { ...replyData, user_liked: false };
},
onSuccess: (_, { threadId }) => {
queryClient.invalidateQueries({ queryKey: ['thread', threadId] });
queryClient.invalidateQueries({ queryKey: ['threads'] });
},
});
}
export function useToggleThreadLike() {
const queryClient = useQueryClient();
const { data: currentUser } = useCurrentUser();
return useMutation({
mutationFn: async (threadId: string) => {
if (!currentUser) throw new Error('Not authenticated');
const { data: existing } = await supabase
.from('tg_thread_likes')
.select('id')
.eq('thread_id', threadId)
.eq('user_id', currentUser.id)
.single();
const existingLike = existing as IdRecord | null;
const { data: thread } = await supabase
.from('tg_threads')
.select('likes')
.eq('id', threadId)
.single();
const threadCounters = thread as Partial<ThreadCounters> | null;
const currentLikes = threadCounters?.likes ?? 0;
if (existingLike) {
await supabase.from('tg_thread_likes').delete().eq('id', existingLike.id);
await supabase
.from('tg_threads')
.update({ likes: Math.max(0, currentLikes - 1) })
.eq('id', threadId);
} else {
await supabase.from('tg_thread_likes').insert({
thread_id: threadId,
user_id: currentUser.id,
});
await supabase
.from('tg_threads')
.update({ likes: currentLikes + 1 })
.eq('id', threadId);
}
return threadId;
},
onSuccess: (threadId) => {
queryClient.invalidateQueries({ queryKey: ['thread', threadId] });
queryClient.invalidateQueries({ queryKey: ['threads'] });
},
});
}
export function useToggleReplyLike() {
const queryClient = useQueryClient();
const { data: currentUser } = useCurrentUser();
return useMutation({
mutationFn: async ({ replyId, threadId }: { replyId: string; threadId: string }) => {
if (!currentUser) throw new Error('Not authenticated');
const { data: existing } = await supabase
.from('tg_reply_likes')
.select('id')
.eq('reply_id', replyId)
.eq('user_id', currentUser.id)
.single();
const existingLike = existing as IdRecord | null;
const { data: reply } = await supabase
.from('tg_replies')
.select('likes')
.eq('id', replyId)
.single();
const replyCounters = reply as Partial<ReplyCounters> | null;
const currentLikes = replyCounters?.likes ?? 0;
if (existingLike) {
await supabase.from('tg_reply_likes').delete().eq('id', existingLike.id);
await supabase
.from('tg_replies')
.update({ likes: Math.max(0, currentLikes - 1) })
.eq('id', replyId);
} else {
await supabase.from('tg_reply_likes').insert({
reply_id: replyId,
user_id: currentUser.id,
});
await supabase
.from('tg_replies')
.update({ likes: currentLikes + 1 })
.eq('id', replyId);
}
return threadId;
},
onSuccess: (threadId) => {
queryClient.invalidateQueries({ queryKey: ['thread', threadId] });
},
});
}
+115
View File
@@ -0,0 +1,115 @@
import { useCallback, useMemo } from 'react';
export function useTelegram() {
const tg = useMemo(() => window.Telegram?.WebApp, []);
const user = useMemo(() => tg?.initDataUnsafe?.user, [tg]);
const startParam = useMemo(() => tg?.initDataUnsafe?.start_param, [tg]);
const hapticImpact = useCallback(
(style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft' = 'medium') => {
tg?.HapticFeedback.impactOccurred(style);
},
[tg]
);
const hapticNotification = useCallback(
(type: 'success' | 'warning' | 'error' = 'success') => {
tg?.HapticFeedback.notificationOccurred(type);
},
[tg]
);
const hapticSelection = useCallback(() => {
tg?.HapticFeedback.selectionChanged();
}, [tg]);
const showAlert = useCallback(
(message: string) => {
if (tg) {
tg.showAlert(message);
} else {
window.alert(message);
}
},
[tg]
);
const showConfirm = useCallback(
(message: string): Promise<boolean> => {
return new Promise((resolve) => {
if (tg) {
tg.showConfirm(message, resolve);
} else {
resolve(window.confirm(message));
}
});
},
[tg]
);
const openLink = useCallback(
(url: string) => {
// Validate URL to prevent javascript: and data: protocol attacks
try {
const parsed = new globalThis.URL(url);
const allowedProtocols = ['http:', 'https:', 'tg:', 'mailto:'];
if (!allowedProtocols.includes(parsed.protocol)) {
return;
}
} catch {
return;
}
if (tg) {
tg.openLink(url);
} else {
window.open(url, '_blank', 'noopener,noreferrer');
}
},
[tg]
);
const openTelegramLink = useCallback(
(url: string) => {
if (tg) {
tg.openTelegramLink(url);
} else {
window.open(url, '_blank');
}
},
[tg]
);
const shareUrl = useCallback(
(url: string, text?: string) => {
const shareText = text ? encodeURIComponent(text) : '';
const shareUrl = encodeURIComponent(url);
openTelegramLink(`https://t.me/share/url?url=${shareUrl}&text=${shareText}`);
},
[openTelegramLink]
);
const close = useCallback(() => {
tg?.close();
}, [tg]);
return {
tg,
user,
startParam,
isAvailable: !!tg,
platform: tg?.platform || 'unknown',
version: tg?.version || '0.0',
hapticImpact,
hapticNotification,
hapticSelection,
showAlert,
showConfirm,
openLink,
openTelegramLink,
shareUrl,
close,
};
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Version management hook
* Handles version checking and auto-update notifications
*/
import { useState, useEffect, useCallback } from 'react';
import versionInfo from '@/version.json';
const VERSION_STORAGE_KEY = 'pezkuwi_app_version';
const VERSION_CHECK_INTERVAL = 5 * 60 * 1000; // Check every 5 minutes
interface VersionState {
currentVersion: string;
buildTime: string;
buildNumber: number;
hasUpdate: boolean;
isChecking: boolean;
}
export function useVersion() {
const [state, setState] = useState<VersionState>({
currentVersion: versionInfo.version,
buildTime: versionInfo.buildTime,
buildNumber: versionInfo.buildNumber,
hasUpdate: false,
isChecking: false,
});
// Check if this is a new version (first load after update)
useEffect(() => {
const storedVersion = localStorage.getItem(VERSION_STORAGE_KEY);
if (storedVersion !== versionInfo.version) {
// New version detected - clear old cache
if (storedVersion) {
// Clear any cached data that might be stale
clearStaleCache();
}
// Store new version
localStorage.setItem(VERSION_STORAGE_KEY, versionInfo.version);
}
}, []);
// Check for updates by fetching version.json from server
const checkForUpdate = useCallback(async () => {
setState((prev) => ({ ...prev, isChecking: true }));
try {
// Add cache-busting timestamp
const response = await fetch(`/src/version.json?t=${Date.now()}`, {
cache: 'no-store',
});
if (!response.ok) {
// Try alternative path (for production build)
const altResponse = await fetch(`/version.json?t=${Date.now()}`, {
cache: 'no-store',
});
if (!altResponse.ok) throw new Error('Version check failed');
const serverVersion = await altResponse.json();
handleVersionCheck(serverVersion);
return;
}
const serverVersion = await response.json();
handleVersionCheck(serverVersion);
} catch (error) {
// Silently fail - not critical
if (import.meta.env.DEV) {
console.warn('[Version] Check failed:', error);
}
} finally {
setState((prev) => ({ ...prev, isChecking: false }));
}
}, []);
const handleVersionCheck = (serverVersion: typeof versionInfo) => {
if (serverVersion.buildNumber > versionInfo.buildNumber) {
setState((prev) => ({ ...prev, hasUpdate: true }));
}
};
// Periodic update check
useEffect(() => {
// Initial check after 30 seconds
const initialTimeout = setTimeout(checkForUpdate, 30000);
// Then check periodically
const interval = setInterval(checkForUpdate, VERSION_CHECK_INTERVAL);
return () => {
clearTimeout(initialTimeout);
clearInterval(interval);
};
}, [checkForUpdate]);
// Force refresh to get new version
const forceUpdate = useCallback(() => {
// Clear all caches
clearStaleCache();
// Force reload without cache
window.location.reload();
}, []);
// Dismiss update notification
const dismissUpdate = useCallback(() => {
setState((prev) => ({ ...prev, hasUpdate: false }));
}, []);
return {
...state,
checkForUpdate,
forceUpdate,
dismissUpdate,
};
}
// Clear stale cached data
function clearStaleCache() {
try {
// Clear React Query cache if available
if ('caches' in window && window.caches) {
window.caches.keys().then((names) => {
names.forEach((name) => window.caches?.delete(name));
});
}
// Clear specific app cache keys (not wallet data)
const keysToPreserve = ['pezkuwi_wallet', 'pezkuwi_app_version'];
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && !keysToPreserve.includes(key) && key.startsWith('pezkuwi_')) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
// Clear sessionStorage
window.sessionStorage.clear();
} catch {
// Silently fail - cache clear is not critical
}
}
export default useVersion;
+61
View File
@@ -0,0 +1,61 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--radius: 0.75rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Telegram safe area */
.safe-area-top {
padding-top: env(safe-area-inset-top);
}
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
/* Hide scrollbar but allow scrolling */
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Smooth animations */
.animate-in {
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Crypto Utility Tests
*/
import { describe, it, expect } from 'vitest';
import {
encrypt,
decrypt,
validatePassword,
calculateEntropy,
getPasswordStrength,
hasWeakPatterns,
} from './crypto';
describe('crypto utilities', () => {
describe('encrypt/decrypt', () => {
it('should encrypt and decrypt data correctly', async () => {
const password = 'TestP@ssword123!';
const data = 'sensitive data to encrypt';
const encrypted = await encrypt(data, password);
expect(encrypted).not.toBe(data);
expect(encrypted.length).toBeGreaterThan(0);
const decrypted = await decrypt(encrypted, password);
expect(decrypted).toBe(data);
});
it('should produce different ciphertext for same input (due to random salt/iv)', async () => {
const password = 'TestP@ssword123!';
const data = 'same data';
const encrypted1 = await encrypt(data, password);
const encrypted2 = await encrypt(data, password);
expect(encrypted1).not.toBe(encrypted2);
});
it('should fail to decrypt with wrong password', async () => {
const data = 'test data';
const encrypted = await encrypt(data, 'CorrectP@ss123!');
await expect(decrypt(encrypted, 'WrongP@ssword1!')).rejects.toThrow();
});
it('should handle unicode data', async () => {
const password = 'TestP@ssword123!';
const data = 'سڵاو کوردستان 🇰🇷';
const encrypted = await encrypt(data, password);
const decrypted = await decrypt(encrypted, password);
expect(decrypted).toBe(data);
});
it('should handle empty string', async () => {
const password = 'TestP@ssword123!';
const data = '';
const encrypted = await encrypt(data, password);
const decrypted = await decrypt(encrypted, password);
expect(decrypted).toBe(data);
});
it('should handle long data', async () => {
const password = 'TestP@ssword123!';
const data = 'a'.repeat(10000);
const encrypted = await encrypt(data, password);
const decrypted = await decrypt(encrypted, password);
expect(decrypted).toBe(data);
});
});
describe('validatePassword', () => {
it('should reject passwords shorter than 12 characters', () => {
const result = validatePassword('Short1!a');
expect(result.valid).toBe(false);
expect(result.message).toContain('12');
});
it('should reject passwords without lowercase', () => {
const result = validatePassword('ALLUPPERCASE123!');
expect(result.valid).toBe(false);
expect(result.message).toContain('biçûk');
});
it('should reject passwords without uppercase', () => {
const result = validatePassword('alllowercase123!');
expect(result.valid).toBe(false);
expect(result.message).toContain('mezin');
});
it('should reject passwords without numbers', () => {
const result = validatePassword('NoNumbersHere!@');
expect(result.valid).toBe(false);
expect(result.message).toContain('hejmar');
});
it('should reject passwords without special characters', () => {
const result = validatePassword('NoSpecial12345a');
expect(result.valid).toBe(false);
expect(result.message).toContain('taybet');
});
it('should accept strong passwords', () => {
const result = validatePassword('StrongP@ssword123!');
expect(result.valid).toBe(true);
expect(result.entropy).toBeGreaterThan(60);
});
it('should return entropy and strength for all passwords', () => {
const result = validatePassword('Test1!');
expect(result.entropy).toBeGreaterThan(0);
expect(result.strength).toBeDefined();
});
});
describe('calculateEntropy', () => {
it('should calculate higher entropy for longer passwords', () => {
const short = calculateEntropy('abc');
const long = calculateEntropy('abcdefghijk');
expect(long).toBeGreaterThan(short);
});
it('should calculate higher entropy for more diverse character sets', () => {
const onlyLower = calculateEntropy('abcdefghij');
const mixed = calculateEntropy('AbC123!@#');
expect(mixed).toBeGreaterThan(onlyLower);
});
it('should return 0 for empty string', () => {
expect(calculateEntropy('')).toBe(0);
});
});
describe('getPasswordStrength', () => {
it('should categorize weak passwords correctly', () => {
expect(getPasswordStrength('abc')).toBe('weak');
expect(getPasswordStrength('abcdef')).toBe('weak');
});
it('should categorize medium passwords correctly', () => {
expect(getPasswordStrength('Abcdef123!')).toBe('medium');
});
it('should categorize strong passwords correctly', () => {
// VeryStr0ngP@ssword! is actually very strong (high entropy)
expect(getPasswordStrength('Str0ngP@ss!')).toBe('strong');
});
it('should categorize very strong passwords correctly', () => {
expect(getPasswordStrength('Super$ecure#P@ssw0rd!2024')).toBe('very-strong');
});
});
describe('hasWeakPatterns', () => {
it('should detect repeated characters', () => {
expect(hasWeakPatterns('aaaaaaaaaaaa')).toBe(true);
});
it('should detect sequential numbers', () => {
expect(hasWeakPatterns('123456789012')).toBe(true);
});
it('should detect common passwords', () => {
expect(hasWeakPatterns('password')).toBe(true);
expect(hasWeakPatterns('qwerty')).toBe(true);
});
it('should not flag strong passwords', () => {
expect(hasWeakPatterns('Xk9#mP2@nQ4!')).toBe(false);
});
});
});
+224
View File
@@ -0,0 +1,224 @@
/**
* Crypto utilities for wallet encryption
* Uses Web Crypto API (AES-GCM)
*
* Security features:
* - AES-256-GCM encryption
* - PBKDF2 key derivation (600K iterations, OWASP 2023 recommendation)
* - 16-byte random salt per encryption
* - 12-byte random IV per encryption
* - Version header for future algorithm updates
*/
const SALT_LENGTH = 16;
const IV_LENGTH = 12;
const VERSION_LENGTH = 1;
const CURRENT_VERSION = 2; // v1: 100K iterations, v2: 600K iterations
// OWASP 2023 recommendation for PBKDF2-SHA256
const KEY_ITERATIONS_V2 = 600000;
const KEY_ITERATIONS_V1 = 100000; // Legacy for backward compatibility
/**
* Derive encryption key from password using PBKDF2
* @param password - User password
* @param salt - Random salt
* @param version - Encryption version (determines iteration count)
*/
async function deriveKey(
password: string,
salt: Uint8Array,
version: number = CURRENT_VERSION
): Promise<CryptoKey> {
const encoder = new TextEncoder();
const passwordKey = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveBits', 'deriveKey']
);
// Use appropriate iteration count based on version
const iterations = version >= 2 ? KEY_ITERATIONS_V2 : KEY_ITERATIONS_V1;
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: new Uint8Array(salt), // Create new Uint8Array for compatibility
iterations,
hash: 'SHA-256',
},
passwordKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
/**
* Encrypt data with password (AES-256-GCM)
* Format: version (1 byte) + salt (16 bytes) + iv (12 bytes) + ciphertext
*/
export async function encrypt(data: string, password: string): Promise<string> {
const encoder = new TextEncoder();
const version = new Uint8Array([CURRENT_VERSION]);
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const key = await deriveKey(password, salt, CURRENT_VERSION);
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoder.encode(data));
// Combine version + salt + iv + encrypted data
const combined = new Uint8Array(VERSION_LENGTH + salt.length + iv.length + encrypted.byteLength);
combined.set(version, 0);
combined.set(salt, VERSION_LENGTH);
combined.set(iv, VERSION_LENGTH + salt.length);
combined.set(new Uint8Array(encrypted), VERSION_LENGTH + salt.length + iv.length);
// Return as base64
return btoa(String.fromCharCode(...combined));
}
/**
* Decrypt data with password
* Supports both v1 (legacy, no version byte) and v2 (with version byte) formats
*/
export async function decrypt(encryptedData: string, password: string): Promise<string> {
const decoder = new TextDecoder();
const combined = new Uint8Array(
atob(encryptedData)
.split('')
.map((c) => c.charCodeAt(0))
);
// Detect version: if first byte is 1 or 2, it's a version header
// Legacy v1 data starts with salt which would be random (unlikely to be 1 or 2)
let version: number;
let offset: number;
if (combined[0] === 1 || combined[0] === 2) {
// New format with version header
version = combined[0];
offset = VERSION_LENGTH;
} else {
// Legacy format (v1) without version header
version = 1;
offset = 0;
}
const salt = combined.slice(offset, offset + SALT_LENGTH);
const iv = combined.slice(offset + SALT_LENGTH, offset + SALT_LENGTH + IV_LENGTH);
const data = combined.slice(offset + SALT_LENGTH + IV_LENGTH);
const key = await deriveKey(password, salt, version);
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data);
return decoder.decode(decrypted);
}
/**
* Calculate password entropy (bits)
* Higher entropy = stronger password
*/
export function calculateEntropy(password: string): number {
let charsetSize = 0;
if (/[a-z]/.test(password)) charsetSize += 26;
if (/[A-Z]/.test(password)) charsetSize += 26;
if (/[0-9]/.test(password)) charsetSize += 10;
if (/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) charsetSize += 32;
if (/\s/.test(password)) charsetSize += 1;
if (charsetSize === 0) return 0;
return Math.floor(password.length * Math.log2(charsetSize));
}
export type PasswordStrength = 'weak' | 'medium' | 'strong' | 'very-strong';
/**
* Get password strength category based on entropy
*/
export function getPasswordStrength(password: string): PasswordStrength {
const entropy = calculateEntropy(password);
if (entropy < 50) return 'weak';
if (entropy < 70) return 'medium';
if (entropy < 90) return 'strong';
return 'very-strong';
}
/**
* Validate password strength
* Requires: 12+ chars, lowercase, uppercase, number, special char
* Minimum entropy: 60 bits
*/
export function validatePassword(password: string): {
valid: boolean;
message?: string;
entropy?: number;
strength?: PasswordStrength;
} {
const entropy = calculateEntropy(password);
const strength = getPasswordStrength(password);
if (password.length < 12) {
return { valid: false, message: 'Şîfre (password) herî kêm 12 tîp be', entropy, strength };
}
if (!/[a-z]/.test(password)) {
return {
valid: false,
message: 'Şîfre (password) herî kêm 1 tîpa biçûk hebe (a-z)',
entropy,
strength,
};
}
if (!/[A-Z]/.test(password)) {
return {
valid: false,
message: 'Şîfre (password) herî kêm 1 tîpa mezin hebe (A-Z)',
entropy,
strength,
};
}
if (!/[0-9]/.test(password)) {
return {
valid: false,
message: 'Şîfre (password) herî kêm 1 hejmar hebe (0-9)',
entropy,
strength,
};
}
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
return {
valid: false,
message: 'Şîfre (password) herî kêm 1 nîşana taybet hebe (!@#$%...)',
entropy,
strength,
};
}
if (entropy < 60) {
return {
valid: false,
message:
'Şîfre (password) ne têra qewî ye. Şîfreyek (password) dirêjtir bi tîpên cûrbecûr biceribîne.',
entropy,
strength,
};
}
return { valid: true, entropy, strength };
}
/**
* Detect common weak patterns in passwords
*/
export function hasWeakPatterns(password: string): boolean {
const weakPatterns = [
/^(.)\1+$/, // All same character
/^(012|123|234|345|456|567|678|789)+$/, // Sequential numbers
/^(abc|bcd|cde|def|efg|fgh|ghi|hij|ijk|jkl|klm|lmn|mno|nop|opq|pqr|qrs|rst|stu|tuv|uvw|vwx|wxy|xyz)+$/i, // Sequential letters
/^(qwerty|asdfgh|zxcvbn)/i, // Keyboard patterns
/^(password|şîfre|parola|123456|qwerty)/i, // Common passwords
];
return weakPatterns.some((pattern) => pattern.test(password.toLowerCase()));
}
+35
View File
@@ -0,0 +1,35 @@
// Environment validation - fail fast if misconfigured
interface EnvConfig {
SUPABASE_URL: string;
SUPABASE_ANON_KEY: string;
IS_DEVELOPMENT: boolean;
IS_PRODUCTION: boolean;
}
function validateEnv(): EnvConfig {
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
const missing: string[] = [];
if (!supabaseUrl) missing.push('VITE_SUPABASE_URL');
if (!supabaseAnonKey) missing.push('VITE_SUPABASE_ANON_KEY');
if (missing.length > 0 && import.meta.env.PROD) {
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
}
if (missing.length > 0) {
console.warn(`[ENV] Missing variables (using fallbacks): ${missing.join(', ')}`);
}
return {
SUPABASE_URL: supabaseUrl || 'https://placeholder.supabase.co',
SUPABASE_ANON_KEY: supabaseAnonKey || 'placeholder-key',
IS_DEVELOPMENT: import.meta.env.DEV,
IS_PRODUCTION: import.meta.env.PROD,
};
}
export const env = validateEnv();
+161
View File
@@ -0,0 +1,161 @@
/**
* Error Tracking Utility Tests
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
trackError,
trackWarning,
getRecentErrors,
clearErrorBuffer,
createError,
extractError,
formatUserError,
} from './error-tracking';
describe('error-tracking utilities', () => {
beforeEach(() => {
clearErrorBuffer();
});
describe('trackError', () => {
it('should add error to buffer', () => {
const error = new Error('Test error');
trackError(error);
const errors = getRecentErrors();
expect(errors).toHaveLength(1);
expect(errors[0].message).toBe('Test error');
});
it('should include context in tracked error', () => {
const error = new Error('Test error');
trackError(error, { component: 'TestComponent', action: 'test_action' });
const errors = getRecentErrors();
expect(errors[0].context?.component).toBe('TestComponent');
expect(errors[0].context?.action).toBe('test_action');
});
it('should include timestamp', () => {
const before = Date.now();
trackError(new Error('Test'));
const after = Date.now();
const errors = getRecentErrors();
expect(errors[0].timestamp).toBeGreaterThanOrEqual(before);
expect(errors[0].timestamp).toBeLessThanOrEqual(after);
});
it('should limit buffer to 50 errors', () => {
for (let i = 0; i < 60; i++) {
trackError(new Error(`Error ${i}`));
}
const errors = getRecentErrors();
expect(errors).toHaveLength(50);
expect(errors[0].message).toBe('Error 10'); // First 10 should be removed
});
});
describe('trackWarning', () => {
it('should not throw', () => {
expect(() => trackWarning('Test warning')).not.toThrow();
});
it('should accept context', () => {
expect(() => trackWarning('Test warning', { action: 'test' })).not.toThrow();
});
});
describe('clearErrorBuffer', () => {
it('should clear all errors', () => {
trackError(new Error('Error 1'));
trackError(new Error('Error 2'));
expect(getRecentErrors()).toHaveLength(2);
clearErrorBuffer();
expect(getRecentErrors()).toHaveLength(0);
});
});
describe('createError', () => {
it('should create and track error', () => {
const error = createError('Created error', { component: 'Test' });
expect(error.message).toBe('Created error');
expect(getRecentErrors()).toHaveLength(1);
});
});
describe('extractError', () => {
it('should return Error instance unchanged', () => {
const error = new Error('Test');
expect(extractError(error)).toBe(error);
});
it('should convert string to Error', () => {
const error = extractError('String error');
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('String error');
});
it('should handle unknown types', () => {
const error = extractError({ weird: 'object' });
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('An unknown error occurred');
});
it('should handle null', () => {
const error = extractError(null);
expect(error).toBeInstanceOf(Error);
});
it('should handle undefined', () => {
const error = extractError(undefined);
expect(error).toBeInstanceOf(Error);
});
});
describe('formatUserError', () => {
it('should format network errors', () => {
const error = new Error('Network Error');
const message = formatUserError(error);
expect(message).toContain('înternetê');
});
it('should format fetch errors', () => {
const error = new Error('Failed to fetch');
const message = formatUserError(error);
expect(message).toContain('înternetê');
});
it('should format timeout errors', () => {
const error = new Error('TIMEOUT');
const message = formatUserError(error);
expect(message).toContain('dirêj');
});
it('should format wallet not found errors', () => {
const error = new Error('Wallet not found');
const message = formatUserError(error);
expect(message).toContain('Wallet');
});
it('should format password errors', () => {
const error = new Error('Şîfre (password) çewt e');
const message = formatUserError(error);
expect(message).toContain('Şîfre');
});
it('should return generic message for unknown errors', () => {
const error = new Error('Some random error');
const message = formatUserError(error);
expect(message).toContain('çewt');
});
});
});
+137
View File
@@ -0,0 +1,137 @@
/**
* Error Tracking Utility
* Centralized error logging and tracking infrastructure
*
* In production, this can be connected to:
* - Sentry (recommended)
* - LogRocket
* - Custom analytics endpoint
*/
export interface ErrorContext {
component?: string;
action?: string;
userId?: string;
extra?: Record<string, unknown>;
}
export interface TrackedError {
message: string;
stack?: string;
timestamp: number;
context?: ErrorContext;
fingerprint?: string;
}
// In-memory error buffer (last 50 errors for debugging)
const errorBuffer: TrackedError[] = [];
const MAX_BUFFER_SIZE = 50;
/**
* Generate a fingerprint for deduplication
*/
function generateFingerprint(error: Error, context?: ErrorContext): string {
const parts = [error.name, error.message, context?.component, context?.action].filter(Boolean);
return parts.join('::');
}
/**
* Track an error
*/
export function trackError(error: Error, context?: ErrorContext): void {
const trackedError: TrackedError = {
message: error.message,
stack: error.stack,
timestamp: Date.now(),
context,
fingerprint: generateFingerprint(error, context),
};
// Add to buffer (FIFO)
errorBuffer.push(trackedError);
if (errorBuffer.length > MAX_BUFFER_SIZE) {
errorBuffer.shift();
}
// Log in development
if (import.meta.env.DEV) {
console.error('[ErrorTracking]', {
error: error.message,
stack: error.stack,
context,
});
}
// TODO: In production, send to error tracking service
// sendToSentry(trackedError);
// sendToAnalytics(trackedError);
}
/**
* Track a warning (non-critical issue)
*/
export function trackWarning(message: string, context?: ErrorContext): void {
if (import.meta.env.DEV) {
console.warn('[Warning]', message, context);
}
// TODO: In production, send to analytics
}
/**
* Get recent errors (for debugging)
*/
export function getRecentErrors(): TrackedError[] {
return [...errorBuffer];
}
/**
* Clear error buffer
*/
export function clearErrorBuffer(): void {
errorBuffer.length = 0;
}
/**
* Create an error with context
*/
export function createError(message: string, context?: ErrorContext): Error {
const error = new Error(message);
trackError(error, context);
return error;
}
/**
* Safe error extraction from unknown catch value
*/
export function extractError(caught: unknown): Error {
if (caught instanceof Error) {
return caught;
}
if (typeof caught === 'string') {
return new Error(caught);
}
return new Error('An unknown error occurred');
}
/**
* Format error for user display
*/
export function formatUserError(error: Error): string {
// Map technical errors to user-friendly messages
const errorMap: Record<string, string> = {
'Network Error': 'Têkiliya înternetê tune ye. Ji kerema xwe têkiliya xwe kontrol bike.',
'Failed to fetch': 'Têkiliya înternetê tune ye. Ji kerema xwe têkiliya xwe kontrol bike.',
TIMEOUT: 'Operasyon zêde dirêj kişand. Ji kerema xwe dîsa biceribîne.',
'Wallet not found': 'Wallet nehate dîtin. Ji kerema xwe wallet çêke an jî restore bike.',
'Şîfre (password) çewt e': 'Şîfre (password) çewt e. Ji kerema xwe dîsa biceribîne.',
};
for (const [key, message] of Object.entries(errorMap)) {
if (error.message.includes(key)) {
return message;
}
}
return 'Tiştek çewt çêbû. Ji kerema xwe dîsa biceribîne.';
}
+158
View File
@@ -0,0 +1,158 @@
/**
* P2P Fiat Crypto Tests
* Tests for AES-256-GCM encryption of payment details
*/
import { describe, it, expect } from 'vitest';
import { encryptPaymentDetails, decryptPaymentDetails, verifyEncryption } from './p2p-fiat-crypto';
describe('P2P Payment Details Encryption', () => {
describe('encryptPaymentDetails', () => {
it('should encrypt payment details to base64 string', async () => {
const details = {
iban: 'TR000000000000000000000001',
account_holder: 'Test User',
};
const encrypted = await encryptPaymentDetails(details);
expect(encrypted).toBeTruthy();
expect(typeof encrypted).toBe('string');
// Should be base64 encoded
expect(() => atob(encrypted)).not.toThrow();
});
it('should not contain plaintext in encrypted output', async () => {
const details = {
iban: 'TR123456789012345678901234',
secret_code: 'SUPERSECRET',
};
const encrypted = await encryptPaymentDetails(details);
expect(encrypted).not.toContain('TR123456789012345678901234');
expect(encrypted).not.toContain('SUPERSECRET');
});
it('should produce different ciphertext for same input (random IV)', async () => {
const details = {
iban: 'TR000000000000000000000001',
};
const encrypted1 = await encryptPaymentDetails(details);
const encrypted2 = await encryptPaymentDetails(details);
expect(encrypted1).not.toBe(encrypted2);
});
it('should handle empty object', async () => {
const details = {};
const encrypted = await encryptPaymentDetails(details);
const decrypted = await decryptPaymentDetails(encrypted);
expect(decrypted).toEqual({});
});
it('should handle special characters', async () => {
const details = {
name: 'Hêlîn Qadîr',
bank: 'Türkiye İş Bankası',
note: 'سڵاو 👋',
};
const encrypted = await encryptPaymentDetails(details);
const decrypted = await decryptPaymentDetails(encrypted);
expect(decrypted).toEqual(details);
});
});
describe('decryptPaymentDetails', () => {
it('should decrypt to original payment details', async () => {
const original = {
iban: 'TR000000000000000000000001',
account_holder: 'Test User',
bank_name: 'Test Bank',
};
const encrypted = await encryptPaymentDetails(original);
const decrypted = await decryptPaymentDetails(encrypted);
expect(decrypted).toEqual(original);
});
it('should handle legacy base64 encoded data', async () => {
// Legacy format: just base64 encoded JSON (no encryption)
const legacy = { iban: 'TR123' };
const legacyEncoded = btoa(JSON.stringify(legacy));
const decrypted = await decryptPaymentDetails(legacyEncoded);
expect(decrypted).toEqual(legacy);
});
it('should return empty object for invalid data', async () => {
const invalid = 'not-valid-base64!!!';
const decrypted = await decryptPaymentDetails(invalid);
expect(decrypted).toEqual({});
});
});
describe('verifyEncryption', () => {
it('should return true when encryption is working', async () => {
const result = await verifyEncryption();
expect(result).toBe(true);
});
});
describe('Security Properties', () => {
it('should use random IV (first 12 bytes should differ)', async () => {
const details = { test: 'data' };
const encrypted1 = await encryptPaymentDetails(details);
const encrypted2 = await encryptPaymentDetails(details);
// Decode and compare first 12 bytes (IV)
const decoded1 = Uint8Array.from(atob(encrypted1), (c) => c.charCodeAt(0));
const decoded2 = Uint8Array.from(atob(encrypted2), (c) => c.charCodeAt(0));
const iv1 = decoded1.slice(0, 12);
const iv2 = decoded2.slice(0, 12);
// IVs should be different
expect(iv1.toString()).not.toBe(iv2.toString());
});
it('should produce authenticated ciphertext (GCM tag)', async () => {
const details = { test: 'data' };
const encrypted = await encryptPaymentDetails(details);
const decoded = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
// IV (12 bytes) + ciphertext + GCM tag (16 bytes)
// For small data, minimum should be at least 12 + 16 = 28 bytes
expect(decoded.length).toBeGreaterThan(28);
});
it('should fail decryption with tampered ciphertext', async () => {
const details = { sensitive: 'data' };
const encrypted = await encryptPaymentDetails(details);
// Tamper with the ciphertext (flip a bit in the middle)
const decoded = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
decoded[decoded.length - 10] ^= 0xff; // Flip bits
const tampered = btoa(String.fromCharCode(...decoded));
// Should fail to decrypt (GCM authentication)
const result = await decryptPaymentDetails(tampered);
// Will fall back to empty object or legacy decode failure
expect(result).toEqual({});
});
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* P2P Fiat Trading - Encryption Utilities
*
* AES-256-GCM encryption for payment details
* Extracted for testing purposes
*
* @module p2p-fiat-crypto
*/
const IV_LENGTH = 12; // 96 bits for GCM
/**
* Derive encryption key from a password/secret
*/
async function getEncryptionKey(): Promise<CryptoKey> {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode('p2p-payment-encryption-v1-pezkuwi'),
'PBKDF2',
false,
['deriveBits', 'deriveKey']
);
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: encoder.encode('pezkuwi-p2p-salt'),
iterations: 100000,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
/**
* Encrypt payment details using AES-256-GCM
* @param details Payment details object to encrypt
* @returns Base64-encoded encrypted string
*/
export async function encryptPaymentDetails(details: Record<string, string>): Promise<string> {
try {
const key = await getEncryptionKey();
const encoder = new TextEncoder();
const data = encoder.encode(JSON.stringify(details));
// Generate random IV
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
// Encrypt
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data);
// Combine IV + ciphertext and encode as base64
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
return btoa(String.fromCharCode(...combined));
} catch (error) {
console.error('Encryption failed:', error);
// Fallback to base64 for backwards compatibility (temporary)
return btoa(JSON.stringify(details));
}
}
/**
* Decrypt payment details using AES-256-GCM
* @param encrypted Base64-encoded encrypted string
* @returns Decrypted payment details object
*/
export async function decryptPaymentDetails(encrypted: string): Promise<Record<string, string>> {
try {
const key = await getEncryptionKey();
// Decode base64
const combined = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
// Extract IV and ciphertext
const iv = combined.slice(0, IV_LENGTH);
const ciphertext = combined.slice(IV_LENGTH);
// Decrypt
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
const decoder = new TextDecoder();
return JSON.parse(decoder.decode(decrypted));
} catch {
// Fallback: try to decode as plain base64 (for old data)
try {
return JSON.parse(atob(encrypted));
} catch {
return {};
}
}
}
/**
* Verify encryption is working correctly
* Used for health checks
*/
export async function verifyEncryption(): Promise<boolean> {
try {
const testData = { test: 'verification' };
const encrypted = await encryptPaymentDetails(testData);
const decrypted = await decryptPaymentDetails(encrypted);
return decrypted.test === 'verification';
} catch {
return false;
}
}
+825
View File
@@ -0,0 +1,825 @@
/**
* P2P Fiat Trading E2E Tests
*
* Tests various trading scenarios:
* 1. Happy path - Two honest users complete a 5 HEZ trade
* 2. Buyer scam - Buyer marks payment sent but didn't pay
* 3. Seller scam - Seller doesn't release even though payment arrived
* 4. Timeout scenarios
* 5. Trade cancellation
*
* @module p2p-fiat.e2e.test
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { createClient, SupabaseClient } from '@supabase/supabase-js';
// Test configuration - using real Supabase project
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const SUPABASE_URL = (import.meta as any).env?.VITE_SUPABASE_URL || '';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const SUPABASE_SERVICE_KEY = (import.meta as any).env?.SUPABASE_SERVICE_ROLE_KEY || '';
// Test wallet from CRITICAL_STATE.md
// Mnemonic: crucial surge north silly divert throw habit fury zebra fabric tank output
const TEST_WALLET_ADDRESS = '5DXv3Dc5xELckTgcYa2dm1TSZPgqDPxVDW3Cid4ALWpVjY3w';
// Test user IDs (Telegram style)
const TEST_USERS = {
ALICE: {
telegram_id: 111111111,
display_name: 'Alice Test',
telegram_username: 'alice_test',
},
BOB: {
telegram_id: 222222222,
display_name: 'Bob Test',
telegram_username: 'bob_test',
},
SCAMMER: {
telegram_id: 333333333,
display_name: 'Scammer Test',
telegram_username: 'scammer_test',
},
};
// Test amounts
const TRADE_AMOUNT_HEZ = 5;
const TRADE_AMOUNT_TRY = 100;
// Supabase client for tests (with service role for admin operations)
let supabase: SupabaseClient;
// Test user Supabase IDs (will be populated after user creation)
let aliceId: string;
let bobId: string;
let scammerId: string;
// Payment method for tests
let testPaymentMethodId: string;
/**
* Skip condition for E2E tests
* These tests require:
* 1. SUPABASE_SERVICE_ROLE_KEY environment variable
* 2. Network access to Supabase
*/
const shouldSkipE2E = !SUPABASE_SERVICE_KEY;
describe.skipIf(shouldSkipE2E)('P2P Fiat Trading E2E Tests', () => {
beforeAll(async () => {
// Initialize Supabase client with service role key
supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
// Create test users
await setupTestUsers();
// Get or create test payment method
await setupTestPaymentMethod();
// Seed initial balances for test users
await seedTestBalances();
});
afterAll(async () => {
// Cleanup test data
await cleanupTestData();
});
describe('Scenario 1: Happy Path - Two Honest Users', () => {
let offerId: string;
let tradeId: string;
it('should allow Alice to create a sell offer', async () => {
// Alice creates offer to sell 5 HEZ for 100 TRY
const { data: offer, error } = await supabase
.from('p2p_fiat_offers')
.insert({
seller_id: aliceId,
seller_wallet: TEST_WALLET_ADDRESS,
token: 'HEZ',
amount_crypto: TRADE_AMOUNT_HEZ,
fiat_currency: 'TRY',
fiat_amount: TRADE_AMOUNT_TRY,
price_per_unit: TRADE_AMOUNT_TRY / TRADE_AMOUNT_HEZ,
payment_method_id: testPaymentMethodId,
payment_details_encrypted: btoa(JSON.stringify({ iban: 'TR000000000000000000000001' })),
time_limit_minutes: 30,
status: 'open',
remaining_amount: TRADE_AMOUNT_HEZ,
})
.select()
.single();
expect(error).toBeNull();
expect(offer).toBeDefined();
expect(offer.status).toBe('open');
expect(offer.amount_crypto).toBe(TRADE_AMOUNT_HEZ);
offerId = offer.id;
// Lock Alice's balance
const { data: lockResult, error: lockError } = await supabase.rpc('lock_escrow_internal', {
p_user_id: aliceId,
p_token: 'HEZ',
p_amount: TRADE_AMOUNT_HEZ,
});
expect(lockError).toBeNull();
const lockResponse = typeof lockResult === 'string' ? JSON.parse(lockResult) : lockResult;
expect(lockResponse.success).toBe(true);
});
it('should allow Bob to accept the offer', async () => {
// Bob accepts Alice's offer
const { data: result, error } = await supabase.rpc('accept_p2p_offer', {
p_offer_id: offerId,
p_buyer_id: bobId,
p_buyer_wallet: TEST_WALLET_ADDRESS,
p_amount: TRADE_AMOUNT_HEZ,
});
expect(error).toBeNull();
const response = typeof result === 'string' ? JSON.parse(result) : result;
expect(response.success).toBe(true);
expect(response.trade_id).toBeDefined();
tradeId = response.trade_id;
// Verify trade was created
const { data: trade } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
expect(trade).toBeDefined();
expect(trade.status).toBe('pending');
expect(trade.buyer_id).toBe(bobId);
expect(trade.seller_id).toBe(aliceId);
});
it('should allow Bob to mark payment as sent', async () => {
// Bob marks payment as sent
const { error } = await supabase
.from('p2p_fiat_trades')
.update({
buyer_marked_paid_at: new Date().toISOString(),
status: 'payment_sent',
confirmation_deadline: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
})
.eq('id', tradeId);
expect(error).toBeNull();
// Verify trade status
const { data: trade } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
expect(trade.status).toBe('payment_sent');
expect(trade.buyer_marked_paid_at).toBeDefined();
});
it('should allow Alice to confirm and release crypto', async () => {
// Release escrow to Bob
const { data: releaseData, error: releaseError } = await supabase.rpc(
'release_escrow_internal',
{
p_from_user_id: aliceId,
p_to_user_id: bobId,
p_token: 'HEZ',
p_amount: TRADE_AMOUNT_HEZ,
p_reference_type: 'trade',
p_reference_id: tradeId,
}
);
expect(releaseError).toBeNull();
const releaseResponse =
typeof releaseData === 'string' ? JSON.parse(releaseData) : releaseData;
expect(releaseResponse.success).toBe(true);
// Update trade status
const { error: updateError } = await supabase
.from('p2p_fiat_trades')
.update({
seller_confirmed_at: new Date().toISOString(),
escrow_released_at: new Date().toISOString(),
status: 'completed',
completed_at: new Date().toISOString(),
})
.eq('id', tradeId);
expect(updateError).toBeNull();
// Verify Bob received the crypto
const { data: bobBalance } = await supabase.rpc('get_user_internal_balance', {
p_user_id: bobId,
});
const balances = typeof bobBalance === 'string' ? JSON.parse(bobBalance) : bobBalance;
const hezBalance = balances?.find((b: { token: string }) => b.token === 'HEZ');
expect(hezBalance).toBeDefined();
expect(hezBalance.available_balance).toBeGreaterThanOrEqual(TRADE_AMOUNT_HEZ);
});
});
describe('Scenario 2: Buyer Scam - Marks Paid But Didnt Pay', () => {
let offerId: string;
let tradeId: string;
beforeEach(async () => {
// Reset balances for this scenario
await resetUserBalance(aliceId, 'HEZ', 10);
});
it('should create a trade and buyer marks payment sent', async () => {
// Alice creates offer
const { data: offer, error } = await supabase
.from('p2p_fiat_offers')
.insert({
seller_id: aliceId,
seller_wallet: TEST_WALLET_ADDRESS,
token: 'HEZ',
amount_crypto: TRADE_AMOUNT_HEZ,
fiat_currency: 'TRY',
fiat_amount: TRADE_AMOUNT_TRY,
price_per_unit: TRADE_AMOUNT_TRY / TRADE_AMOUNT_HEZ,
payment_method_id: testPaymentMethodId,
payment_details_encrypted: btoa(JSON.stringify({ iban: 'TR000000000000000000000001' })),
time_limit_minutes: 30,
status: 'open',
remaining_amount: TRADE_AMOUNT_HEZ,
})
.select()
.single();
expect(error).toBeNull();
offerId = offer.id;
// Lock escrow
await supabase.rpc('lock_escrow_internal', {
p_user_id: aliceId,
p_token: 'HEZ',
p_amount: TRADE_AMOUNT_HEZ,
});
// Scammer accepts offer
const { data: result } = await supabase.rpc('accept_p2p_offer', {
p_offer_id: offerId,
p_buyer_id: scammerId,
p_buyer_wallet: TEST_WALLET_ADDRESS,
p_amount: TRADE_AMOUNT_HEZ,
});
const response = typeof result === 'string' ? JSON.parse(result) : result;
tradeId = response.trade_id;
// Scammer marks payment as sent (but didn't actually pay)
await supabase
.from('p2p_fiat_trades')
.update({
buyer_marked_paid_at: new Date().toISOString(),
status: 'payment_sent',
confirmation_deadline: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
})
.eq('id', tradeId);
});
it('should allow Alice to NOT release if payment not received', async () => {
// Alice checks her bank account and sees NO payment
// She should NOT confirm payment received
// Get trade status - should still be payment_sent
const { data: trade } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
expect(trade.status).toBe('payment_sent');
expect(trade.seller_confirmed_at).toBeNull();
});
it('should allow Alice to open a dispute', async () => {
// Alice opens dispute because payment not received
const { error } = await supabase
.from('p2p_fiat_trades')
.update({
status: 'disputed',
disputed_at: new Date().toISOString(),
disputed_by: aliceId,
dispute_reason: 'Payment not received in bank account',
})
.eq('id', tradeId);
expect(error).toBeNull();
// Verify trade is disputed
const { data: trade } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
expect(trade.status).toBe('disputed');
expect(trade.disputed_by).toBe(aliceId);
});
it('should allow admin to refund escrow to Alice', async () => {
// Admin reviews dispute and sees no payment proof
// Refund escrow to Alice
const { error: refundError } = await supabase.rpc('refund_escrow', {
p_from_user_id: aliceId,
p_token: 'HEZ',
p_amount: TRADE_AMOUNT_HEZ,
p_reference_type: 'dispute_refund',
p_reference_id: tradeId,
});
// Note: This RPC might not exist yet - thats OK, we're testing the flow
if (refundError?.code === '42883') {
// Function doesn't exist - skip
console.warn('refund_escrow function not implemented yet');
return;
}
expect(refundError).toBeNull();
// Update trade status
await supabase
.from('p2p_fiat_trades')
.update({
status: 'refunded',
dispute_resolved_at: new Date().toISOString(),
dispute_resolution: 'Refunded to seller - no payment proof provided',
})
.eq('id', tradeId);
});
});
describe('Scenario 3: Seller Scam - Doesnt Release Despite Payment', () => {
let offerId: string;
let tradeId: string;
beforeEach(async () => {
// Reset balances
await resetUserBalance(scammerId, 'HEZ', 10);
});
it('should create trade where scammer is seller', async () => {
// Scammer creates offer
const { data: offer, error } = await supabase
.from('p2p_fiat_offers')
.insert({
seller_id: scammerId,
seller_wallet: TEST_WALLET_ADDRESS,
token: 'HEZ',
amount_crypto: TRADE_AMOUNT_HEZ,
fiat_currency: 'TRY',
fiat_amount: TRADE_AMOUNT_TRY,
price_per_unit: TRADE_AMOUNT_TRY / TRADE_AMOUNT_HEZ,
payment_method_id: testPaymentMethodId,
payment_details_encrypted: btoa(JSON.stringify({ iban: 'TR000000000000000000000002' })),
time_limit_minutes: 30,
status: 'open',
remaining_amount: TRADE_AMOUNT_HEZ,
})
.select()
.single();
expect(error).toBeNull();
offerId = offer.id;
// Lock scammer's escrow
await supabase.rpc('lock_escrow_internal', {
p_user_id: scammerId,
p_token: 'HEZ',
p_amount: TRADE_AMOUNT_HEZ,
});
// Bob accepts offer (honest buyer)
const { data: result } = await supabase.rpc('accept_p2p_offer', {
p_offer_id: offerId,
p_buyer_id: bobId,
p_buyer_wallet: TEST_WALLET_ADDRESS,
p_amount: TRADE_AMOUNT_HEZ,
});
const response = typeof result === 'string' ? JSON.parse(result) : result;
tradeId = response.trade_id;
});
it('should allow Bob to mark payment as sent with proof', async () => {
// Bob sends real payment and marks as sent with proof
await supabase
.from('p2p_fiat_trades')
.update({
buyer_marked_paid_at: new Date().toISOString(),
buyer_payment_proof_url: 'https://example.com/bank-receipt-12345.jpg',
status: 'payment_sent',
confirmation_deadline: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
})
.eq('id', tradeId);
const { data: trade } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
expect(trade.status).toBe('payment_sent');
expect(trade.buyer_payment_proof_url).toBeDefined();
});
it('should allow Bob to open dispute after confirmation deadline', async () => {
// Simulate time passing - scammer doesn't confirm
// In real scenario, confirmation_deadline would have passed
// Bob opens dispute
const { error } = await supabase
.from('p2p_fiat_trades')
.update({
status: 'disputed',
disputed_at: new Date().toISOString(),
disputed_by: bobId,
dispute_reason: 'Seller not releasing crypto despite payment proof',
})
.eq('id', tradeId);
expect(error).toBeNull();
});
it('should allow admin to force release to Bob', async () => {
// Admin reviews dispute:
// - Sees Bob's payment proof (bank receipt)
// - Verifies payment in scammer's bank account
// - Forces release to Bob
// Force release escrow from scammer to Bob
const { error: releaseError } = await supabase.rpc('release_escrow_internal', {
p_from_user_id: scammerId,
p_to_user_id: bobId,
p_token: 'HEZ',
p_amount: TRADE_AMOUNT_HEZ,
p_reference_type: 'admin_forced_release',
p_reference_id: tradeId,
});
expect(releaseError).toBeNull();
// Update trade status
await supabase
.from('p2p_fiat_trades')
.update({
status: 'completed',
completed_at: new Date().toISOString(),
dispute_resolved_at: new Date().toISOString(),
dispute_resolution: 'Admin forced release - valid payment proof verified',
})
.eq('id', tradeId);
// Penalize scammer reputation
await supabase
.from('p2p_reputation')
.update({
disputed_trades: 1,
reputation_score: 0,
trust_level: 'new',
})
.eq('user_id', scammerId);
});
});
describe('Scenario 4: Trade Cancellation', () => {
let offerId: string;
let tradeId: string;
beforeEach(async () => {
await resetUserBalance(aliceId, 'HEZ', 10);
});
it('should allow buyer to cancel before marking payment', async () => {
// Alice creates offer
const { data: offer } = await supabase
.from('p2p_fiat_offers')
.insert({
seller_id: aliceId,
seller_wallet: TEST_WALLET_ADDRESS,
token: 'HEZ',
amount_crypto: TRADE_AMOUNT_HEZ,
fiat_currency: 'TRY',
fiat_amount: TRADE_AMOUNT_TRY,
price_per_unit: TRADE_AMOUNT_TRY / TRADE_AMOUNT_HEZ,
payment_method_id: testPaymentMethodId,
payment_details_encrypted: btoa(JSON.stringify({ iban: 'TR000000000000000000000001' })),
time_limit_minutes: 30,
status: 'open',
remaining_amount: TRADE_AMOUNT_HEZ,
})
.select()
.single();
expect(offer).toBeDefined();
offerId = offer?.id ?? '';
// Lock escrow
await supabase.rpc('lock_escrow_internal', {
p_user_id: aliceId,
p_token: 'HEZ',
p_amount: TRADE_AMOUNT_HEZ,
});
// Bob accepts
const { data: result } = await supabase.rpc('accept_p2p_offer', {
p_offer_id: offerId,
p_buyer_id: bobId,
p_buyer_wallet: TEST_WALLET_ADDRESS,
p_amount: TRADE_AMOUNT_HEZ,
});
const response = typeof result === 'string' ? JSON.parse(result) : result;
tradeId = response.trade_id;
// Verify trade is pending
const { data: trade } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
expect(trade.status).toBe('pending');
});
it('should cancel trade and restore offer availability', async () => {
// Bob cancels trade
const { error } = await supabase
.from('p2p_fiat_trades')
.update({
status: 'cancelled',
cancelled_by: bobId,
cancel_reason: 'Changed my mind',
})
.eq('id', tradeId);
expect(error).toBeNull();
// Restore offer remaining amount
const { data: offer } = await supabase
.from('p2p_fiat_offers')
.select('remaining_amount')
.eq('id', offerId)
.single();
await supabase
.from('p2p_fiat_offers')
.update({
remaining_amount: (offer?.remaining_amount || 0) + TRADE_AMOUNT_HEZ,
status: 'open',
})
.eq('id', offerId);
// Verify offer is open again
const { data: updatedOffer } = await supabase
.from('p2p_fiat_offers')
.select('*')
.eq('id', offerId)
.single();
expect(updatedOffer.status).toBe('open');
});
});
describe('Scenario 5: Payment Timeout', () => {
let offerId: string;
let tradeId: string;
it('should handle payment deadline timeout', async () => {
await resetUserBalance(aliceId, 'HEZ', 10);
// Alice creates offer
const { data: offer } = await supabase
.from('p2p_fiat_offers')
.insert({
seller_id: aliceId,
seller_wallet: TEST_WALLET_ADDRESS,
token: 'HEZ',
amount_crypto: TRADE_AMOUNT_HEZ,
fiat_currency: 'TRY',
fiat_amount: TRADE_AMOUNT_TRY,
price_per_unit: TRADE_AMOUNT_TRY / TRADE_AMOUNT_HEZ,
payment_method_id: testPaymentMethodId,
payment_details_encrypted: btoa(JSON.stringify({ iban: 'TR000000000000000000000001' })),
time_limit_minutes: 1, // 1 minute for testing
status: 'open',
remaining_amount: TRADE_AMOUNT_HEZ,
})
.select()
.single();
expect(offer).toBeDefined();
offerId = offer?.id ?? '';
// Lock escrow
await supabase.rpc('lock_escrow_internal', {
p_user_id: aliceId,
p_token: 'HEZ',
p_amount: TRADE_AMOUNT_HEZ,
});
// Bob accepts
const { data: result } = await supabase.rpc('accept_p2p_offer', {
p_offer_id: offerId,
p_buyer_id: bobId,
p_buyer_wallet: TEST_WALLET_ADDRESS,
p_amount: TRADE_AMOUNT_HEZ,
});
const response = typeof result === 'string' ? JSON.parse(result) : result;
tradeId = response.trade_id;
// Simulate deadline passed - set deadline to past
await supabase
.from('p2p_fiat_trades')
.update({
payment_deadline: new Date(Date.now() - 1000).toISOString(),
})
.eq('id', tradeId);
// Check trade - in production, a cron job would auto-cancel this
const { data: trade } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
// Verify deadline has passed
expect(new Date(trade.payment_deadline).getTime()).toBeLessThan(Date.now());
});
});
});
// =====================================================
// HELPER FUNCTIONS
// =====================================================
async function setupTestUsers(): Promise<void> {
// Create or get Alice
const { data: alice } = await supabase
.from('p2p_users')
.upsert(
{
telegram_id: TEST_USERS.ALICE.telegram_id,
display_name: TEST_USERS.ALICE.display_name,
telegram_username: TEST_USERS.ALICE.telegram_username,
},
{ onConflict: 'telegram_id' }
)
.select()
.single();
aliceId = alice?.id;
// Create or get Bob
const { data: bob } = await supabase
.from('p2p_users')
.upsert(
{
telegram_id: TEST_USERS.BOB.telegram_id,
display_name: TEST_USERS.BOB.display_name,
telegram_username: TEST_USERS.BOB.telegram_username,
},
{ onConflict: 'telegram_id' }
)
.select()
.single();
bobId = bob?.id;
// Create or get Scammer
const { data: scammer } = await supabase
.from('p2p_users')
.upsert(
{
telegram_id: TEST_USERS.SCAMMER.telegram_id,
display_name: TEST_USERS.SCAMMER.display_name,
telegram_username: TEST_USERS.SCAMMER.telegram_username,
},
{ onConflict: 'telegram_id' }
)
.select()
.single();
scammerId = scammer?.id;
}
async function setupTestPaymentMethod(): Promise<void> {
// Check if test payment method exists
const { data: existing } = await supabase
.from('payment_methods')
.select('id')
.eq('method_name', 'Test Bank Transfer')
.single();
if (existing) {
testPaymentMethodId = existing.id;
return;
}
// Create test payment method
const { data: method, error } = await supabase
.from('payment_methods')
.insert({
currency: 'TRY',
country: 'TR',
method_name: 'Test Bank Transfer',
method_type: 'bank',
fields: { iban: 'IBAN Number', account_holder: 'Account Holder Name' },
validation_rules: {
iban: { required: true, pattern: '^TR[0-9]{24}$' },
account_holder: { required: true, minLength: 3 },
},
min_trade_amount: 10,
max_trade_amount: 100000,
processing_time_minutes: 30,
display_order: 1,
is_active: true,
})
.select()
.single();
if (error) {
console.error('Failed to create payment method:', error);
throw error;
}
testPaymentMethodId = method.id;
}
async function seedTestBalances(): Promise<void> {
// Seed balances for test users
const users = [
{ id: aliceId, token: 'HEZ', amount: 100 },
{ id: bobId, token: 'HEZ', amount: 50 },
{ id: scammerId, token: 'HEZ', amount: 100 },
];
for (const user of users) {
if (!user.id) continue;
await supabase.from('p2p_internal_balances').upsert(
{
user_id: user.id,
token: user.token,
available_balance: user.amount,
locked_balance: 0,
total_deposited: user.amount,
total_withdrawn: 0,
},
{ onConflict: 'user_id,token' }
);
}
}
async function resetUserBalance(userId: string, token: string, amount: number): Promise<void> {
await supabase
.from('p2p_internal_balances')
.upsert(
{
user_id: userId,
token,
available_balance: amount,
locked_balance: 0,
total_deposited: amount,
total_withdrawn: 0,
},
{ onConflict: 'user_id,token' }
)
.eq('user_id', userId)
.eq('token', token);
}
async function cleanupTestData(): Promise<void> {
// Delete test trades
await supabase
.from('p2p_fiat_trades')
.delete()
.or(`buyer_id.eq.${aliceId},buyer_id.eq.${bobId},buyer_id.eq.${scammerId}`);
// Delete test offers
await supabase
.from('p2p_fiat_offers')
.delete()
.or(`seller_id.eq.${aliceId},seller_id.eq.${bobId},seller_id.eq.${scammerId}`);
// Note: We keep user records and balances for future tests
}
+367
View File
@@ -0,0 +1,367 @@
/**
* P2P Fiat Trading Integration Tests
*
* These tests verify the business logic without requiring full E2E setup.
* Mock Supabase responses to test various scenarios.
*
* @module p2p-fiat.integration.test
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock Supabase
const mockSupabase = {
from: vi.fn(),
rpc: vi.fn(),
functions: {
invoke: vi.fn(),
},
auth: {
getUser: vi.fn(),
},
};
// Mock the supabase module
vi.mock('@/lib/supabase', () => ({
supabase: mockSupabase,
}));
// Mock toast
vi.mock('sonner', () => ({
toast: {
info: vi.fn(),
success: vi.fn(),
error: vi.fn(),
},
}));
describe('P2P Fiat Trading Integration Tests', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Payment Details Encryption', () => {
it('should encrypt payment details with AES-256-GCM', async () => {
const { encryptPaymentDetails, decryptPaymentDetails } = await import('./p2p-fiat-crypto');
const details = {
iban: 'TR000000000000000000000001',
account_holder: 'Test User',
};
const encrypted = await encryptPaymentDetails(details);
// Encrypted should be base64 encoded
expect(encrypted).toBeTruthy();
expect(encrypted).not.toContain('TR000000');
// Should be decryptable
const decrypted = await decryptPaymentDetails(encrypted);
expect(decrypted).toEqual(details);
});
it('should produce different ciphertext for same input', async () => {
const { encryptPaymentDetails } = await import('./p2p-fiat-crypto');
const details = { iban: 'TR000000000000000000000001' };
const encrypted1 = await encryptPaymentDetails(details);
const encrypted2 = await encryptPaymentDetails(details);
// Due to random IV, outputs should differ
expect(encrypted1).not.toBe(encrypted2);
});
});
describe('Trade Status Transitions', () => {
it('should only allow valid status transitions', () => {
const validTransitions: Record<string, string[]> = {
pending: ['payment_sent', 'cancelled'],
payment_sent: ['completed', 'disputed', 'cancelled'],
disputed: ['completed', 'refunded'],
completed: [],
cancelled: [],
refunded: [],
};
// Verify transition rules
expect(validTransitions['pending']).toContain('payment_sent');
expect(validTransitions['pending']).toContain('cancelled');
expect(validTransitions['pending']).not.toContain('completed');
expect(validTransitions['payment_sent']).toContain('completed');
expect(validTransitions['payment_sent']).toContain('disputed');
expect(validTransitions['disputed']).toContain('refunded');
expect(validTransitions['disputed']).toContain('completed');
// Terminal states have no transitions
expect(validTransitions['completed']).toHaveLength(0);
expect(validTransitions['cancelled']).toHaveLength(0);
expect(validTransitions['refunded']).toHaveLength(0);
});
});
describe('Escrow Logic', () => {
it('should lock balance before creating offer', async () => {
// Simulate successful lock
mockSupabase.rpc.mockResolvedValueOnce({
data: JSON.stringify({ success: true, new_available_balance: 95, new_locked_balance: 5 }),
error: null,
});
const result = await mockSupabase.rpc('lock_escrow_internal', {
p_user_id: 'test-user-id',
p_token: 'HEZ',
p_amount: 5,
});
expect(result.error).toBeNull();
const response = JSON.parse(result.data);
expect(response.success).toBe(true);
expect(response.new_locked_balance).toBe(5);
});
it('should fail lock if insufficient balance', async () => {
mockSupabase.rpc.mockResolvedValueOnce({
data: JSON.stringify({ success: false, error: 'Insufficient balance' }),
error: null,
});
const result = await mockSupabase.rpc('lock_escrow_internal', {
p_user_id: 'test-user-id',
p_token: 'HEZ',
p_amount: 1000,
});
const response = JSON.parse(result.data);
expect(response.success).toBe(false);
expect(response.error).toBe('Insufficient balance');
});
it('should release escrow to buyer on confirmation', async () => {
mockSupabase.rpc.mockResolvedValueOnce({
data: JSON.stringify({
success: true,
seller_new_locked: 0,
buyer_new_available: 5,
}),
error: null,
});
const result = await mockSupabase.rpc('release_escrow_internal', {
p_from_user_id: 'seller-id',
p_to_user_id: 'buyer-id',
p_token: 'HEZ',
p_amount: 5,
p_reference_type: 'trade',
p_reference_id: 'trade-123',
});
const response = JSON.parse(result.data);
expect(response.success).toBe(true);
});
});
describe('Offer Acceptance Race Condition Prevention', () => {
it('should use atomic RPC to prevent double-spending', async () => {
// First acceptance succeeds
mockSupabase.rpc.mockResolvedValueOnce({
data: JSON.stringify({
success: true,
trade_id: 'trade-1',
crypto_amount: 5,
fiat_amount: 100,
}),
error: null,
});
const result1 = await mockSupabase.rpc('accept_p2p_offer', {
p_offer_id: 'offer-123',
p_buyer_id: 'buyer-1',
p_buyer_wallet: 'wallet-1',
p_amount: 5,
});
expect(JSON.parse(result1.data).success).toBe(true);
// Second acceptance should fail (offer already taken)
mockSupabase.rpc.mockResolvedValueOnce({
data: JSON.stringify({
success: false,
error: 'Insufficient remaining amount',
}),
error: null,
});
const result2 = await mockSupabase.rpc('accept_p2p_offer', {
p_offer_id: 'offer-123',
p_buyer_id: 'buyer-2',
p_buyer_wallet: 'wallet-2',
p_amount: 5,
});
expect(JSON.parse(result2.data).success).toBe(false);
});
});
describe('Reputation System', () => {
it('should increase reputation after successful trade', async () => {
const currentRep = {
user_id: 'user-1',
total_trades: 10,
completed_trades: 9,
cancelled_trades: 1,
disputed_trades: 0,
reputation_score: 80,
trust_level: 'intermediate',
};
// After successful trade
const newRep = {
...currentRep,
total_trades: 11,
completed_trades: 10,
reputation_score: Math.min(100, currentRep.reputation_score + 1),
};
expect(newRep.completed_trades).toBe(10);
expect(newRep.reputation_score).toBe(81);
});
it('should decrease reputation after cancelled trade', async () => {
const currentRep = {
user_id: 'user-1',
reputation_score: 50,
cancelled_trades: 0,
};
// After cancellation
const newRep = {
...currentRep,
cancelled_trades: 1,
reputation_score: Math.max(0, currentRep.reputation_score - 2),
};
expect(newRep.cancelled_trades).toBe(1);
expect(newRep.reputation_score).toBe(48);
});
it('should severely penalize disputed trades', async () => {
const currentRep = {
user_id: 'scammer-1',
reputation_score: 70,
disputed_trades: 0,
};
// After losing dispute (scammer)
const newRep = {
...currentRep,
disputed_trades: 1,
reputation_score: Math.max(0, currentRep.reputation_score - 20),
trust_level: 'new', // Demoted
};
expect(newRep.disputed_trades).toBe(1);
expect(newRep.reputation_score).toBe(50);
expect(newRep.trust_level).toBe('new');
});
});
describe('Trade Deadlines', () => {
it('should calculate payment deadline correctly', () => {
const timeLimitMinutes = 30;
const tradeCreatedAt = new Date();
const paymentDeadline = new Date(tradeCreatedAt.getTime() + timeLimitMinutes * 60 * 1000);
expect(paymentDeadline.getTime() - tradeCreatedAt.getTime()).toBe(30 * 60 * 1000);
});
it('should detect expired trades', () => {
const paymentDeadline = new Date(Date.now() - 1000); // 1 second ago
const isExpired = new Date(paymentDeadline).getTime() < Date.now();
expect(isExpired).toBe(true);
});
it('should allow action within deadline', () => {
const paymentDeadline = new Date(Date.now() + 60000); // 1 minute from now
const isExpired = new Date(paymentDeadline).getTime() < Date.now();
expect(isExpired).toBe(false);
});
});
describe('Price Calculation', () => {
it('should calculate price per unit correctly', () => {
const fiatAmount = 100;
const cryptoAmount = 5;
const pricePerUnit = fiatAmount / cryptoAmount;
expect(pricePerUnit).toBe(20);
});
it('should handle partial fills', () => {
const offerCryptoAmount = 10;
const buyAmount = 3;
const pricePerUnit = 20;
const expectedFiat = buyAmount * pricePerUnit;
const remainingCrypto = offerCryptoAmount - buyAmount;
expect(expectedFiat).toBe(60);
expect(remainingCrypto).toBe(7);
});
});
});
/**
* Test helper to simulate different dispute outcomes
*/
describe('Dispute Resolution Scenarios', () => {
it('Scenario: No payment proof, no bank verification - Refund to seller', () => {
const dispute = {
trade_id: 'trade-1',
buyer_payment_proof_url: null,
bank_verified: false,
};
// Decision: Refund to seller
const resolution =
!dispute.buyer_payment_proof_url && !dispute.bank_verified ? 'refund_to_seller' : 'unknown';
expect(resolution).toBe('refund_to_seller');
});
it('Scenario: Valid payment proof, bank verified - Release to buyer', () => {
const dispute = {
trade_id: 'trade-2',
buyer_payment_proof_url: 'https://example.com/receipt.jpg',
bank_verified: true,
};
// Decision: Release to buyer
const resolution =
dispute.buyer_payment_proof_url && dispute.bank_verified ? 'release_to_buyer' : 'unknown';
expect(resolution).toBe('release_to_buyer');
});
it('Scenario: Payment proof exists but bank not verified - Manual review', () => {
const dispute = {
trade_id: 'trade-3',
buyer_payment_proof_url: 'https://example.com/receipt.jpg',
bank_verified: false,
};
// Decision: Needs manual admin review
const resolution =
dispute.buyer_payment_proof_url && !dispute.bank_verified ? 'manual_review' : 'unknown';
expect(resolution).toBe('manual_review');
});
});
+284
View File
@@ -0,0 +1,284 @@
/**
* Referral System Integration with pallet_referral
* Based on pwap/shared/lib/referral.ts
*
* NOTE: pallet_referral is on People Chain (connected to KYC via OnKycApproved hook)
* Use peopleApi when calling these functions!
*
* Workflow:
* 1. User A calls initiateReferral(userB_address) -> creates pending referral
* 2. User B completes KYC and gets approved
* 3. Pallet automatically confirms referral via OnKycApproved hook
* 4. User A's referral count increases
*/
import type { ApiPromise } from '@pezkuwi/api';
import type { KeyringPair } from '@pezkuwi/keyring/types';
export interface ReferralInfo {
referrer: string;
createdAt: number;
}
export interface ReferralStats {
referralCount: number;
referralScore: number;
whoInvitedMe: string | null;
pendingReferral: string | null;
}
/**
* Check if the referral pallet is available on the chain
*/
function isReferralPalletAvailable(api: ApiPromise): boolean {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return !!((api.query as any).referral && (api.query as any).referral.pendingReferrals);
}
/**
* Initiate a referral for a new user
*/
export function initiateReferral(
api: ApiPromise,
keypair: KeyringPair,
referredAddress: string
): Promise<string> {
return new Promise((resolve, reject) => {
const tx = api.tx.referral.initiateReferral(referredAddress);
tx.signAndSend(keypair, ({ status, dispatchError }) => {
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
const error = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
reject(new Error(error));
} else {
reject(new Error(dispatchError.toString()));
}
return;
}
if (status.isInBlock || status.isFinalized) {
const hash = status.asInBlock?.toString() || status.asFinalized?.toString() || '';
resolve(hash);
}
}).catch(reject);
});
}
/**
* Get the pending referral for a user
*/
export async function getPendingReferral(api: ApiPromise, address: string): Promise<string | null> {
try {
// Check if referral pallet exists
if (!isReferralPalletAvailable(api)) {
console.log('Referral pallet not available on this chain');
return null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await (api.query.referral as any).pendingReferrals(address);
if (result.isEmpty) {
return null;
}
return result.toString();
} catch (error) {
console.error('Error fetching pending referral:', error);
return null;
}
}
/**
* Get the number of successful referrals for a user
*/
export async function getReferralCount(api: ApiPromise, address: string): Promise<number> {
try {
// Check if referral pallet exists
if (!isReferralPalletAvailable(api)) {
return 0;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const count = await (api.query.referral as any).referralCount(address);
return count.toNumber();
} catch (error) {
console.error('Error fetching referral count:', error);
return 0;
}
}
/**
* Get referral info for a user (who referred them)
*/
export async function getReferralInfo(
api: ApiPromise,
address: string
): Promise<ReferralInfo | null> {
try {
// Check if referral pallet exists
if (!isReferralPalletAvailable(api)) {
return null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await (api.query.referral as any).referrals(address);
if (result.isEmpty) {
return null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = result.toJSON() as any;
return {
referrer: data.referrer,
createdAt: parseInt(data.createdAt),
};
} catch (error) {
console.error('Error fetching referral info:', error);
return null;
}
}
/**
* Calculate referral score based on referral count
*
* Score calculation:
* - 0 referrals = 0 points
* - 1-10 referrals = count * 10 points (10, 20, 30, ..., 100)
* - 11-50 referrals = 100 + (count - 10) * 5 points
* - 51-100 referrals = 300 + (count - 50) * 4 points
* - 101+ referrals = 500 points (maximum)
*/
export function calculateReferralScore(referralCount: number): number {
if (referralCount === 0) return 0;
if (referralCount <= 10) return referralCount * 10;
if (referralCount <= 50) return 100 + (referralCount - 10) * 5;
if (referralCount <= 100) return 300 + (referralCount - 50) * 4;
return 500; // Max score
}
/**
* Get comprehensive referral statistics for a user
*/
export async function getReferralStats(api: ApiPromise, address: string): Promise<ReferralStats> {
// Check if referral pallet exists first
if (!isReferralPalletAvailable(api)) {
return {
referralCount: 0,
referralScore: 0,
whoInvitedMe: null,
pendingReferral: null,
};
}
try {
const [referralCount, referralInfo, pendingReferral] = await Promise.all([
getReferralCount(api, address),
getReferralInfo(api, address),
getPendingReferral(api, address),
]);
const referralScore = calculateReferralScore(referralCount);
return {
referralCount,
referralScore,
whoInvitedMe: referralInfo?.referrer || null,
pendingReferral,
};
} catch (error) {
console.error('Error fetching referral stats:', error);
return {
referralCount: 0,
referralScore: 0,
whoInvitedMe: null,
pendingReferral: null,
};
}
}
/**
* Get list of all users who were referred by this user
*/
export async function getMyReferrals(api: ApiPromise, referrerAddress: string): Promise<string[]> {
try {
// Check if referral pallet exists
if (!isReferralPalletAvailable(api)) {
return [];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const entries = await (api.query.referral as any).referrals.entries();
const myReferrals = entries
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.filter(([_key, value]: [any, any]) => {
if (value.isEmpty) return false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = value.toJSON() as any;
return data.referrer === referrerAddress;
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map(([key]: [any, any]) => {
const addressHex = key.args[0].toString();
return addressHex;
});
return myReferrals;
} catch (error) {
console.error('Error fetching my referrals:', error);
return [];
}
}
/**
* Subscribe to referral events for real-time updates
*/
export async function subscribeToReferralEvents(
api: ApiPromise,
callback: (event: {
type: 'initiated' | 'confirmed';
referrer: string;
referred: string;
count?: number;
}) => void
): Promise<() => void> {
// Check if referral pallet exists - if not, return no-op unsubscribe
if (!isReferralPalletAvailable(api)) {
return () => {};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const unsub = await api.query.system.events((events: any[]) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
events.forEach((record: any) => {
const { event } = record;
if (event.section === 'referral') {
if (event.method === 'ReferralInitiated') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [referrer, referred] = event.data as any;
callback({
type: 'initiated',
referrer: referrer.toString(),
referred: referred.toString(),
});
} else if (event.method === 'ReferralConfirmed') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [referrer, referred, newCount] = event.data as any;
callback({
type: 'confirmed',
referrer: referrer.toString(),
referred: referred.toString(),
count: newCount.toNumber(),
});
}
}
});
});
return unsub as unknown as () => void;
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Retry Utility Tests
*/
import { describe, it, expect, vi } from 'vitest';
import { withRetry, withTimeout, createRetryWrapper } from './retry';
describe('retry utilities', () => {
describe('withRetry', () => {
it('should succeed on first try', async () => {
const fn = vi.fn().mockResolvedValue('success');
const result = await withRetry(fn);
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(1);
});
it('should retry on failure and eventually succeed', async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error('Network Error'))
.mockRejectedValueOnce(new Error('Network Error'))
.mockResolvedValue('success');
const result = await withRetry(fn, { maxAttempts: 3, initialDelayMs: 10 });
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(3);
});
it('should throw after max attempts', async () => {
const fn = vi.fn().mockRejectedValue(new Error('Network Error'));
await expect(withRetry(fn, { maxAttempts: 3, initialDelayMs: 10 })).rejects.toThrow(
'Network Error'
);
expect(fn).toHaveBeenCalledTimes(3);
});
it('should not retry on non-retryable errors', async () => {
const fn = vi.fn().mockRejectedValue(new Error('Validation failed'));
await expect(withRetry(fn, { maxAttempts: 3, initialDelayMs: 10 })).rejects.toThrow(
'Validation failed'
);
expect(fn).toHaveBeenCalledTimes(1);
});
it('should call onRetry callback', async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error('Network Error'))
.mockResolvedValue('success');
const onRetry = vi.fn();
await withRetry(fn, { maxAttempts: 3, initialDelayMs: 10, onRetry });
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry).toHaveBeenCalledWith(1, expect.any(Error));
});
it('should use custom retry condition', async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error('Custom retry error'))
.mockResolvedValue('success');
const result = await withRetry(fn, {
maxAttempts: 3,
initialDelayMs: 10,
retryCondition: (err) => err.message.includes('Custom'),
});
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(2);
});
});
describe('withTimeout', () => {
it('should resolve if function completes before timeout', async () => {
const fn = vi.fn().mockResolvedValue('success');
const result = await withTimeout(fn, 1000);
expect(result).toBe('success');
});
it('should reject if timeout is exceeded', async () => {
const fn = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(resolve, 500);
})
);
await expect(withTimeout(fn, 50)).rejects.toThrow('TIMEOUT');
});
it('should use custom timeout error message', async () => {
const fn = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(resolve, 500);
})
);
await expect(withTimeout(fn, 50, 'Operation timed out')).rejects.toThrow(
'Operation timed out'
);
});
});
describe('createRetryWrapper', () => {
it('should create a function that retries on failure', async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error('Network Error'))
.mockResolvedValue('success');
const wrappedFn = createRetryWrapper(fn as () => Promise<string>, {
maxAttempts: 3,
initialDelayMs: 10,
});
const result = await wrappedFn();
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(2);
});
it('should pass all arguments through', async () => {
const fn = vi.fn().mockResolvedValue('success');
const wrappedFn = createRetryWrapper(fn as (a: string, b: number) => Promise<string>, {
maxAttempts: 2,
});
await wrappedFn('arg1', 42);
expect(fn).toHaveBeenCalledWith('arg1', 42);
});
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* Retry Utility
* Implements exponential backoff with jitter for resilient operations
*/
import { trackError, trackWarning } from './error-tracking';
export interface RetryOptions {
maxAttempts?: number;
initialDelayMs?: number;
maxDelayMs?: number;
backoffMultiplier?: number;
jitter?: boolean;
onRetry?: (attempt: number, error: Error) => void;
retryCondition?: (error: Error) => boolean;
}
const DEFAULT_OPTIONS: Required<Omit<RetryOptions, 'onRetry' | 'retryCondition'>> = {
maxAttempts: 3,
initialDelayMs: 1000,
maxDelayMs: 30000,
backoffMultiplier: 2,
jitter: true,
};
/**
* Calculate delay with exponential backoff and optional jitter
*/
function calculateDelay(
attempt: number,
initialDelayMs: number,
maxDelayMs: number,
backoffMultiplier: number,
jitter: boolean
): number {
let delay = initialDelayMs * Math.pow(backoffMultiplier, attempt - 1);
delay = Math.min(delay, maxDelayMs);
if (jitter) {
// Add random jitter (±25%)
const jitterFactor = 0.75 + Math.random() * 0.5;
delay = Math.floor(delay * jitterFactor);
}
return delay;
}
/**
* Sleep for specified milliseconds
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Default retry condition - retry on network and timeout errors
*/
function defaultRetryCondition(error: Error): boolean {
const retryableErrors = [
'Network Error',
'Failed to fetch',
'TIMEOUT',
'ECONNRESET',
'ECONNREFUSED',
'ETIMEDOUT',
'fetch failed',
'network request failed',
];
return retryableErrors.some(
(msg) => error.message.toLowerCase().includes(msg.toLowerCase()) || error.name === msg
);
}
/**
* Execute a function with automatic retry and exponential backoff
*
* @example
* const result = await withRetry(() => fetchData(), { maxAttempts: 5 });
*/
export async function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T> {
const config = { ...DEFAULT_OPTIONS, ...options };
const retryCondition = options?.retryCondition ?? defaultRetryCondition;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// Check if we should retry
if (attempt >= config.maxAttempts || !retryCondition(lastError)) {
trackError(lastError, { action: 'retry_failed', extra: { attempt } });
throw lastError;
}
// Calculate delay and wait
const delay = calculateDelay(
attempt,
config.initialDelayMs,
config.maxDelayMs,
config.backoffMultiplier,
config.jitter
);
trackWarning(`Retry attempt ${attempt}/${config.maxAttempts}, waiting ${delay}ms`, {
action: 'retry_attempt',
extra: { error: lastError.message },
});
// Call optional retry callback
if (options?.onRetry) {
options.onRetry(attempt, lastError);
}
await sleep(delay);
}
}
// This shouldn't happen, but TypeScript needs it
throw lastError ?? new Error('Retry failed');
}
/**
* Create a retry wrapper for a specific function
*
* @example
* const fetchWithRetry = createRetryWrapper(fetchData, { maxAttempts: 5 });
* const result = await fetchWithRetry();
*/
export function createRetryWrapper<TArgs extends unknown[], TResult>(
fn: (...args: TArgs) => Promise<TResult>,
options?: RetryOptions
): (...args: TArgs) => Promise<TResult> {
return (...args: TArgs) => withRetry(() => fn(...args), options);
}
/**
* Execute with timeout
*/
export async function withTimeout<T>(
fn: () => Promise<T>,
timeoutMs: number,
timeoutError?: string
): Promise<T> {
return Promise.race([
fn(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(timeoutError ?? 'TIMEOUT')), timeoutMs)
),
]);
}
/**
* Execute with both retry and timeout
*/
export async function withRetryAndTimeout<T>(
fn: () => Promise<T>,
timeoutMs: number,
retryOptions?: RetryOptions
): Promise<T> {
return withRetry(() => withTimeout(fn, timeoutMs), retryOptions);
}
+822
View File
@@ -0,0 +1,822 @@
/**
* RPC Connection Manager
* Handles multiple RPC endpoints with automatic failover
*/
import { ApiPromise, WsProvider } from '@pezkuwi/api';
import { trackError, trackWarning } from './error-tracking';
// RPC Endpoints - ordered by priority
// Note: Domain must have SSL and WebSocket proxy configured
const RPC_ENDPOINTS = [
{
url: 'wss://rpc.pezkuwichain.io',
name: 'Pezkuwichain RPC',
priority: 1,
},
];
// Asset Hub RPC for PEZ token
const ASSET_HUB_ENDPOINTS = [
{
url: 'wss://asset-hub-rpc.pezkuwichain.io',
name: 'Asset Hub RPC',
priority: 1,
},
];
// People Chain RPC for identity/citizenship
const PEOPLE_ENDPOINTS = [
{
url: 'wss://people-rpc.pezkuwichain.io',
name: 'People Chain RPC',
priority: 1,
},
];
interface RPCEndpoint {
url: string;
name: string;
priority: number;
}
interface ConnectionState {
api: ApiPromise | null;
provider: WsProvider | null;
endpoint: RPCEndpoint | null;
isConnected: boolean;
lastError: Error | null;
reconnectAttempts: number;
}
const MAX_RECONNECT_ATTEMPTS = 3;
const RECONNECT_DELAY_MS = 2000;
const CONNECTION_TIMEOUT_MS = 10000; // Reduced from 15s to 10s
const HEALTH_CHECK_INTERVAL_MS = 30000;
let state: ConnectionState = {
api: null,
provider: null,
endpoint: null,
isConnected: false,
lastError: null,
reconnectAttempts: 0,
};
// Asset Hub connection state (for PEZ token)
let assetHubState: ConnectionState = {
api: null,
provider: null,
endpoint: null,
isConnected: false,
lastError: null,
reconnectAttempts: 0,
};
// People Chain connection state (for identity/citizenship)
let peopleState: ConnectionState = {
api: null,
provider: null,
endpoint: null,
isConnected: false,
lastError: null,
reconnectAttempts: 0,
};
// Export last error for UI display
export function getLastError(): string | null {
return state.lastError?.message || null;
}
let healthCheckInterval: ReturnType<typeof setInterval> | null = null;
let assetHubHealthCheckInterval: ReturnType<typeof setInterval> | null = null;
let peopleHealthCheckInterval: ReturnType<typeof setInterval> | null = null;
let connectionListeners: Set<(isConnected: boolean, endpoint: RPCEndpoint | null) => void> =
new Set();
let assetHubListeners: Set<(isConnected: boolean, endpoint: RPCEndpoint | null) => void> =
new Set();
let peopleListeners: Set<(isConnected: boolean, endpoint: RPCEndpoint | null) => void> = new Set();
/**
* Try to connect to a specific endpoint
*/
// PezkuwiChain custom signed extensions
const PEZKUWI_SIGNED_EXTENSIONS = {
AuthorizeCall: {
extrinsic: {},
payload: {},
},
StorageWeightReclaim: {
extrinsic: {},
payload: {},
},
};
async function connectToEndpoint(endpoint: RPCEndpoint): Promise<ApiPromise> {
// Use simple WsProvider with auto-connect (more reliable)
const provider = new WsProvider(endpoint.url);
// Create API with timeout
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Connection to ${endpoint.name} timed out`));
}, CONNECTION_TIMEOUT_MS);
});
try {
const api = await Promise.race([
ApiPromise.create({
provider,
signedExtensions: PEZKUWI_SIGNED_EXTENSIONS,
}),
timeoutPromise,
]);
await api.isReady;
state.provider = provider;
state.endpoint = endpoint;
return api;
} catch (error) {
provider.disconnect();
throw error;
}
}
/**
* Try to connect to available endpoints in order of priority
*/
async function connectWithFailover(): Promise<ApiPromise> {
const sortedEndpoints = [...RPC_ENDPOINTS].sort((a, b) => a.priority - b.priority);
for (const endpoint of sortedEndpoints) {
try {
trackWarning(`Attempting connection to ${endpoint.name}`, {
action: 'rpc_connect_attempt',
extra: { url: endpoint.url },
});
const api = await connectToEndpoint(endpoint);
trackWarning(`Connected to ${endpoint.name}`, {
action: 'rpc_connected',
extra: { url: endpoint.url },
});
return api;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
trackWarning(`Failed to connect to ${endpoint.name}: ${err.message}`, {
action: 'rpc_connect_failed',
extra: { url: endpoint.url },
});
continue;
}
}
throw new Error('All RPC endpoints unavailable');
}
/**
* Initialize RPC connection
*/
export async function initRPCConnection(): Promise<ApiPromise> {
if (state.api && state.isConnected) {
return state.api;
}
try {
const api = await connectWithFailover();
state.api = api;
state.isConnected = true;
state.reconnectAttempts = 0;
state.lastError = null;
// Set up disconnect handler
api.on('disconnected', handleDisconnect);
api.on('error', handleError);
// Start health checks
startHealthCheck();
// Notify listeners
notifyListeners();
return api;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
state.lastError = err;
state.isConnected = false;
trackError(err, { action: 'rpc_init_failed' });
notifyListeners();
throw err;
}
}
/**
* Handle disconnect event
*/
async function handleDisconnect(): Promise<void> {
state.isConnected = false;
notifyListeners();
trackWarning('RPC disconnected, attempting reconnect', { action: 'rpc_disconnected' });
// Attempt reconnection
await attemptReconnect();
}
/**
* Handle error event
*/
function handleError(error: Error): void {
trackError(error, { action: 'rpc_error' });
}
/**
* Attempt to reconnect with exponential backoff
*/
async function attemptReconnect(): Promise<void> {
if (state.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
trackError(new Error('Max reconnect attempts reached'), { action: 'rpc_max_reconnects' });
return;
}
state.reconnectAttempts++;
const delay = RECONNECT_DELAY_MS * Math.pow(2, state.reconnectAttempts - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
try {
await initRPCConnection();
} catch {
// Will retry on next disconnect or health check
}
}
/**
* Start periodic health checks
*/
function startHealthCheck(): void {
if (healthCheckInterval) {
clearInterval(healthCheckInterval);
}
healthCheckInterval = setInterval(async () => {
if (!state.api || !state.isConnected) return;
try {
// Simple health check - get chain name
await state.api.rpc.system.chain();
} catch {
trackWarning('Health check failed', { action: 'rpc_health_failed' });
handleDisconnect();
}
}, HEALTH_CHECK_INTERVAL_MS);
}
/**
* Stop health checks
*/
function stopHealthCheck(): void {
if (healthCheckInterval) {
clearInterval(healthCheckInterval);
healthCheckInterval = null;
}
}
/**
* Get current API instance
*/
export function getAPI(): ApiPromise | null {
return state.api;
}
/**
* Get current connection state
*/
export function getConnectionState(): {
isConnected: boolean;
endpoint: RPCEndpoint | null;
lastError: Error | null;
} {
return {
isConnected: state.isConnected,
endpoint: state.endpoint,
lastError: state.lastError,
};
}
/**
* Subscribe to connection state changes
* Immediately calls callback with current state, then on every change
*/
export function subscribeToConnection(
callback: (isConnected: boolean, endpoint: RPCEndpoint | null) => void
): () => void {
connectionListeners.add(callback);
// Immediately call with current state to avoid race condition
callback(state.isConnected, state.endpoint);
return () => {
connectionListeners.delete(callback);
};
}
/**
* Notify all listeners of state change
*/
function notifyListeners(): void {
connectionListeners.forEach((callback) => {
callback(state.isConnected, state.endpoint);
});
}
/**
* Disconnect from RPC
*/
export async function disconnectRPC(): Promise<void> {
stopHealthCheck();
if (state.api) {
await state.api.disconnect();
}
if (state.provider) {
state.provider.disconnect();
}
state = {
api: null,
provider: null,
endpoint: null,
isConnected: false,
lastError: null,
reconnectAttempts: 0,
};
notifyListeners();
}
/**
* Get list of available endpoints
*/
export function getAvailableEndpoints(): RPCEndpoint[] {
return [...RPC_ENDPOINTS];
}
// ============================================
// ASSET HUB CONNECTION (for PEZ token)
// ============================================
/**
* Connect to Asset Hub endpoint
*/
async function connectToAssetHubEndpoint(endpoint: RPCEndpoint): Promise<ApiPromise> {
const provider = new WsProvider(endpoint.url);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Connection to ${endpoint.name} timed out`));
}, CONNECTION_TIMEOUT_MS);
});
try {
const api = await Promise.race([
ApiPromise.create({
provider,
signedExtensions: PEZKUWI_SIGNED_EXTENSIONS,
}),
timeoutPromise,
]);
await api.isReady;
assetHubState.provider = provider;
assetHubState.endpoint = endpoint;
return api;
} catch (error) {
provider.disconnect();
throw error;
}
}
/**
* Connect to Asset Hub with failover
*/
async function connectAssetHubWithFailover(): Promise<ApiPromise> {
const sortedEndpoints = [...ASSET_HUB_ENDPOINTS].sort((a, b) => a.priority - b.priority);
for (const endpoint of sortedEndpoints) {
try {
trackWarning(`Attempting Asset Hub connection to ${endpoint.name}`, {
action: 'asset_hub_connect_attempt',
extra: { url: endpoint.url },
});
const api = await connectToAssetHubEndpoint(endpoint);
trackWarning(`Connected to Asset Hub ${endpoint.name}`, {
action: 'asset_hub_connected',
extra: { url: endpoint.url },
});
return api;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
trackWarning(`Failed to connect to Asset Hub ${endpoint.name}: ${err.message}`, {
action: 'asset_hub_connect_failed',
extra: { url: endpoint.url },
});
continue;
}
}
throw new Error('All Asset Hub RPC endpoints unavailable');
}
/**
* Initialize Asset Hub RPC connection
*/
export async function initAssetHubConnection(): Promise<ApiPromise> {
if (assetHubState.api && assetHubState.isConnected) {
return assetHubState.api;
}
try {
const api = await connectAssetHubWithFailover();
assetHubState.api = api;
assetHubState.isConnected = true;
assetHubState.reconnectAttempts = 0;
assetHubState.lastError = null;
// Set up disconnect handler
api.on('disconnected', handleAssetHubDisconnect);
api.on('error', handleAssetHubError);
// Start health checks
startAssetHubHealthCheck();
// Notify listeners
notifyAssetHubListeners();
return api;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
assetHubState.lastError = err;
assetHubState.isConnected = false;
trackError(err, { action: 'asset_hub_init_failed' });
notifyAssetHubListeners();
throw err;
}
}
/**
* Handle Asset Hub disconnect
*/
async function handleAssetHubDisconnect(): Promise<void> {
assetHubState.isConnected = false;
notifyAssetHubListeners();
trackWarning('Asset Hub RPC disconnected, attempting reconnect', {
action: 'asset_hub_disconnected',
});
await attemptAssetHubReconnect();
}
/**
* Handle Asset Hub error
*/
function handleAssetHubError(error: Error): void {
trackError(error, { action: 'asset_hub_error' });
}
/**
* Attempt Asset Hub reconnection
*/
async function attemptAssetHubReconnect(): Promise<void> {
if (assetHubState.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
trackError(new Error('Asset Hub max reconnect attempts reached'), {
action: 'asset_hub_max_reconnects',
});
return;
}
assetHubState.reconnectAttempts++;
const delay = RECONNECT_DELAY_MS * Math.pow(2, assetHubState.reconnectAttempts - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
try {
await initAssetHubConnection();
} catch {
// Will retry on next disconnect or health check
}
}
/**
* Start Asset Hub health checks
*/
function startAssetHubHealthCheck(): void {
if (assetHubHealthCheckInterval) {
clearInterval(assetHubHealthCheckInterval);
}
assetHubHealthCheckInterval = setInterval(async () => {
if (!assetHubState.api || !assetHubState.isConnected) return;
try {
await assetHubState.api.rpc.system.chain();
} catch {
trackWarning('Asset Hub health check failed', { action: 'asset_hub_health_failed' });
handleAssetHubDisconnect();
}
}, HEALTH_CHECK_INTERVAL_MS);
}
/**
* Notify Asset Hub listeners
*/
function notifyAssetHubListeners(): void {
assetHubListeners.forEach((callback) => {
callback(assetHubState.isConnected, assetHubState.endpoint);
});
}
/**
* Get Asset Hub API instance
*/
export function getAssetHubAPI(): ApiPromise | null {
return assetHubState.api;
}
/**
* Subscribe to Asset Hub connection state
*/
export function subscribeToAssetHubConnection(
callback: (isConnected: boolean, endpoint: RPCEndpoint | null) => void
): () => void {
assetHubListeners.add(callback);
callback(assetHubState.isConnected, assetHubState.endpoint);
return () => {
assetHubListeners.delete(callback);
};
}
/**
* Disconnect from Asset Hub
*/
export async function disconnectAssetHub(): Promise<void> {
if (assetHubHealthCheckInterval) {
clearInterval(assetHubHealthCheckInterval);
assetHubHealthCheckInterval = null;
}
if (assetHubState.api) {
await assetHubState.api.disconnect();
}
if (assetHubState.provider) {
assetHubState.provider.disconnect();
}
assetHubState = {
api: null,
provider: null,
endpoint: null,
isConnected: false,
lastError: null,
reconnectAttempts: 0,
};
notifyAssetHubListeners();
}
// ============================================
// PEOPLE CHAIN CONNECTION (for identity/citizenship)
// ============================================
/**
* Connect to People Chain endpoint
*/
async function connectToPeopleEndpoint(endpoint: RPCEndpoint): Promise<ApiPromise> {
const provider = new WsProvider(endpoint.url);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Connection to ${endpoint.name} timed out`));
}, CONNECTION_TIMEOUT_MS);
});
try {
const api = await Promise.race([
ApiPromise.create({
provider,
signedExtensions: PEZKUWI_SIGNED_EXTENSIONS,
}),
timeoutPromise,
]);
await api.isReady;
peopleState.provider = provider;
peopleState.endpoint = endpoint;
return api;
} catch (error) {
provider.disconnect();
throw error;
}
}
/**
* Connect to People Chain with failover
*/
async function connectPeopleWithFailover(): Promise<ApiPromise> {
const sortedEndpoints = [...PEOPLE_ENDPOINTS].sort((a, b) => a.priority - b.priority);
for (const endpoint of sortedEndpoints) {
try {
trackWarning(`Attempting People Chain connection to ${endpoint.name}`, {
action: 'people_connect_attempt',
extra: { url: endpoint.url },
});
const api = await connectToPeopleEndpoint(endpoint);
trackWarning(`Connected to People Chain ${endpoint.name}`, {
action: 'people_connected',
extra: { url: endpoint.url },
});
return api;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
trackWarning(`Failed to connect to People Chain ${endpoint.name}: ${err.message}`, {
action: 'people_connect_failed',
extra: { url: endpoint.url },
});
continue;
}
}
throw new Error('All People Chain RPC endpoints unavailable');
}
/**
* Initialize People Chain RPC connection
*/
export async function initPeopleConnection(): Promise<ApiPromise> {
if (peopleState.api && peopleState.isConnected) {
return peopleState.api;
}
try {
const api = await connectPeopleWithFailover();
peopleState.api = api;
peopleState.isConnected = true;
peopleState.reconnectAttempts = 0;
peopleState.lastError = null;
// Set up disconnect handler
api.on('disconnected', handlePeopleDisconnect);
api.on('error', handlePeopleError);
// Start health checks
startPeopleHealthCheck();
// Notify listeners
notifyPeopleListeners();
return api;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
peopleState.lastError = err;
peopleState.isConnected = false;
trackError(err, { action: 'people_init_failed' });
notifyPeopleListeners();
throw err;
}
}
/**
* Handle People Chain disconnect
*/
async function handlePeopleDisconnect(): Promise<void> {
peopleState.isConnected = false;
notifyPeopleListeners();
trackWarning('People Chain RPC disconnected, attempting reconnect', {
action: 'people_disconnected',
});
await attemptPeopleReconnect();
}
/**
* Handle People Chain error
*/
function handlePeopleError(error: Error): void {
trackError(error, { action: 'people_error' });
}
/**
* Attempt People Chain reconnection
*/
async function attemptPeopleReconnect(): Promise<void> {
if (peopleState.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
trackError(new Error('People Chain max reconnect attempts reached'), {
action: 'people_max_reconnects',
});
return;
}
peopleState.reconnectAttempts++;
const delay = RECONNECT_DELAY_MS * Math.pow(2, peopleState.reconnectAttempts - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
try {
await initPeopleConnection();
} catch {
// Will retry on next disconnect or health check
}
}
/**
* Start People Chain health checks
*/
function startPeopleHealthCheck(): void {
if (peopleHealthCheckInterval) {
clearInterval(peopleHealthCheckInterval);
}
peopleHealthCheckInterval = setInterval(async () => {
if (!peopleState.api || !peopleState.isConnected) return;
try {
await peopleState.api.rpc.system.chain();
} catch {
trackWarning('People Chain health check failed', { action: 'people_health_failed' });
handlePeopleDisconnect();
}
}, HEALTH_CHECK_INTERVAL_MS);
}
/**
* Notify People Chain listeners
*/
function notifyPeopleListeners(): void {
peopleListeners.forEach((callback) => {
callback(peopleState.isConnected, peopleState.endpoint);
});
}
/**
* Get People Chain API instance
*/
export function getPeopleAPI(): ApiPromise | null {
return peopleState.api;
}
/**
* Subscribe to People Chain connection state
*/
export function subscribeToPeopleConnection(
callback: (isConnected: boolean, endpoint: RPCEndpoint | null) => void
): () => void {
peopleListeners.add(callback);
callback(peopleState.isConnected, peopleState.endpoint);
return () => {
peopleListeners.delete(callback);
};
}
/**
* Disconnect from People Chain
*/
export async function disconnectPeople(): Promise<void> {
if (peopleHealthCheckInterval) {
clearInterval(peopleHealthCheckInterval);
peopleHealthCheckInterval = null;
}
if (peopleState.api) {
await peopleState.api.disconnect();
}
if (peopleState.provider) {
peopleState.provider.disconnect();
}
peopleState = {
api: null,
provider: null,
endpoint: null,
isConnected: false,
lastError: null,
reconnectAttempts: 0,
};
notifyPeopleListeners();
}
+42
View File
@@ -0,0 +1,42 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { env } from './env';
// Supabase client singleton
// Using 'any' for database type - run `supabase gen types typescript` for proper types
export const supabase: SupabaseClient = createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY);
// Telegram auth helper - validates initData with Edge Function
export async function signInWithTelegram(initData: string) {
if (!initData) {
throw new Error('No Telegram initData provided');
}
const { data, error } = await supabase.functions.invoke('telegram-auth', {
body: { initData },
});
if (error) {
console.error('[Auth] Telegram sign-in failed:', error);
throw error;
}
if (data?.session) {
await supabase.auth.setSession(data.session);
}
return data;
}
// Helper to get current session
export async function getCurrentSession() {
const {
data: { session },
} = await supabase.auth.getSession();
return session;
}
// Helper to sign out
export async function signOut() {
const { error } = await supabase.auth.signOut();
if (error) throw error;
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { cn, formatNumber, formatDate, formatAddress } from './utils';
describe('utils', () => {
describe('cn', () => {
it('merges class names correctly', () => {
expect(cn('foo', 'bar')).toBe('foo bar');
const condition = false;
expect(cn('foo', condition && 'bar', 'baz')).toBe('foo baz');
});
it('handles tailwind conflicts', () => {
expect(cn('p-4', 'p-2')).toBe('p-2');
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
});
});
describe('formatNumber', () => {
it('formats small numbers as-is', () => {
expect(formatNumber(0)).toBe('0');
expect(formatNumber(999)).toBe('999');
});
it('formats thousands with K suffix', () => {
expect(formatNumber(1000)).toBe('1.0K');
expect(formatNumber(1500)).toBe('1.5K');
expect(formatNumber(999999)).toBe('1000.0K');
});
it('formats millions with M suffix', () => {
expect(formatNumber(1000000)).toBe('1.0M');
expect(formatNumber(2500000)).toBe('2.5M');
});
});
describe('formatAddress', () => {
it('truncates long addresses', () => {
const address = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY';
expect(formatAddress(address)).toBe('5Grwva...GKutQY'); // default: 6 chars from each end
expect(formatAddress(address, 4)).toBe('5Grw...utQY');
});
it('returns short addresses unchanged', () => {
expect(formatAddress('abc')).toBe('abc');
expect(formatAddress('')).toBe('');
});
});
describe('formatDate', () => {
it('formats recent dates as relative time', () => {
const now = new Date();
expect(formatDate(now)).toBe('Niha');
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
expect(formatDate(fiveMinutesAgo)).toBe('5 deq berê');
const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000);
expect(formatDate(twoHoursAgo)).toBe('2 saet berê');
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
expect(formatDate(threeDaysAgo)).toBe('3 roj berê');
});
});
});
+38
View File
@@ -0,0 +1,38 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatAddress(address: string, chars = 6): string {
if (!address || address.length < chars * 2) return address;
return `${address.slice(0, chars)}...${address.slice(-chars)}`;
}
export function formatNumber(num: number): string {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num.toString();
}
export function formatDate(date: Date | string): string {
const d = new Date(date);
const now = new Date();
const diff = now.getTime() - d.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'Niha';
if (minutes < 60) return `${minutes} deq berê`;
if (hours < 24) return `${hours} saet berê`;
if (days < 7) return `${days} roj berê`;
return d.toLocaleDateString('ku', { day: 'numeric', month: 'short' });
}
export function generateId(): string {
return Math.random().toString(36).substring(2, 15);
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Wallet Service
* Handles mnemonic generation, keypair creation, and signing
*/
import { Keyring } from '@pezkuwi/keyring';
import { mnemonicGenerate, mnemonicValidate, cryptoWaitReady } from '@pezkuwi/util-crypto';
// PezkuwiChain SS58 format
const SS58_FORMAT = 42;
let isReady = false;
/**
* Initialize crypto libraries (must be called before using wallet functions)
*/
export async function initWalletService(): Promise<void> {
if (isReady) return;
await cryptoWaitReady();
isReady = true;
}
/**
* Generate a new 12-word mnemonic
*/
export function generateMnemonic(): string {
if (!isReady) throw new Error('Wallet service not initialized');
// Use onlyJs=true to avoid WASM bip39Generate stub that throws error
return mnemonicGenerate(12, undefined, true);
}
/**
* Validate a mnemonic phrase
*/
export function validateMnemonic(mnemonic: string): boolean {
return mnemonicValidate(mnemonic);
}
/**
* Derive wallet address from mnemonic
*/
export function getAddressFromMnemonic(mnemonic: string): string {
if (!isReady) throw new Error('Wallet service not initialized');
const keyring = new Keyring({ type: 'sr25519', ss58Format: SS58_FORMAT });
const pair = keyring.addFromMnemonic(mnemonic);
return pair.address;
}
/**
* Create keypair from mnemonic (for signing)
*/
export function createKeypair(mnemonic: string) {
if (!isReady) throw new Error('Wallet service not initialized');
const keyring = new Keyring({ type: 'sr25519', ss58Format: SS58_FORMAT });
return keyring.addFromMnemonic(mnemonic);
}
/**
* Format address for display (truncate middle)
*/
export function formatAddress(address: string, chars = 6): string {
if (address.length <= chars * 2 + 3) return address;
return `${address.slice(0, chars)}...${address.slice(-chars)}`;
}
/**
* Validate address format
*/
export function isValidAddress(address: string): boolean {
// Basic SS58 validation - starts with expected prefix and has correct length
return /^[1-9A-HJ-NP-Za-km-z]{47,48}$/.test(address);
}
/**
* Generate cryptographically secure random integer in range [0, max)
*/
function secureRandomInt(max: number): number {
const randomBuffer = new Uint32Array(1);
crypto.getRandomValues(randomBuffer);
return randomBuffer[0] % max;
}
/**
* Get random words from mnemonic for verification
* Uses crypto.getRandomValues() for security
*/
export function getVerificationWords(
mnemonic: string,
count = 3
): { index: number; word: string }[] {
const words = mnemonic.split(' ');
const indices: number[] = [];
while (indices.length < count) {
const randomIndex = secureRandomInt(words.length);
if (!indices.includes(randomIndex)) {
indices.push(randomIndex);
}
}
return indices
.sort((a, b) => a - b)
.map((index) => ({
index: index + 1, // 1-based for display
word: words[index],
}));
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Wallet Storage Service
* Handles encrypted wallet storage in localStorage
*/
import { encrypt, decrypt } from './crypto';
const STORAGE_KEY = 'pezkuwi_wallet';
export interface StoredWallet {
address: string;
encryptedMnemonic: string;
createdAt: number;
}
/**
* Check if wallet exists in storage
*/
export function hasStoredWallet(): boolean {
return localStorage.getItem(STORAGE_KEY) !== null;
}
/**
* Save wallet to storage (encrypted)
*/
export async function saveWallet(
mnemonic: string,
address: string,
password: string
): Promise<void> {
const encryptedMnemonic = await encrypt(mnemonic, password);
const wallet: StoredWallet = {
address,
encryptedMnemonic,
createdAt: Date.now(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(wallet));
}
/**
* Load wallet from storage
*/
export function getStoredWallet(): StoredWallet | null {
const data = localStorage.getItem(STORAGE_KEY);
if (!data) return null;
try {
return JSON.parse(data) as StoredWallet;
} catch {
return null;
}
}
/**
* Get stored wallet address (without decryption)
*/
export function getStoredAddress(): string | null {
const wallet = getStoredWallet();
return wallet?.address ?? null;
}
/**
* Unlock wallet (decrypt mnemonic)
*/
export async function unlockWallet(password: string): Promise<string> {
const wallet = getStoredWallet();
if (!wallet) throw new Error('Wallet not found');
try {
const mnemonic = await decrypt(wallet.encryptedMnemonic, password);
return mnemonic;
} catch {
throw new Error('Şîfre (password) çewt e');
}
}
/**
* Delete wallet from storage
*/
export function deleteWallet(): void {
localStorage.removeItem(STORAGE_KEY);
}
/**
* Update wallet address in Supabase for existing user
* User must already exist (created by telegram-auth Edge Function)
*/
export async function syncWalletToSupabase(
supabase: { from: (table: string) => unknown },
telegramId: number,
address: string
): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = supabase as any;
// UPDATE existing user's wallet_address (don't create new user)
const { error } = await client
.from('users')
.update({
wallet_address: address,
updated_at: new Date().toISOString(),
})
.eq('telegram_id', telegramId);
if (error) {
console.error('Wallet sync error:', error);
throw new Error('Wallet adresa DB-ê re senkronîze nebû');
}
}
+106
View File
@@ -0,0 +1,106 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './contexts/AuthContext';
import { WalletProvider } from './contexts/WalletContext';
import { ReferralProvider } from './contexts/ReferralContext';
import { ErrorBoundary } from './components/ErrorBoundary';
import App from './App';
import './index.css';
// Suppress console logs in production
if (import.meta.env.PROD) {
const noop = () => {};
console.log = noop;
console.debug = noop;
console.info = noop;
// Keep console.warn and console.error for critical issues
}
// Initialize Telegram WebApp
const tg = window.Telegram?.WebApp;
if (tg) {
tg.ready();
tg.expand();
tg.setHeaderColor('#030712');
tg.setBackgroundColor('#030712');
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30000,
retry: 2,
},
},
});
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root element not found');
}
createRoot(rootElement).render(
<StrictMode>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<WalletProvider>
<ReferralProvider>
<App />
</ReferralProvider>
</WalletProvider>
</AuthProvider>
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>
);
// Type declarations for Telegram WebApp
declare global {
interface Window {
Telegram?: {
WebApp: {
ready: () => void;
expand: () => void;
close: () => void;
setHeaderColor: (color: string) => void;
setBackgroundColor: (color: string) => void;
showAlert: (message: string) => void;
showConfirm: (message: string, callback: (confirmed: boolean) => void) => void;
HapticFeedback: {
impactOccurred: (style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft') => void;
notificationOccurred: (type: 'error' | 'success' | 'warning') => void;
selectionChanged: () => void;
};
initDataUnsafe: {
user?: {
id: number;
first_name: string;
last_name?: string;
username?: string;
language_code?: string;
};
start_param?: string;
};
openLink: (url: string) => void;
openTelegramLink: (url: string) => void;
showScanQrPopup: (
params: { text?: string },
callback: (text: string) => boolean | void
) => void;
closeScanQrPopup: () => void;
initData: string;
version: string;
platform: string;
themeParams: {
bg_color?: string;
text_color?: string;
hint_color?: string;
link_color?: string;
button_color?: string;
button_text_color?: string;
};
};
};
}
}
+168
View File
@@ -0,0 +1,168 @@
import {
Megaphone,
ThumbsUp,
ThumbsDown,
RefreshCw,
ExternalLink,
Calendar,
Eye,
} from 'lucide-react';
import { cn, formatDate, formatNumber } from '@/lib/utils';
import { useTelegram } from '@/hooks/useTelegram';
import { useAnnouncements, useAnnouncementReaction } from '@/hooks/useSupabase';
import { useAuth } from '@/contexts/AuthContext';
export function AnnouncementsSection() {
const { hapticImpact, hapticNotification, openLink } = useTelegram();
const { isAuthenticated } = useAuth();
const { data: announcements, isLoading, refetch, isRefetching } = useAnnouncements();
const reactionMutation = useAnnouncementReaction();
const handleReaction = (id: string, reaction: 'like' | 'dislike') => {
if (!isAuthenticated) {
hapticNotification('error');
// Show alert or toast here if UI library allows, for now using browser alert for clarity in dev
// In production better to use a Toast component
if (window.Telegram?.WebApp) {
window.Telegram.WebApp.showAlert('Ji bo dengdanê divê tu têketî bî');
} else {
window.alert('Ji bo dengdanê divê tu têketî bî');
}
return;
}
hapticImpact('light');
reactionMutation.mutate(
{ announcementId: id, reaction },
{ onSuccess: () => hapticNotification('success') }
);
};
const handleRefresh = () => {
hapticImpact('medium');
refetch();
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<header className="flex-shrink-0 px-4 py-3 border-b border-border bg-background/80 backdrop-blur-sm safe-area-top">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
<Megaphone className="w-4 h-4 text-primary" />
</div>
<h1 className="text-lg font-semibold">Ragihandin</h1>
</div>
<button
onClick={handleRefresh}
disabled={isRefetching}
className="p-2 rounded-lg hover:bg-secondary transition-colors"
>
<RefreshCw
className={cn('w-5 h-5 text-muted-foreground', isRefetching && 'animate-spin')}
/>
</button>
</div>
</header>
{/* Content */}
<div className="flex-1 overflow-y-auto hide-scrollbar">
{isLoading ? (
<div className="p-4 space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-secondary/50 rounded-xl p-4 animate-pulse">
<div className="h-4 bg-secondary rounded w-3/4 mb-3" />
<div className="h-3 bg-secondary rounded w-full mb-2" />
<div className="h-3 bg-secondary rounded w-2/3" />
</div>
))}
</div>
) : (
<div className="p-4 space-y-4">
{announcements?.map((announcement) => (
<article
key={announcement.id}
className="bg-secondary/30 rounded-xl overflow-hidden border border-border/50"
>
{/* Image */}
{announcement.image_url && (
<div className="overflow-hidden bg-secondary/50">
<img
src={announcement.image_url}
alt=""
className="w-full h-auto object-contain"
/>
</div>
)}
<div className="p-4">
{/* Title */}
<h2 className="font-semibold text-foreground mb-2 leading-tight">
{announcement.title}
</h2>
{/* Content */}
<p className="text-sm text-muted-foreground mb-3 leading-relaxed">
{announcement.content}
</p>
{/* Link */}
{announcement.link_url && (
<button
onClick={() => openLink(announcement.link_url as string)}
className="flex items-center gap-1.5 text-sm text-primary mb-3 hover:underline"
>
<ExternalLink className="w-3.5 h-3.5" />
Zêdetir bixwîne
</button>
)}
{/* Meta */}
<div className="flex items-center gap-4 text-xs text-muted-foreground mb-3">
<span className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" />
{formatDate(announcement.created_at)}
</span>
<span className="flex items-center gap-1">
<Eye className="w-3.5 h-3.5" />
{formatNumber(announcement.views)}
</span>
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-3 border-t border-border/50">
<button
onClick={() => handleReaction(announcement.id, 'like')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors',
announcement.user_reaction === 'like'
? 'bg-green-500/20 text-green-400'
: 'bg-secondary hover:bg-secondary/80 text-muted-foreground'
)}
>
<ThumbsUp className="w-4 h-4" />
{formatNumber(announcement.likes)}
</button>
<button
onClick={() => handleReaction(announcement.id, 'dislike')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors',
announcement.user_reaction === 'dislike'
? 'bg-red-500/20 text-red-400'
: 'bg-secondary hover:bg-secondary/80 text-muted-foreground'
)}
>
<ThumbsDown className="w-4 h-4" />
{formatNumber(announcement.dislikes)}
</button>
</div>
</div>
</article>
))}
</div>
)}
</div>
</div>
);
}
+862
View File
@@ -0,0 +1,862 @@
/**
* Forum Section - Community Discussions
* Uses shared Supabase tables from pwap/web (forum_discussions, forum_categories)
*/
import { useState, useEffect } from 'react';
import {
MessageCircle,
ArrowLeft,
ThumbsUp,
ThumbsDown,
MessageSquare,
Clock,
TrendingUp,
Flame,
RefreshCw,
Search,
Pin,
Lock,
Eye,
AlertTriangle,
Info,
CheckCircle,
Megaphone,
Plus,
Send,
X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTelegram } from '@/hooks/useTelegram';
import { useAuth } from '@/contexts/AuthContext';
import { useForum, type ForumDiscussion, type ForumReply } from '@/hooks/useForum';
import { formatDistanceToNow } from 'date-fns';
type SortBy = 'recent' | 'popular' | 'replies' | 'views';
export function ForumSection() {
const { hapticImpact, hapticNotification, showAlert } = useTelegram();
const { user: authUser } = useAuth();
// Use authenticated user ID from backend, not initDataUnsafe
const userId = authUser?.telegram_id?.toString() || '';
const userName = authUser?.first_name || 'Telegram User';
const {
announcements,
categories,
discussions,
loading,
refreshData,
fetchReplies,
createDiscussion,
createReply,
voteOnDiscussion,
voteOnReply,
incrementViewCount,
} = useForum(userId);
const [view, setView] = useState<'list' | 'thread' | 'create'>('list');
const [selectedDiscussion, setSelectedDiscussion] = useState<ForumDiscussion | null>(null);
const [sortBy, setSortBy] = useState<SortBy>('recent');
const [filterCategory, setFilterCategory] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
// Thread view state
const [replies, setReplies] = useState<ForumReply[]>([]);
const [loadingReplies, setLoadingReplies] = useState(false);
const [replyText, setReplyText] = useState('');
const [submittingReply, setSubmittingReply] = useState(false);
// Create discussion state
const [newTitle, setNewTitle] = useState('');
const [newContent, setNewContent] = useState('');
const [newCategory, setNewCategory] = useState<string>('');
const [newTags, setNewTags] = useState('');
const [submittingDiscussion, setSubmittingDiscussion] = useState(false);
const handleOpenThread = async (discussion: ForumDiscussion) => {
hapticImpact('light');
setSelectedDiscussion(discussion);
setView('thread');
// Increment view count
await incrementViewCount(discussion.id);
// Load replies
setLoadingReplies(true);
const loadedReplies = await fetchReplies(discussion.id);
setReplies(loadedReplies);
setLoadingReplies(false);
};
const handleBack = () => {
hapticImpact('light');
setView('list');
setSelectedDiscussion(null);
setReplies([]);
setReplyText('');
};
const handleOpenCreate = () => {
hapticImpact('medium');
setView('create');
// Set default category if available
if (categories.length > 0 && !newCategory) {
setNewCategory(categories[0].id);
}
};
const handleCloseCreate = () => {
hapticImpact('light');
setView('list');
setNewTitle('');
setNewContent('');
setNewTags('');
};
const handleVoteDiscussion = async (voteType: 'upvote' | 'downvote') => {
if (!selectedDiscussion || !userId) {
showAlert('Ji bo dengdanê têkeve');
return;
}
hapticImpact('light');
try {
await voteOnDiscussion(selectedDiscussion.id, userId, voteType);
hapticNotification('success');
// Update local state
const updatedDiscussion = discussions.find((d) => d.id === selectedDiscussion.id);
if (updatedDiscussion) {
setSelectedDiscussion(updatedDiscussion);
}
} catch {
hapticNotification('error');
showAlert('Çewtî di dengdanê de');
}
};
const handleVoteReply = async (replyId: string, voteType: 'upvote' | 'downvote') => {
if (!userId) {
showAlert('Ji bo dengdanê têkeve');
return;
}
hapticImpact('light');
try {
await voteOnReply(replyId, userId, voteType);
hapticNotification('success');
// Refresh replies
if (selectedDiscussion) {
const loadedReplies = await fetchReplies(selectedDiscussion.id);
setReplies(loadedReplies);
}
} catch {
hapticNotification('error');
}
};
const handleSubmitReply = async () => {
if (!selectedDiscussion || !replyText.trim() || !userId) {
showAlert('Ji kerema xwe bersiva xwe binivîse');
return;
}
if (selectedDiscussion.is_locked) {
showAlert('Ev mijar kilîtkirî ye');
return;
}
setSubmittingReply(true);
hapticImpact('medium');
try {
await createReply({
discussion_id: selectedDiscussion.id,
content: replyText.trim(),
author_id: userId,
author_name: userName,
});
setReplyText('');
hapticNotification('success');
// Refresh replies
const loadedReplies = await fetchReplies(selectedDiscussion.id);
setReplies(loadedReplies);
} catch {
hapticNotification('error');
showAlert('Çewtî di şandina bersivê de');
} finally {
setSubmittingReply(false);
}
};
const handleSubmitDiscussion = async () => {
if (!newTitle.trim() || !newContent.trim() || !newCategory || !userId) {
showAlert('Ji kerema xwe hemû qadan tije bike');
return;
}
setSubmittingDiscussion(true);
hapticImpact('medium');
try {
const tags = newTags
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
await createDiscussion({
title: newTitle.trim(),
content: newContent.trim(),
category_id: newCategory,
author_id: userId,
author_name: userName,
tags,
});
hapticNotification('success');
showAlert('Mijar hat afirandin!');
handleCloseCreate();
} catch {
hapticNotification('error');
showAlert('Çewtî di afirandina mijarê de');
} finally {
setSubmittingDiscussion(false);
}
};
const getAnnouncementStyle = (type: string) => {
switch (type) {
case 'critical':
return { icon: AlertTriangle, bgClass: 'bg-red-500/20 border-red-500/40 text-red-300' };
case 'warning':
return {
icon: AlertTriangle,
bgClass: 'bg-yellow-500/20 border-yellow-500/40 text-yellow-300',
};
case 'success':
return { icon: CheckCircle, bgClass: 'bg-green-500/20 border-green-500/40 text-green-300' };
default:
return { icon: Info, bgClass: 'bg-blue-500/20 border-blue-500/40 text-blue-300' };
}
};
// Filter and sort discussions
const filteredDiscussions = discussions
.filter((d) => {
const matchesSearch =
d.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
d.content.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory =
filterCategory === 'all' || d.category?.name.toLowerCase() === filterCategory.toLowerCase();
return matchesSearch && matchesCategory;
})
.sort((a, b) => {
switch (sortBy) {
case 'popular':
return (b.upvotes || 0) - (a.upvotes || 0);
case 'replies':
return b.replies_count - a.replies_count;
case 'views':
return b.views_count - a.views_count;
default:
return new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime();
}
});
// Refresh selected discussion when discussions update
const selectedDiscussionId = selectedDiscussion?.id;
useEffect(() => {
if (selectedDiscussionId) {
const updated = discussions.find((d) => d.id === selectedDiscussionId);
if (updated) {
setSelectedDiscussion(updated);
}
}
}, [discussions, selectedDiscussionId]);
// Create Discussion View
if (view === 'create') {
return (
<div className="flex flex-col h-full">
<header className="flex-shrink-0 px-4 py-3 border-b border-border bg-background/80 backdrop-blur-sm safe-area-top">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={handleCloseCreate}
className="p-2 -ml-2 rounded-lg hover:bg-secondary"
>
<X className="w-5 h-5" />
</button>
<h1 className="text-lg font-semibold">Mijara </h1>
</div>
<button
onClick={handleSubmitDiscussion}
disabled={submittingDiscussion || !newTitle.trim() || !newContent.trim()}
className={cn(
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
submittingDiscussion || !newTitle.trim() || !newContent.trim()
? 'bg-secondary text-muted-foreground'
: 'bg-primary text-primary-foreground'
)}
>
{submittingDiscussion ? 'Tê şandin...' : 'Biweşîne'}
</button>
</div>
</header>
<div className="flex-1 overflow-y-auto hide-scrollbar p-4 space-y-4">
{/* Category */}
<div>
<label className="text-sm text-muted-foreground mb-2 block">Kategorî</label>
<div className="flex gap-2 flex-wrap">
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => {
hapticImpact('light');
setNewCategory(cat.id);
}}
className={cn(
'px-3 py-2 rounded-lg text-sm transition-colors flex items-center gap-2',
newCategory === cat.id
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground'
)}
>
<span>{cat.icon}</span>
{cat.name}
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label className="text-sm text-muted-foreground mb-2 block">Sernav</label>
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Navê mijarê..."
className="w-full px-4 py-3 bg-secondary rounded-lg text-foreground"
maxLength={200}
/>
</div>
{/* Content */}
<div>
<label className="text-sm text-muted-foreground mb-2 block">Naverok</label>
<textarea
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
placeholder="Naveroka mijarê binivîse..."
className="w-full px-4 py-3 bg-secondary rounded-lg text-foreground min-h-[200px] resize-none"
/>
</div>
{/* Tags */}
<div>
<label className="text-sm text-muted-foreground mb-2 block">
Etîket (bi virgulê cuda bike)
</label>
<input
type="text"
value={newTags}
onChange={(e) => setNewTags(e.target.value)}
placeholder="blockchain, kurd, pez..."
className="w-full px-4 py-3 bg-secondary rounded-lg text-foreground"
/>
</div>
</div>
</div>
);
}
// Thread Detail View
if (view === 'thread' && selectedDiscussion) {
return (
<div className="flex flex-col h-full">
<header className="flex-shrink-0 px-4 py-3 border-b border-border bg-background/80 backdrop-blur-sm safe-area-top">
<div className="flex items-center gap-3">
<button onClick={handleBack} className="p-2 -ml-2 rounded-lg hover:bg-secondary">
<ArrowLeft className="w-5 h-5" />
</button>
<h1 className="text-lg font-semibold truncate flex-1">{selectedDiscussion.title}</h1>
</div>
</header>
<div className="flex-1 overflow-y-auto hide-scrollbar p-4">
{/* Badges */}
<div className="flex items-center gap-2 mb-3 flex-wrap">
{selectedDiscussion.is_pinned && (
<span className="inline-flex items-center gap-1 text-xs bg-yellow-500/20 text-yellow-400 px-2 py-1 rounded-full">
<Pin className="w-3 h-3" />
Pinned
</span>
)}
{selectedDiscussion.is_locked && (
<span className="inline-flex items-center gap-1 text-xs bg-red-500/20 text-red-400 px-2 py-1 rounded-full">
<Lock className="w-3 h-3" />
Kilîtkirî
</span>
)}
{selectedDiscussion.category && (
<span className="inline-flex items-center gap-1 text-xs bg-secondary text-muted-foreground px-2 py-1 rounded-full">
{selectedDiscussion.category.icon} {selectedDiscussion.category.name}
</span>
)}
</div>
{/* Content */}
<div className="bg-secondary/30 rounded-xl p-4 mb-4 border border-border/50">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-sm font-bold">
{selectedDiscussion.author_name?.charAt(0) || 'A'}
</div>
<div>
<p className="text-sm font-medium">
{selectedDiscussion.author_name || 'Anonymous'}
</p>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(selectedDiscussion.created_at), {
addSuffix: true,
})}
</p>
</div>
</div>
{/* Image if exists */}
{selectedDiscussion.image_url && (
<div className="mb-4 rounded-lg overflow-hidden bg-secondary/50">
<img
src={selectedDiscussion.image_url}
alt=""
className="w-full h-auto object-contain"
/>
</div>
)}
<p className="text-foreground leading-relaxed whitespace-pre-wrap">
{selectedDiscussion.content}
</p>
{/* Tags */}
{selectedDiscussion.tags && selectedDiscussion.tags.length > 0 && (
<div className="flex gap-2 mt-4 flex-wrap">
{selectedDiscussion.tags.map((tag) => (
<span
key={tag}
className="text-xs bg-cyan-500/20 text-cyan-400 px-2 py-1 rounded-full"
>
#{tag}
</span>
))}
</div>
)}
{/* Stats & Vote Buttons */}
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border/50">
<button
onClick={() => handleVoteDiscussion('upvote')}
className={cn(
'flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm transition-colors',
selectedDiscussion.userVote === 'upvote'
? 'bg-green-500/20 text-green-400'
: 'bg-secondary text-muted-foreground hover:bg-secondary/80'
)}
>
<ThumbsUp className="w-4 h-4" />
{selectedDiscussion.upvotes || 0}
</button>
<button
onClick={() => handleVoteDiscussion('downvote')}
className={cn(
'flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm transition-colors',
selectedDiscussion.userVote === 'downvote'
? 'bg-red-500/20 text-red-400'
: 'bg-secondary text-muted-foreground hover:bg-secondary/80'
)}
>
<ThumbsDown className="w-4 h-4" />
{selectedDiscussion.downvotes || 0}
</button>
<span className="flex items-center gap-1 text-sm text-muted-foreground ml-auto">
<MessageSquare className="w-4 h-4" />
{selectedDiscussion.replies_count}
</span>
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<Eye className="w-4 h-4" />
{selectedDiscussion.views_count}
</span>
</div>
</div>
{/* Replies Section */}
<div className="mb-4">
<h3 className="text-sm font-semibold text-muted-foreground mb-3 flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Bersiv ({replies.length})
</h3>
{loadingReplies ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-secondary/30 rounded-xl p-4 animate-pulse">
<div className="h-4 bg-secondary rounded w-1/4 mb-2" />
<div className="h-3 bg-secondary rounded w-3/4" />
</div>
))}
</div>
) : replies.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">Hêj bersiv tune ye</p>
<p className="text-xs">Yekemîn bersivê tu bide!</p>
</div>
) : (
<div className="space-y-3">
{replies.map((reply) => (
<div
key={reply.id}
className="bg-secondary/20 rounded-xl p-3 border border-border/30"
>
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold">
{reply.author_name?.charAt(0) || 'A'}
</div>
<span className="text-sm font-medium">
{reply.author_name || 'Anonymous'}
</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(reply.created_at), { addSuffix: true })}
</span>
</div>
<p className="text-sm text-foreground whitespace-pre-wrap mb-2">
{reply.content}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => handleVoteReply(reply.id, 'upvote')}
className={cn(
'flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors',
reply.userVote === 'upvote'
? 'bg-green-500/20 text-green-400'
: 'text-muted-foreground hover:bg-secondary'
)}
>
<ThumbsUp className="w-3 h-3" />
{reply.upvotes || 0}
</button>
<button
onClick={() => handleVoteReply(reply.id, 'downvote')}
className={cn(
'flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors',
reply.userVote === 'downvote'
? 'bg-red-500/20 text-red-400'
: 'text-muted-foreground hover:bg-secondary'
)}
>
<ThumbsDown className="w-3 h-3" />
{reply.downvotes || 0}
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Reply Input */}
{!selectedDiscussion.is_locked && (
<div className="flex-shrink-0 p-4 border-t border-border bg-background safe-area-bottom">
<div className="flex gap-2">
<input
type="text"
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Bersiva xwe binivîse..."
className="flex-1 px-4 py-2.5 bg-secondary rounded-lg text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmitReply();
}
}}
/>
<button
onClick={handleSubmitReply}
disabled={submittingReply || !replyText.trim()}
className={cn(
'p-2.5 rounded-lg transition-colors',
submittingReply || !replyText.trim()
? 'bg-secondary text-muted-foreground'
: 'bg-primary text-primary-foreground'
)}
>
<Send className="w-5 h-5" />
</button>
</div>
</div>
)}
</div>
);
}
// Thread List View
return (
<div className="flex flex-col h-full">
<header className="flex-shrink-0 px-4 py-3 border-b border-border bg-background/80 backdrop-blur-sm safe-area-top">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center">
<MessageCircle className="w-4 h-4 text-blue-400" />
</div>
<h1 className="text-lg font-semibold">Forum</h1>
<span className="text-xs text-muted-foreground">({discussions.length})</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleOpenCreate}
className="p-2 rounded-lg bg-primary text-primary-foreground"
>
<Plus className="w-5 h-5" />
</button>
<button
onClick={() => {
hapticImpact('light');
refreshData();
}}
disabled={loading}
className="p-2 rounded-lg hover:bg-secondary"
>
<RefreshCw
className={`w-5 h-5 text-muted-foreground ${loading ? 'animate-spin' : ''}`}
/>
</button>
</div>
</div>
{/* Search */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Mijar bigere..."
className="w-full pl-9 pr-4 py-2 bg-secondary rounded-lg text-sm"
/>
</div>
{/* Sort Tabs */}
<div className="flex gap-1 bg-secondary/50 rounded-lg p-1">
{[
{ id: 'recent' as SortBy, icon: Clock, label: 'Nû' },
{ id: 'popular' as SortBy, icon: TrendingUp, label: 'Populer' },
{ id: 'replies' as SortBy, icon: MessageSquare, label: 'Bersiv' },
{ id: 'views' as SortBy, icon: Eye, label: 'Dîtin' },
].map(({ id, icon: Icon, label }) => (
<button
key={id}
onClick={() => {
hapticImpact('light');
setSortBy(id);
}}
className={cn(
'flex-1 flex items-center justify-center gap-1 py-2 rounded-md text-xs transition-colors',
sortBy === id ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground'
)}
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
</header>
<div className="flex-1 overflow-y-auto hide-scrollbar">
{/* Announcements */}
{announcements.length > 0 && (
<div className="p-4 space-y-2">
{announcements.map((announcement) => {
const style = getAnnouncementStyle(announcement.type);
const Icon = style.icon;
return (
<div key={announcement.id} className={`rounded-xl p-3 border ${style.bgClass}`}>
<div className="flex items-start gap-3">
<Megaphone className="w-5 h-5 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm">{announcement.title}</h4>
<p className="text-xs mt-1 opacity-90 line-clamp-2">{announcement.content}</p>
</div>
<Icon className="w-4 h-4 flex-shrink-0" />
</div>
</div>
);
})}
</div>
)}
{/* Categories */}
{categories.length > 0 && (
<div className="px-4 pb-3">
<div className="flex gap-2 overflow-x-auto hide-scrollbar py-1">
<button
onClick={() => {
hapticImpact('light');
setFilterCategory('all');
}}
className={cn(
'flex-shrink-0 px-3 py-1.5 rounded-full text-xs transition-colors',
filterCategory === 'all'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground'
)}
>
Hemû
</button>
{categories.map((category) => (
<button
key={category.id}
onClick={() => {
hapticImpact('light');
setFilterCategory(category.name.toLowerCase());
}}
className={cn(
'flex-shrink-0 px-3 py-1.5 rounded-full text-xs transition-colors flex items-center gap-1',
filterCategory === category.name.toLowerCase()
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground'
)}
>
<span>{category.icon}</span>
{category.name}
</button>
))}
</div>
</div>
)}
{/* Discussions List */}
{loading ? (
<div className="p-4 space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-secondary/50 rounded-xl p-4 animate-pulse">
<div className="h-4 bg-secondary rounded w-3/4 mb-2" />
<div className="h-3 bg-secondary rounded w-1/2" />
</div>
))}
</div>
) : filteredDiscussions.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 p-8 text-center">
<MessageCircle className="w-12 h-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">Mijar nehat dîtin</p>
<p className="text-sm text-muted-foreground/70 mb-4">Filterên xwe biguhêre</p>
<button
onClick={handleOpenCreate}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm"
>
<Plus className="w-4 h-4" />
Mijara Biafirîne
</button>
</div>
) : (
<div className="p-4 space-y-3">
{filteredDiscussions.map((discussion) => (
<button
key={discussion.id}
onClick={() => handleOpenThread(discussion)}
className="w-full text-left bg-secondary/30 rounded-xl p-4 border border-border/50 hover:bg-secondary/50 transition-colors"
>
{/* Badges */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
{discussion.is_pinned && (
<span className="inline-flex items-center gap-1 text-[10px] bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded">
<Pin className="w-2.5 h-2.5" />
Pinned
</span>
)}
{discussion.is_locked && (
<span className="inline-flex items-center gap-1 text-[10px] bg-red-500/20 text-red-400 px-1.5 py-0.5 rounded">
<Lock className="w-2.5 h-2.5" />
</span>
)}
{discussion.category && (
<span className="text-[10px] bg-secondary text-muted-foreground px-1.5 py-0.5 rounded">
{discussion.category.icon} {discussion.category.name}
</span>
)}
{(discussion.upvotes || 0) > 10 && (
<span className="inline-flex items-center gap-1 text-[10px] bg-orange-500/20 text-orange-400 px-1.5 py-0.5 rounded">
<Flame className="w-2.5 h-2.5" />
Trending
</span>
)}
</div>
{/* Image thumbnail if exists */}
{discussion.image_url && (
<div className="mb-2 rounded-lg overflow-hidden bg-secondary/50">
<img
src={discussion.image_url}
alt=""
className="w-full h-auto object-contain max-h-40"
/>
</div>
)}
{/* Title */}
<h3 className="font-medium text-foreground mb-1 line-clamp-2">
{discussion.title}
</h3>
{/* Meta */}
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
<span>{discussion.author_name || 'Anonymous'}</span>
<span>
{formatDistanceToNow(new Date(discussion.last_activity_at), {
addSuffix: true,
})}
</span>
<span className="flex items-center gap-1">
<ThumbsUp className="w-3 h-3" />
{(discussion.upvotes || 0) - (discussion.downvotes || 0)}
</span>
<span className="flex items-center gap-1">
<MessageSquare className="w-3 h-3" />
{discussion.replies_count}
</span>
<span className="flex items-center gap-1">
<Eye className="w-3 h-3" />
{discussion.views_count}
</span>
</div>
{/* Tags */}
{discussion.tags && discussion.tags.length > 0 && (
<div className="flex gap-1 mt-2 flex-wrap">
{discussion.tags.slice(0, 3).map((tag) => (
<span key={tag} className="text-[10px] text-cyan-400">
#{tag}
</span>
))}
{discussion.tags.length > 3 && (
<span className="text-[10px] text-muted-foreground">
+{discussion.tags.length - 3}
</span>
)}
</div>
)}
</button>
))}
</div>
)}
</div>
</div>
);
}
+499
View File
@@ -0,0 +1,499 @@
/**
* Rewards Section - Referral System
* Uses real blockchain data from ReferralContext
*/
import { useState, useEffect, useCallback } from 'react';
import {
Gift,
Users,
Trophy,
Copy,
Check,
Share2,
RefreshCw,
Star,
TrendingUp,
Award,
Zap,
Coins,
} from 'lucide-react';
import { cn, formatAddress } from '@/lib/utils';
import { useTelegram } from '@/hooks/useTelegram';
import { useAuth } from '@/contexts/AuthContext';
import { useReferral } from '@/contexts/ReferralContext';
import { useWallet } from '@/contexts/WalletContext';
import { SocialLinks } from '@/components/SocialLinks';
// Activity tracking constants
const ACTIVITY_STORAGE_KEY = 'pezkuwi_last_active';
const ACTIVITY_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
export function RewardsSection() {
const { hapticImpact, hapticNotification, shareUrl, showAlert } = useTelegram();
const { user: authUser } = useAuth();
const { stats, myReferrals, loading, refreshStats } = useReferral();
const { isConnected } = useWallet();
const [copied, setCopied] = useState(false);
const [activeTab, setActiveTab] = useState<'overview' | 'referrals'>('overview');
const [isActive, setIsActive] = useState(false);
const [timeRemaining, setTimeRemaining] = useState<string | null>(null);
// Check activity status
const checkActivityStatus = useCallback(() => {
const lastActive = localStorage.getItem(ACTIVITY_STORAGE_KEY);
if (lastActive) {
const lastActiveTime = parseInt(lastActive, 10);
const now = Date.now();
const elapsed = now - lastActiveTime;
if (elapsed < ACTIVITY_DURATION_MS) {
setIsActive(true);
const remaining = ACTIVITY_DURATION_MS - elapsed;
const hours = Math.floor(remaining / (60 * 60 * 1000));
const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000));
setTimeRemaining(`${hours}s ${minutes}d`);
} else {
setIsActive(false);
setTimeRemaining(null);
}
} else {
setIsActive(false);
setTimeRemaining(null);
}
}, []);
// Check activity status on mount and every minute
useEffect(() => {
// Run check after a microtask to avoid synchronous setState in effect
const timeoutId = setTimeout(checkActivityStatus, 0);
const interval = setInterval(checkActivityStatus, 60000);
return () => {
clearTimeout(timeoutId);
clearInterval(interval);
};
}, [checkActivityStatus]);
const handleActivate = () => {
hapticNotification('success');
localStorage.setItem(ACTIVITY_STORAGE_KEY, Date.now().toString());
setIsActive(true);
setTimeRemaining('24s 0d');
showAlert('Tu niha aktîv î! 24 saet paşê dîsa bikirtîne.');
};
// Telegram referral link (for sharing) - use authenticated user ID
const referralLink = authUser?.telegram_id
? `https://t.me/pezkuwichain_bot?start=ref_${authUser.telegram_id}`
: 'https://t.me/pezkuwichain_bot';
const handleCopy = async () => {
try {
await window.navigator.clipboard.writeText(referralLink);
setCopied(true);
hapticNotification('success');
setTimeout(() => setCopied(false), 2000);
} catch {
showAlert('Kopî bû');
}
};
const handleShare = () => {
hapticImpact('medium');
shareUrl(
referralLink,
'Pezkuwichain - Dewleta Dîjîtal a Kurd! Bi lînka min ve tev li me bibe:'
);
};
const handleRefresh = () => {
hapticImpact('medium');
refreshStats();
};
// Calculate points per referral based on position
const getPointsForPosition = (position: number): number => {
if (position <= 10) return 10;
if (position <= 50) return 5;
if (position <= 100) return 4;
return 0;
};
if (!isConnected) {
return (
<div className="flex flex-col h-full items-center justify-center p-8 text-center">
<Gift className="w-16 h-16 text-purple-400 mb-4" />
<h2 className="text-xl font-semibold mb-2">Xelat - Referral System</h2>
<p className="text-muted-foreground mb-4">
Ji bo dîtina referral û xelatên xwe, berî her tiştî cîzdanê xwe girêde.
</p>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<header className="flex-shrink-0 px-4 py-3 border-b border-border bg-background/80 backdrop-blur-sm safe-area-top">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Gift className="w-4 h-4 text-purple-400" />
</div>
<h1 className="text-lg font-semibold">Xelat</h1>
</div>
<button
onClick={handleRefresh}
disabled={loading}
className="p-2 rounded-lg hover:bg-secondary"
>
<RefreshCw
className={`w-5 h-5 text-muted-foreground ${loading ? 'animate-spin' : ''}`}
/>
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 bg-secondary/50 rounded-lg p-1">
{[
{ id: 'overview' as const, label: 'Geşbîn' },
{ id: 'referrals' as const, label: 'Referral' },
].map(({ id, label }) => (
<button
key={id}
onClick={() => {
hapticImpact('light');
setActiveTab(id);
}}
className={cn(
'flex-1 py-2 rounded-md text-sm transition-colors',
activeTab === id
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground'
)}
>
{label}
</button>
))}
</div>
</header>
<div className="flex-1 overflow-y-auto hide-scrollbar">
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="p-4 space-y-4">
{loading ? (
<div className="grid grid-cols-2 gap-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-secondary/50 rounded-xl p-4 animate-pulse">
<div className="h-4 bg-secondary rounded w-1/2 mb-2" />
<div className="h-6 bg-secondary rounded w-3/4" />
</div>
))}
</div>
) : (
<>
{/* Score Card */}
<div className="bg-gradient-to-br from-purple-600 to-pink-600 rounded-2xl p-4 text-white">
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-purple-100 text-sm">Pûana Referral</p>
<p className="text-4xl font-bold">{stats?.referralScore ?? 0}</p>
</div>
<div className="w-16 h-16 rounded-full bg-white/20 flex items-center justify-center">
<Trophy className="w-8 h-8" />
</div>
</div>
<div className="flex items-center gap-2 text-sm text-purple-100">
<TrendingUp className="w-4 h-4" />
<span>Max pûan: 500</span>
</div>
</div>
{/* Refer More Card */}
<div className="bg-gradient-to-r from-amber-500/20 to-orange-500/20 border border-amber-500/30 rounded-xl p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-amber-500/30 flex items-center justify-center flex-shrink-0">
<Coins className="w-5 h-5 text-amber-400" />
</div>
<div>
<h4 className="font-semibold text-amber-100 mb-1">
زیاتر ڕیفەر بکە، زیاتر قازانج بکە!
</h4>
<p className="text-sm text-amber-200/80">
هەر کەسێک بهێنیت، HEZ و PEZ وەک خەڵات وەردەگریت. زیاتر ڕیفەر = زیاتر خەڵات!
</p>
</div>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
<div className="flex items-center gap-2 mb-2">
<Users className="w-4 h-4 text-green-400" />
<span className="text-xs text-muted-foreground">Referral</span>
</div>
<p className="text-2xl font-bold text-foreground">
{stats?.referralCount ?? 0}
</p>
<p className="text-xs text-muted-foreground">KYC pejirandî</p>
</div>
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
<div className="flex items-center gap-2 mb-2">
<Award className="w-4 h-4 text-blue-400" />
<span className="text-xs text-muted-foreground">Referrer</span>
</div>
{stats?.whoInvitedMe ? (
<p className="text-sm font-mono text-foreground truncate">
{formatAddress(stats.whoInvitedMe, 6)}
</p>
) : (
<p className="text-sm text-muted-foreground">Tune</p>
)}
<p className="text-xs text-muted-foreground">Min vexwand</p>
</div>
</div>
{/* Pending Referral Notification */}
{stats?.pendingReferral && (
<div className="bg-blue-900/20 border border-blue-600/30 rounded-xl p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-600/30 flex items-center justify-center">
<Award className="w-5 h-5 text-blue-400" />
</div>
<div className="flex-1">
<div className="text-white font-semibold">Referral li bendê</div>
<div className="text-sm text-blue-300">
KYC temam bike ji bo pejirandina referral ji{' '}
<span className="font-mono">
{formatAddress(stats.pendingReferral, 6)}
</span>
</div>
</div>
</div>
</div>
)}
{/* Invite Card */}
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
<h3 className="font-medium text-foreground mb-3 flex items-center gap-2">
<Share2 className="w-4 h-4 text-primary" />
Hevalên xwe vexwîne
</h3>
<div className="bg-background rounded-lg p-3 mb-3">
<p className="text-xs text-muted-foreground mb-1">Lînka te</p>
<code className="text-sm text-foreground break-all">{referralLink}</code>
</div>
<div className="flex gap-2">
<button
onClick={handleCopy}
className={cn(
'flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-medium transition-colors',
copied
? 'bg-green-500/20 text-green-400'
: 'bg-secondary hover:bg-secondary/80'
)}
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? 'Kopî bû!' : 'Kopî bike'}
</button>
<button
onClick={handleShare}
className="flex-1 flex items-center justify-center gap-2 py-2.5 bg-primary rounded-lg text-primary-foreground text-sm font-medium"
>
<Share2 className="w-4 h-4" />
Parve bike
</button>
</div>
</div>
{/* Score System */}
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
<h3 className="font-medium text-foreground mb-3 flex items-center gap-2">
<Star className="w-4 h-4 text-yellow-400" />
Sîstema pûanan
</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between py-2 border-b border-border/30">
<span className="text-muted-foreground">1-10 referral</span>
<span className="text-green-400 font-medium">×10 pûan</span>
</div>
<div className="flex justify-between py-2 border-b border-border/30">
<span className="text-muted-foreground">11-50 referral</span>
<span className="text-green-400 font-medium">100 + ×5</span>
</div>
<div className="flex justify-between py-2 border-b border-border/30">
<span className="text-muted-foreground">51-100 referral</span>
<span className="text-green-400 font-medium">300 + ×4</span>
</div>
<div className="flex justify-between py-2">
<span className="text-muted-foreground">101+ referral</span>
<span className="text-yellow-400 font-medium">500 (Max)</span>
</div>
</div>
</div>
{/* I am Active Button */}
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Zap
className={cn('w-5 h-5', isActive ? 'text-green-400' : 'text-gray-400')}
/>
<div>
<h3 className="font-medium text-foreground">Rewşa Aktîvbûnê</h3>
{isActive && timeRemaining && (
<p className="text-xs text-green-400">Dem: {timeRemaining} maye</p>
)}
</div>
</div>
<div
className={cn(
'px-2 py-1 rounded-full text-xs font-medium',
isActive ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
)}
>
{isActive ? 'Aktîv' : 'Ne Aktîv'}
</div>
</div>
<p className="text-sm text-muted-foreground mb-3">
Her 24 saet carekê bikirtîne da ku aktîv bimînî û xelatên zêdetir qezenc bikî!
</p>
<button
onClick={handleActivate}
disabled={isActive}
className={cn(
'w-full py-3 rounded-lg font-medium flex items-center justify-center gap-2 transition-all',
isActive
? 'bg-green-500/20 text-green-400 cursor-not-allowed'
: 'bg-gradient-to-r from-green-500 to-emerald-600 text-white hover:opacity-90'
)}
>
<Zap className="w-5 h-5" />
{isActive ? 'Tu Aktîv î!' : 'Ez Aktîv im!'}
</button>
</div>
{/* Social Links */}
<SocialLinks />
</>
)}
</div>
)}
{/* Referrals Tab */}
{activeTab === 'referrals' && (
<div className="p-4 space-y-4">
{/* Refer More Card */}
<div className="bg-gradient-to-r from-amber-500/20 to-orange-500/20 border border-amber-500/30 rounded-xl p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-amber-500/30 flex items-center justify-center flex-shrink-0">
<Coins className="w-5 h-5 text-amber-400" />
</div>
<div>
<h4 className="font-semibold text-amber-100 mb-1">
زیاتر ڕیفەر بکە، زیاتر قازانج بکە!
</h4>
<p className="text-sm text-amber-200/80">
هەر کەسێک بهێنیت، HEZ و PEZ وەک خەڵات وەردەگریت. زیاتر ڕیفەر = زیاتر خەڵات!
</p>
</div>
</div>
</div>
{/* I am Active Button */}
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Zap className={cn('w-5 h-5', isActive ? 'text-green-400' : 'text-gray-400')} />
<div>
<h3 className="font-medium text-foreground">Rewşa Aktîvbûnê</h3>
{isActive && timeRemaining && (
<p className="text-xs text-green-400">Dem: {timeRemaining} maye</p>
)}
</div>
</div>
<div
className={cn(
'px-2 py-1 rounded-full text-xs font-medium',
isActive ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
)}
>
{isActive ? 'Aktîv' : 'Ne Aktîv'}
</div>
</div>
<button
onClick={handleActivate}
disabled={isActive}
className={cn(
'w-full py-3 rounded-lg font-medium flex items-center justify-center gap-2 transition-all',
isActive
? 'bg-green-500/20 text-green-400 cursor-not-allowed'
: 'bg-gradient-to-r from-green-500 to-emerald-600 text-white hover:opacity-90'
)}
>
<Zap className="w-5 h-5" />
{isActive ? 'Tu Aktîv î!' : 'Ez Aktîv im!'}
</button>
</div>
{loading ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="bg-secondary/50 rounded-xl p-4 animate-pulse">
<div className="h-4 bg-secondary rounded w-2/3" />
</div>
))}
</div>
) : myReferrals.length > 0 ? (
<div className="space-y-3">
<div className="text-sm text-muted-foreground mb-2">
{myReferrals.length} referral (KYC pejirandî)
</div>
{myReferrals.map((referralAddress, index) => (
<div
key={referralAddress}
className="bg-secondary/30 rounded-xl p-4 border border-border/50 flex items-center gap-3"
>
<div className="w-10 h-10 rounded-full bg-green-500/20 flex items-center justify-center text-sm font-bold text-green-400">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<code className="text-sm text-foreground">
{formatAddress(referralAddress, 8)}
</code>
<p className="text-xs text-green-400">KYC Pejirandî</p>
</div>
<div className="text-right">
<span className="text-green-400 text-sm font-medium">
+{getPointsForPosition(index + 1)} pûan
</span>
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Users className="w-12 h-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground">Hêj referralên te tune ne</p>
<p className="text-sm text-muted-foreground mt-1">Lînka xwe parve bike!</p>
<button
onClick={handleShare}
className="mt-4 flex items-center gap-2 px-4 py-2 bg-primary rounded-lg text-primary-foreground text-sm font-medium"
>
<Share2 className="w-4 h-4" />
Parve bike
</button>
</div>
)}
</div>
)}
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
/**
* Wallet Section
* Main wallet interface with create, import, connect, and dashboard flows
*/
import { useState, useMemo } from 'react';
import { Wallet, AlertTriangle, RefreshCw } from 'lucide-react';
import { useWallet } from '@/contexts/WalletContext';
import { useAuth } from '@/contexts/AuthContext';
import {
WalletSetup,
WalletCreate,
WalletImport,
WalletConnect,
WalletDashboard,
} from '@/components/wallet';
import { LoadingScreen } from '@/components/LoadingScreen';
import { VersionInfo } from '@/components/VersionInfo';
type Screen = 'loading' | 'auth-error' | 'setup' | 'create' | 'import' | 'connect' | 'dashboard';
type UserScreen = 'create' | 'import' | null;
export function WalletSection() {
const { isInitialized, isConnected, hasWallet, deleteWalletData } = useWallet();
const { isAuthenticated, isLoading: authLoading, signIn } = useAuth();
const [userScreen, setUserScreen] = useState<UserScreen>(null);
// Derive screen from wallet state and user navigation
const screen = useMemo<Screen>(() => {
// Auth loading - wait
if (authLoading) return 'loading';
// Wallet not initialized yet - wait
if (!isInitialized) return 'loading';
// Auth failed - show error
if (!isAuthenticated) return 'auth-error';
// Connected - show dashboard
if (isConnected) return 'dashboard';
// User navigating to create/import
if (userScreen) return userScreen;
// Has wallet but not connected
if (hasWallet) return 'connect';
// No wallet - show setup
return 'setup';
}, [authLoading, isInitialized, isAuthenticated, isConnected, hasWallet, userScreen]);
// Handle wallet deletion
const handleDeleteWallet = () => {
deleteWalletData();
setUserScreen(null);
};
// Reset user screen when wallet state changes
const handleComplete = () => setUserScreen(null);
// Handle retry auth
const handleRetryAuth = () => {
signIn();
};
// Loading state
if (screen === 'loading') {
return (
<div className="flex flex-col h-full">
<Header />
<div className="flex-1 flex items-center justify-center">
<LoadingScreen />
</div>
</div>
);
}
// Auth error state
if (screen === 'auth-error') {
return (
<div className="flex flex-col h-full">
<Header />
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center space-y-6">
<div className="w-16 h-16 mx-auto bg-red-500/20 rounded-full flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-red-400" />
</div>
<div>
<h2 className="text-xl font-semibold mb-2">Têketin Têk Çû</h2>
<p className="text-muted-foreground text-sm">
Ji kerema xwe piştrast bikin ku hûn app-ê di nav Telegram de vedikin
</p>
</div>
<button
onClick={handleRetryAuth}
className="px-6 py-3 bg-primary text-primary-foreground rounded-xl font-semibold flex items-center gap-2 mx-auto"
>
<RefreshCw className="w-4 h-4" />
Dîsa Biceribîne
</button>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
<Header />
<div className="flex-1 overflow-y-auto">
{screen === 'setup' && (
<WalletSetup
onCreate={() => setUserScreen('create')}
onImport={() => setUserScreen('import')}
/>
)}
{screen === 'create' && (
<WalletCreate onComplete={handleComplete} onBack={() => setUserScreen(null)} />
)}
{screen === 'import' && (
<WalletImport onComplete={handleComplete} onBack={() => setUserScreen(null)} />
)}
{screen === 'connect' && (
<WalletConnect onConnected={handleComplete} onDelete={handleDeleteWallet} />
)}
{screen === 'dashboard' && <WalletDashboard onDisconnect={handleComplete} />}
</div>
</div>
);
}
// Header component
function Header() {
return (
<header className="flex-shrink-0 px-4 py-3 border-b border-border bg-background/80 backdrop-blur-sm safe-area-top">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-cyan-500/20 flex items-center justify-center">
<Wallet className="w-4 h-4 text-cyan-400" />
</div>
<h1 className="text-lg font-semibold">Berîk</h1>
</div>
<VersionInfo />
</div>
</header>
);
}
+42
View File
@@ -0,0 +1,42 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// Mock Telegram WebApp
Object.defineProperty(window, 'Telegram', {
value: {
WebApp: {
ready: vi.fn(),
expand: vi.fn(),
close: vi.fn(),
setHeaderColor: vi.fn(),
setBackgroundColor: vi.fn(),
showAlert: vi.fn(),
showConfirm: vi.fn(),
initData: '',
initDataUnsafe: {
user: {
id: 123456789,
first_name: 'Test',
last_name: 'User',
username: 'testuser',
language_code: 'ku',
},
},
HapticFeedback: {
impactOccurred: vi.fn(),
notificationOccurred: vi.fn(),
selectionChanged: vi.fn(),
},
openLink: vi.fn(),
openTelegramLink: vi.fn(),
version: '7.0',
platform: 'web',
themeParams: {},
},
},
writable: true,
});
// Mock environment variables
vi.stubEnv('VITE_SUPABASE_URL', 'https://test.supabase.co');
vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-key');
+205
View File
@@ -0,0 +1,205 @@
/**
* Database types for Supabase tables
* These types match the actual database schema
*/
// ==================== USERS ====================
export interface DbUser {
id: string;
telegram_id: number;
username: string | null;
first_name: string;
last_name: string | null;
photo_url: string | null;
is_admin: boolean;
created_at: string;
updated_at: string;
}
// ==================== ANNOUNCEMENTS ====================
export interface DbAnnouncement {
id: string;
title: string;
content: string;
image_url: string | null;
link_url: string | null;
author_id: string;
is_published: boolean;
likes: number;
dislikes: number;
views: number;
created_at: string;
updated_at: string;
}
export interface DbAnnouncementWithAuthor extends DbAnnouncement {
author: DbAuthorInfo | null;
}
export interface DbAnnouncementReaction {
id: string;
announcement_id: string;
user_id: string;
reaction: 'like' | 'dislike';
created_at: string;
}
// ==================== FORUM ====================
export interface DbThread {
id: string;
title: string;
content: string;
author_id: string;
reply_count: number;
likes: number;
views: number;
last_activity: string;
created_at: string;
updated_at: string;
}
export interface DbThreadWithAuthor extends DbThread {
author: DbAuthorInfo | null;
}
export interface DbReply {
id: string;
thread_id: string;
content: string;
author_id: string;
likes: number;
created_at: string;
updated_at: string;
}
export interface DbReplyWithAuthor extends DbReply {
author: DbAuthorInfo | null;
}
export interface DbThreadLike {
id: string;
thread_id: string;
user_id: string;
created_at: string;
}
export interface DbReplyLike {
id: string;
reply_id: string;
user_id: string;
created_at: string;
}
// ==================== COMMON ====================
export interface DbAuthorInfo {
username: string | null;
first_name: string;
photo_url: string | null;
}
// ==================== QUERY RESULT TYPES ====================
export interface AnnouncementCounters {
likes: number;
dislikes: number;
}
export interface ThreadCounters {
likes: number;
reply_count: number;
views: number;
}
export interface ReplyCounters {
likes: number;
}
// ==================== TYPE GUARDS ====================
export function isDbAnnouncementWithAuthor(data: unknown): data is DbAnnouncementWithAuthor {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'title' in data &&
'content' in data &&
'author_id' in data
);
}
export function isDbThreadWithAuthor(data: unknown): data is DbThreadWithAuthor {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'title' in data &&
'content' in data &&
'author_id' in data &&
'reply_count' in data
);
}
export function isDbReplyWithAuthor(data: unknown): data is DbReplyWithAuthor {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'thread_id' in data &&
'content' in data &&
'author_id' in data
);
}
// ==================== SUPABASE DATABASE SCHEMA ====================
export type Database = {
public: {
Tables: {
tg_users: {
Row: DbUser;
Insert: Omit<DbUser, 'created_at' | 'updated_at'>;
Update: Partial<Omit<DbUser, 'id' | 'created_at'>>;
};
tg_announcements: {
Row: DbAnnouncement;
Insert: Omit<
DbAnnouncement,
'id' | 'likes' | 'dislikes' | 'views' | 'created_at' | 'updated_at'
>;
Update: Partial<Omit<DbAnnouncement, 'id' | 'created_at'>>;
};
tg_announcement_reactions: {
Row: DbAnnouncementReaction;
Insert: Omit<DbAnnouncementReaction, 'id' | 'created_at'>;
Update: Partial<Omit<DbAnnouncementReaction, 'id' | 'created_at'>>;
};
tg_threads: {
Row: DbThread;
Insert: Omit<
DbThread,
'id' | 'reply_count' | 'likes' | 'views' | 'created_at' | 'updated_at'
>;
Update: Partial<Omit<DbThread, 'id' | 'created_at'>>;
};
tg_replies: {
Row: DbReply;
Insert: Omit<DbReply, 'id' | 'likes' | 'created_at' | 'updated_at'>;
Update: Partial<Omit<DbReply, 'id' | 'created_at'>>;
};
tg_thread_likes: {
Row: DbThreadLike;
Insert: Omit<DbThreadLike, 'id' | 'created_at'>;
Update: never;
};
tg_reply_likes: {
Row: DbReplyLike;
Insert: Omit<DbReplyLike, 'id' | 'created_at'>;
Update: never;
};
};
};
};
+254
View File
@@ -0,0 +1,254 @@
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
export interface Database {
public: {
Tables: {
users: {
Row: {
id: string;
telegram_id: number;
username: string | null;
first_name: string;
last_name: string | null;
photo_url: string | null;
language_code: string | null;
is_admin: boolean;
wallet_address: string | null;
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
telegram_id: number;
username?: string | null;
first_name: string;
last_name?: string | null;
photo_url?: string | null;
language_code?: string | null;
is_admin?: boolean;
wallet_address?: string | null;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
telegram_id?: number;
username?: string | null;
first_name?: string;
last_name?: string | null;
photo_url?: string | null;
language_code?: string | null;
is_admin?: boolean;
wallet_address?: string | null;
created_at?: string;
updated_at?: string;
};
};
announcements: {
Row: {
id: string;
title: string;
content: string;
image_url: string | null;
link_url: string | null;
author_id: string;
likes: number;
dislikes: number;
views: number;
is_published: boolean;
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
title: string;
content: string;
image_url?: string | null;
link_url?: string | null;
author_id: string;
likes?: number;
dislikes?: number;
views?: number;
is_published?: boolean;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
title?: string;
content?: string;
image_url?: string | null;
link_url?: string | null;
author_id?: string;
likes?: number;
dislikes?: number;
views?: number;
is_published?: boolean;
created_at?: string;
updated_at?: string;
};
};
announcement_reactions: {
Row: {
id: string;
announcement_id: string;
user_id: string;
reaction: 'like' | 'dislike';
created_at: string;
};
Insert: {
id?: string;
announcement_id: string;
user_id: string;
reaction: 'like' | 'dislike';
created_at?: string;
};
Update: {
id?: string;
announcement_id?: string;
user_id?: string;
reaction?: 'like' | 'dislike';
created_at?: string;
};
};
threads: {
Row: {
id: string;
title: string;
content: string;
author_id: string;
reply_count: number;
likes: number;
views: number;
last_activity: string;
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
title: string;
content: string;
author_id: string;
reply_count?: number;
likes?: number;
views?: number;
last_activity?: string;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
title?: string;
content?: string;
author_id?: string;
reply_count?: number;
likes?: number;
views?: number;
last_activity?: string;
created_at?: string;
updated_at?: string;
};
};
thread_likes: {
Row: {
id: string;
thread_id: string;
user_id: string;
created_at: string;
};
Insert: {
id?: string;
thread_id: string;
user_id: string;
created_at?: string;
};
Update: {
id?: string;
thread_id?: string;
user_id?: string;
created_at?: string;
};
};
replies: {
Row: {
id: string;
thread_id: string;
content: string;
author_id: string;
likes: number;
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
thread_id: string;
content: string;
author_id: string;
likes?: number;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
thread_id?: string;
content?: string;
author_id?: string;
likes?: number;
created_at?: string;
updated_at?: string;
};
};
reply_likes: {
Row: {
id: string;
reply_id: string;
user_id: string;
created_at: string;
};
Insert: {
id?: string;
reply_id: string;
user_id: string;
created_at?: string;
};
Update: {
id?: string;
reply_id?: string;
user_id?: string;
created_at?: string;
};
};
};
Views: {
[_ in never]: never;
};
Functions: {
[_ in never]: never;
};
Enums: {
[_ in never]: never;
};
};
}
// Helper types
export type User = Database['public']['Tables']['users']['Row'];
export type Announcement = Database['public']['Tables']['announcements']['Row'];
export type Thread = Database['public']['Tables']['threads']['Row'];
export type Reply = Database['public']['Tables']['replies']['Row'];
// Extended types with author info
export type AnnouncementWithAuthor = Announcement & {
author: Pick<User, 'username' | 'first_name' | 'photo_url'>;
user_reaction?: 'like' | 'dislike' | null;
};
export type ThreadWithAuthor = Thread & {
author: Pick<User, 'username' | 'first_name' | 'photo_url'>;
user_liked?: boolean;
};
export type ReplyWithAuthor = Reply & {
author: Pick<User, 'username' | 'first_name' | 'photo_url'>;
user_liked?: boolean;
};
+5
View File
@@ -0,0 +1,5 @@
{
"version": "1.0.112",
"buildTime": "2026-02-05T07:53:10.125Z",
"buildNumber": 1770277990126
}
+14
View File
@@ -0,0 +1,14 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPABASE_URL: string;
readonly VITE_SUPABASE_ANON_KEY: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
// Global version constants defined in vite.config.ts
declare const __APP_VERSION__: string;
declare const __BUILD_TIME__: string;
@@ -0,0 +1,219 @@
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers':
'authorization, x-client-info, apikey, content-type, x-supabase-client-platform',
};
interface CreateOfferRequest {
sessionToken: string;
token: 'HEZ' | 'PEZ';
amountCrypto: number;
fiatCurrency: 'TRY' | 'IQD' | 'IRR' | 'EUR' | 'USD';
fiatAmount: number;
paymentMethodId: string;
paymentDetailsEncrypted: string;
minOrderAmount?: number;
maxOrderAmount?: number;
timeLimitMinutes?: number;
adType?: 'buy' | 'sell'; // Default: 'sell'
}
// Verify session token and get telegram_id
function verifySessionToken(token: string): number | null {
try {
const decoded = atob(token);
const [telegramId, timestamp] = decoded.split(':');
const ts = parseInt(timestamp);
// Token valid for 7 days
if (Date.now() - ts > 7 * 24 * 60 * 60 * 1000) {
return null;
}
return parseInt(telegramId);
} catch {
return null;
}
}
serve(async (req) => {
// Handle CORS
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const body: CreateOfferRequest = await req.json();
const {
sessionToken,
token,
amountCrypto,
fiatCurrency,
fiatAmount,
paymentMethodId,
paymentDetailsEncrypted,
minOrderAmount,
maxOrderAmount,
timeLimitMinutes = 30,
adType = 'sell',
} = body;
// Validate session token
if (!sessionToken) {
return new Response(JSON.stringify({ error: 'Missing session token' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const telegramId = verifySessionToken(sessionToken);
if (!telegramId) {
return new Response(JSON.stringify({ error: 'Invalid or expired session' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate required fields
if (!token || !amountCrypto || !fiatCurrency || !fiatAmount || !paymentMethodId) {
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Create Supabase admin client (bypasses RLS)
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get auth user ID for this telegram user
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
const {
data: { users: authUsers },
} = await supabase.auth.admin.listUsers();
const authUser = authUsers?.find((u) => u.email === telegramEmail);
if (!authUser) {
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const userId = authUser.id;
// 1. Lock escrow from internal balance
const { data: lockResult, error: lockError } = await supabase.rpc('lock_escrow_internal', {
p_user_id: userId,
p_token: token,
p_amount: amountCrypto,
});
if (lockError) {
console.error('Lock escrow error:', lockError);
return new Response(
JSON.stringify({ error: 'Failed to lock escrow: ' + lockError.message }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Parse result
const lockResponse = typeof lockResult === 'string' ? JSON.parse(lockResult) : lockResult;
if (!lockResponse.success) {
return new Response(
JSON.stringify({ error: lockResponse.error || 'Failed to lock balance' }),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// 2. Create offer in database (using service role bypasses RLS)
const { data: offer, error: offerError } = await supabase
.from('p2p_fiat_offers')
.insert({
seller_id: userId,
seller_wallet: '', // No longer needed with internal ledger
token,
amount_crypto: amountCrypto,
fiat_currency: fiatCurrency,
fiat_amount: fiatAmount,
payment_method_id: paymentMethodId,
payment_details_encrypted: paymentDetailsEncrypted,
min_order_amount: minOrderAmount || null,
max_order_amount: maxOrderAmount || null,
time_limit_minutes: timeLimitMinutes,
status: 'open',
remaining_amount: amountCrypto,
escrow_locked_at: new Date().toISOString(),
ad_type: adType,
})
.select()
.single();
if (offerError) {
console.error('Create offer error:', offerError);
// Rollback: refund escrow
try {
await supabase.rpc('refund_escrow_internal', {
p_user_id: userId,
p_token: token,
p_amount: amountCrypto,
});
} catch (refundErr) {
console.error('Failed to refund escrow:', refundErr);
}
return new Response(
JSON.stringify({ error: 'Failed to create offer: ' + offerError.message }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// 3. Log to audit
await supabase.from('p2p_audit_log').insert({
user_id: userId,
action: 'create_offer',
entity_type: 'offer',
entity_id: offer.id,
details: {
token,
amount_crypto: amountCrypto,
fiat_currency: fiatCurrency,
fiat_amount: fiatAmount,
escrow_type: 'internal_ledger',
},
});
return new Response(
JSON.stringify({
success: true,
offer_id: offer.id,
offer,
locked_balance: lockResponse.locked_balance,
available_balance: lockResponse.available_balance,
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error:', error);
return new Response(
JSON.stringify({ error: error instanceof Error ? error.message : 'Internal server error' }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
});
+124
View File
@@ -0,0 +1,124 @@
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers':
'authorization, x-client-info, apikey, content-type, x-supabase-client-platform',
};
interface GetMyOffersRequest {
sessionToken: string;
status?: string; // Optional: filter by status ('open', 'paused', etc.)
}
// Verify session token and get telegram_id
function verifySessionToken(token: string): number | null {
try {
const decoded = atob(token);
const [telegramId, timestamp] = decoded.split(':');
const ts = parseInt(timestamp);
// Token valid for 7 days
if (Date.now() - ts > 7 * 24 * 60 * 60 * 1000) {
return null;
}
return parseInt(telegramId);
} catch {
return null;
}
}
serve(async (req) => {
// Handle CORS
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const body: GetMyOffersRequest = await req.json();
const { sessionToken, status } = body;
// Validate session token
if (!sessionToken) {
return new Response(JSON.stringify({ error: 'Missing session token' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const telegramId = verifySessionToken(sessionToken);
if (!telegramId) {
return new Response(JSON.stringify({ error: 'Invalid or expired session' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Create Supabase admin client (bypasses RLS)
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get auth user ID for this telegram user
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
const {
data: { users: authUsers },
} = await supabase.auth.admin.listUsers();
const authUser = authUsers?.find((u) => u.email === telegramEmail);
if (!authUser) {
return new Response(
JSON.stringify({ error: 'User not found. Please authenticate first.', offers: [] }),
{
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
const userId = authUser.id;
// Build query
let query = supabase
.from('p2p_fiat_offers')
.select('*')
.eq('seller_id', userId)
.order('created_at', { ascending: false });
// Apply status filter if provided
if (status) {
query = query.eq('status', status);
}
const { data: offers, error: queryError } = await query;
if (queryError) {
console.error('Query error:', queryError);
return new Response(
JSON.stringify({ error: 'Failed to fetch offers: ' + queryError.message }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
return new Response(
JSON.stringify({
success: true,
offers: offers || [],
count: offers?.length || 0,
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error:', error);
return new Response(
JSON.stringify({ error: error instanceof Error ? error.message : 'Internal server error' }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
});
@@ -0,0 +1,297 @@
// process-withdraw Edge Function
// Processes pending withdrawal requests by sending tokens from platform wallet to user wallets
// This should be called by a cron job or manually by admins
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
import { ApiPromise, WsProvider, Keyring } from 'npm:@pezkuwi/api@16.5.36';
import { cryptoWaitReady } from 'npm:@pezkuwi/util-crypto@14.0.25';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
// RPC endpoint for PezkuwiChain
const RPC_ENDPOINT = 'wss://rpc.pezkuwichain.io';
// Token decimals
const DECIMALS = 12;
// PEZ asset ID
const PEZ_ASSET_ID = 1;
// Cache API connection
let apiInstance: ApiPromise | null = null;
async function getApi(): Promise<ApiPromise> {
if (apiInstance && apiInstance.isConnected) {
return apiInstance;
}
const provider = new WsProvider(RPC_ENDPOINT);
apiInstance = await ApiPromise.create({ provider });
return apiInstance;
}
interface ProcessWithdrawRequest {
adminKey?: string; // Optional admin key for manual processing
requestId?: string; // Optional: process specific request
batchSize?: number; // How many requests to process (default: 10)
}
serve(async (req) => {
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
try {
// Parse request body
let body: ProcessWithdrawRequest = {};
try {
const text = await req.text();
if (text) {
body = JSON.parse(text);
}
} catch {
// Empty body is OK for cron triggers
}
const { requestId, batchSize = 10 } = body;
// Create Supabase service client
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const serviceClient = createClient(supabaseUrl, supabaseServiceKey);
// Get platform wallet seed phrase from environment
const escrowSeedPhrase = Deno.env.get('ESCROW_SEED_PHRASE');
if (!escrowSeedPhrase) {
console.error('ESCROW_SEED_PHRASE not configured');
return new Response(
JSON.stringify({ success: false, error: 'Platform wallet not configured' }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Initialize crypto
await cryptoWaitReady();
// Create keyring and add platform wallet
const keyring = new Keyring({ type: 'sr25519' });
const platformWallet = keyring.addFromUri(escrowSeedPhrase);
console.log(`Platform wallet address: ${platformWallet.address}`);
// Connect to blockchain
const api = await getApi();
console.log(`Connected to chain: ${api.runtimeChain.toString()}`);
// Get pending withdrawal requests
let query = serviceClient
.from('p2p_deposit_withdraw_requests')
.select('*')
.eq('request_type', 'withdraw')
.eq('status', 'pending')
.order('created_at', { ascending: true })
.limit(batchSize);
if (requestId) {
query = serviceClient
.from('p2p_deposit_withdraw_requests')
.select('*')
.eq('id', requestId)
.eq('request_type', 'withdraw')
.in('status', ['pending', 'failed']); // Allow retry of failed
}
const { data: pendingRequests, error: queryError } = await query;
if (queryError) {
console.error('Failed to query pending requests:', queryError);
return new Response(JSON.stringify({ success: false, error: 'Failed to query requests' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (!pendingRequests || pendingRequests.length === 0) {
return new Response(
JSON.stringify({ success: true, message: 'No pending withdrawal requests', processed: 0 }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
console.log(`Processing ${pendingRequests.length} withdrawal requests`);
const results: Array<{
requestId: string;
success: boolean;
txHash?: string;
error?: string;
}> = [];
for (const request of pendingRequests) {
try {
console.log(
`Processing withdrawal: ID=${request.id}, Token=${request.token}, Amount=${request.amount}, To=${request.wallet_address}`
);
// Mark as processing
await serviceClient
.from('p2p_deposit_withdraw_requests')
.update({ status: 'processing' })
.eq('id', request.id);
// Convert amount to chain units
const amountInUnits = BigInt(Math.floor(request.amount * Math.pow(10, DECIMALS)));
let tx;
if (request.token === 'HEZ') {
// Native token transfer
tx = api.tx.balances.transferKeepAlive(request.wallet_address, amountInUnits);
} else if (request.token === 'PEZ') {
// Asset transfer
tx = api.tx.assets.transfer(PEZ_ASSET_ID, request.wallet_address, amountInUnits);
} else {
throw new Error(`Unsupported token: ${request.token}`);
}
// Sign and send transaction
const txHash = await new Promise<string>((resolve, reject) => {
let resolved = false;
tx.signAndSend(platformWallet, ({ status, txHash, dispatchError }) => {
if (resolved) return;
if (dispatchError) {
resolved = true;
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`));
} else {
reject(new Error(dispatchError.toString()));
}
return;
}
if (status.isInBlock || status.isFinalized) {
resolved = true;
resolve(txHash.toHex());
}
}).catch(reject);
// Timeout after 60 seconds
setTimeout(() => {
if (!resolved) {
resolved = true;
reject(new Error('Transaction timeout'));
}
}, 60000);
});
console.log(`Withdrawal TX sent: ${txHash}`);
// Update user's locked balance (reduce locked, track withdrawal)
const { error: balanceError } = await serviceClient.rpc('complete_withdraw', {
p_user_id: request.user_id,
p_token: request.token,
p_amount: request.amount,
p_tx_hash: txHash,
p_request_id: request.id,
});
// If complete_withdraw function doesn't exist, manually update
if (balanceError && balanceError.message.includes('does not exist')) {
// Manual update
await serviceClient
.from('user_internal_balances')
.update({
locked_balance: serviceClient.raw(`locked_balance - ${request.amount}`),
total_withdrawn: serviceClient.raw(`total_withdrawn + ${request.amount}`),
last_withdraw_at: new Date().toISOString(),
})
.eq('user_id', request.user_id)
.eq('token', request.token);
// Log transaction
await serviceClient.from('p2p_balance_transactions').insert({
user_id: request.user_id,
token: request.token,
transaction_type: 'withdraw',
amount: -request.amount,
balance_before: 0, // We don't have this info here
balance_after: 0, // We don't have this info here
reference_type: 'withdraw_request',
reference_id: request.id,
description: `Withdrawal to ${request.wallet_address}: ${txHash}`,
});
}
// Update request status to completed
await serviceClient
.from('p2p_deposit_withdraw_requests')
.update({
status: 'completed',
blockchain_tx_hash: txHash,
processed_at: new Date().toISOString(),
})
.eq('id', request.id);
results.push({
requestId: request.id,
success: true,
txHash,
});
console.log(`Withdrawal completed: ${request.id} -> ${txHash}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error(`Withdrawal failed for ${request.id}:`, errorMessage);
// Update request status to failed
await serviceClient
.from('p2p_deposit_withdraw_requests')
.update({
status: 'failed',
error_message: errorMessage,
processed_at: new Date().toISOString(),
})
.eq('id', request.id);
// Don't refund locked balance on failure - admin should review
// They can manually retry or refund
results.push({
requestId: request.id,
success: false,
error: errorMessage,
});
}
}
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
return new Response(
JSON.stringify({
success: true,
message: `Processed ${results.length} requests: ${successCount} succeeded, ${failCount} failed`,
processed: results.length,
succeeded: successCount,
failed: failCount,
results,
}),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Process withdraw error:', error);
return new Response(
JSON.stringify({
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
}),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
});
+313
View File
@@ -0,0 +1,313 @@
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
import { createHmac } from 'https://deno.land/std@0.177.0/node/crypto.ts';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers':
'authorization, x-client-info, apikey, content-type, x-supabase-client-platform',
};
interface TelegramUser {
id: number;
first_name: string;
last_name?: string;
username?: string;
photo_url?: string;
language_code?: string;
}
function validateInitData(initData: string, botToken: string): TelegramUser | null {
try {
const params = new URLSearchParams(initData);
const hash = params.get('hash');
if (!hash) return null;
params.delete('hash');
// Sort parameters
const sortedParams = Array.from(params.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${value}`)
.join('\n');
// Validate hash
const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest();
const calculatedHash = createHmac('sha256', secretKey).update(sortedParams).digest('hex');
if (calculatedHash !== hash) {
console.error('Hash mismatch');
return null;
}
// Check auth_date (allow 24 hours)
const authDate = parseInt(params.get('auth_date') || '0');
const now = Math.floor(Date.now() / 1000);
if (now - authDate > 86400) {
console.error('Auth data expired');
return null;
}
// Parse user data
const userStr = params.get('user');
if (!userStr) return null;
return JSON.parse(userStr) as TelegramUser;
} catch (e) {
console.error('Validation error:', e);
return null;
}
}
// Generate session token
function generateSessionToken(telegramId: number): string {
const payload = `${telegramId}:${Date.now()}:${crypto.randomUUID()}`;
return btoa(payload);
}
// Verify session token
function verifySessionToken(token: string): number | null {
try {
const decoded = atob(token);
const [telegramId, timestamp] = decoded.split(':');
const ts = parseInt(timestamp);
// Token valid for 7 days
if (Date.now() - ts > 7 * 24 * 60 * 60 * 1000) {
return null;
}
return parseInt(telegramId);
} catch {
return null;
}
}
serve(async (req) => {
// Handle CORS
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const body = await req.json();
const { initData, telegram_id, from_miniapp, wallet_address, sessionToken } = body;
// Create Supabase admin client
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
let telegramUser: TelegramUser | null = null;
// Method 1: Session token verification
if (sessionToken) {
const tgId = verifySessionToken(sessionToken);
if (!tgId) {
return new Response(JSON.stringify({ error: 'Invalid or expired session' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Get user by telegram_id
const { data: userData } = await supabase
.from('users')
.select('*')
.eq('telegram_id', tgId)
.single();
if (!userData) {
return new Response(JSON.stringify({ error: 'User not found' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Get auth user ID for P2P operations
const telegramEmail = `telegram_${tgId}@pezkuwichain.io`;
const {
data: { users: authUsers },
} = await supabase.auth.admin.listUsers();
const authUser = authUsers?.find((u) => u.email === telegramEmail);
return new Response(
JSON.stringify({
user: userData,
session_token: generateSessionToken(tgId),
auth_user_id: authUser?.id || null,
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Method 2: Mini-app redirect (telegram_id from URL params)
if (from_miniapp && telegram_id) {
// For mini-app redirects, we trust the telegram_id since it comes from our own mini-app
// The user was already authenticated in the mini-app context
// Check if user exists
const { data: existingUser } = await supabase
.from('users')
.select('*')
.eq('telegram_id', telegram_id)
.single();
if (!existingUser) {
return new Response(
JSON.stringify({ error: 'User not found. Please use the main app first.' }),
{
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Update wallet if provided
if (wallet_address && wallet_address !== existingUser.wallet_address) {
await supabase.from('users').update({ wallet_address }).eq('telegram_id', telegram_id);
existingUser.wallet_address = wallet_address;
}
// Get auth user ID for P2P operations
const telegramEmail = `telegram_${telegram_id}@pezkuwichain.io`;
const {
data: { users: authUsers },
} = await supabase.auth.admin.listUsers();
const authUser = authUsers?.find((u) => u.email === telegramEmail);
return new Response(
JSON.stringify({
user: existingUser,
session_token: generateSessionToken(telegram_id),
auth_user_id: authUser?.id || null,
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Method 3: Full Telegram WebApp initData verification
if (!initData) {
return new Response(JSON.stringify({ error: 'Missing authentication data' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const botToken = Deno.env.get('TELEGRAM_BOT_TOKEN');
if (!botToken) {
console.error('TELEGRAM_BOT_TOKEN not set');
return new Response(JSON.stringify({ error: 'Server configuration error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate Telegram data
telegramUser = validateInitData(initData, botToken);
if (!telegramUser) {
return new Response(JSON.stringify({ error: 'Invalid Telegram data' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Supabase client already created above
// Check if user exists
const { data: existingUser } = await supabase
.from('users')
.select('id')
.eq('telegram_id', telegramUser.id)
.single();
let userId: string;
if (existingUser) {
// Update existing user
userId = existingUser.id;
await supabase
.from('users')
.update({
username: telegramUser.username,
first_name: telegramUser.first_name,
last_name: telegramUser.last_name,
photo_url: telegramUser.photo_url,
language_code: telegramUser.language_code,
})
.eq('id', userId);
} else {
// Create new user
const { data: newUser, error: createError } = await supabase
.from('users')
.insert({
telegram_id: telegramUser.id,
username: telegramUser.username,
first_name: telegramUser.first_name,
last_name: telegramUser.last_name,
photo_url: telegramUser.photo_url,
language_code: telegramUser.language_code || 'ku',
})
.select('id')
.single();
if (createError) {
console.error('Error creating user:', createError);
return new Response(JSON.stringify({ error: 'Failed to create user' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
userId = newUser.id;
}
// Get or create auth user for P2P operations
const telegramEmail = `telegram_${telegramUser.id}@pezkuwichain.io`;
let authUserId: string | null = null;
// Try to get existing auth user
const {
data: { users: existingAuthUsers },
} = await supabase.auth.admin.listUsers();
const existingAuthUser = existingAuthUsers?.find((u) => u.email === telegramEmail);
if (existingAuthUser) {
authUserId = existingAuthUser.id;
} else {
// Create auth user
const { data: authData, error: signInError } = await supabase.auth.admin.createUser({
email: telegramEmail,
email_confirm: true,
user_metadata: {
telegram_id: telegramUser.id,
username: telegramUser.username,
first_name: telegramUser.first_name,
photo_url: telegramUser.photo_url,
},
});
if (!signInError && authData?.user) {
authUserId = authData.user.id;
}
}
// Get the user data
const { data: userData } = await supabase.from('users').select('*').eq('id', userId).single();
return new Response(
JSON.stringify({
user: userData,
telegram_user: telegramUser,
session_token: generateSessionToken(telegramUser.id),
auth_user_id: authUserId, // For P2P balance queries
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
});
+217
View File
@@ -0,0 +1,217 @@
/**
* PezkuwiChain Telegram Bot - Supabase Edge Function
* Handles webhook updates from Telegram
*/
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
const BOT_TOKEN = Deno.env.get('TELEGRAM_BOT_TOKEN') || '';
const MINI_APP_URL = 'https://telegram.pezkuwichain.io';
// Welcome image URL (hosted on GitHub)
const WELCOME_IMAGE_URL =
'https://raw.githubusercontent.com/pezkuwichain/pezkuwi-telegram-miniapp/main/public/images/welcome.png';
const WELCOME_MESSAGE = `
🌍 <b>Welcome to PezkuwiChain!</b>
The first blockchain platform connecting Kurds worldwide — building a digital Kurdistan where borders don't limit our unity.
🔗 <b>One Chain. One Nation. One Future.</b>
Join millions of Kurds in creating a decentralized digital economy. Your wallet, your identity, your freedom.
<i>Bi hev re, em dikarin.</i>
<i>Together, we can.</i>
`;
interface TelegramUpdate {
update_id: number;
message?: {
message_id: number;
from: {
id: number;
first_name: string;
username?: string;
};
chat: {
id: number;
type: string;
};
text?: string;
};
callback_query?: {
id: string;
from: {
id: number;
};
data: string;
};
}
// Send message via Telegram API
async function sendTelegramRequest(method: string, body: Record<string, unknown>) {
console.log(`[Telegram] Calling ${method}`, JSON.stringify(body));
if (!BOT_TOKEN) {
console.error('[Telegram] BOT_TOKEN is not set!');
return { ok: false, error: 'BOT_TOKEN not configured' };
}
const response = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/${method}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const result = await response.json();
console.log(`[Telegram] Response:`, JSON.stringify(result));
return result;
}
// Send welcome message with photo
async function sendWelcomeMessage(chatId: number) {
const keyboard = {
inline_keyboard: [
[
{
text: '📱 Open App on Telegram',
web_app: { url: MINI_APP_URL },
},
],
[
{
text: '🤖 Play Store (Coming Soon)',
callback_data: 'playstore_coming_soon',
},
],
],
};
// Try to send photo with caption
if (WELCOME_IMAGE_URL) {
const result = await sendTelegramRequest('sendPhoto', {
chat_id: chatId,
photo: WELCOME_IMAGE_URL,
caption: WELCOME_MESSAGE,
parse_mode: 'HTML',
reply_markup: keyboard,
});
if (result.ok) return;
console.log('[Bot] Photo failed, falling back to text');
}
// Send text-only message
await sendTelegramRequest('sendMessage', {
chat_id: chatId,
text: WELCOME_MESSAGE,
parse_mode: 'HTML',
reply_markup: keyboard,
});
}
// Handle callback query (button clicks)
async function handleCallbackQuery(callbackQueryId: string, data: string) {
if (data === 'playstore_coming_soon') {
await sendTelegramRequest('answerCallbackQuery', {
callback_query_id: callbackQueryId,
text: '🚀 Android app coming soon! Stay tuned.',
show_alert: true,
});
}
}
// Send help message
async function sendHelpMessage(chatId: number) {
const helpText = `
<b>PezkuwiChain Bot Commands:</b>
/start - Show welcome message
/help - Show this help message
/app - Open the PezkuwiChain app
<b>Links:</b>
🌐 Website: pezkuwichain.io
📱 App: t.me/pezkuwichain_bot/app
🐦 Twitter: @pezkuwichain
`;
await sendTelegramRequest('sendMessage', {
chat_id: chatId,
text: helpText,
parse_mode: 'HTML',
});
}
// Send app link
async function sendAppLink(chatId: number) {
const keyboard = {
inline_keyboard: [
[
{
text: '📱 Open PezkuwiChain App',
web_app: { url: MINI_APP_URL },
},
],
],
};
await sendTelegramRequest('sendMessage', {
chat_id: chatId,
text: 'Click below to open the app:',
reply_markup: keyboard,
});
}
serve(async (req: Request) => {
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
// Only accept POST requests
if (req.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
try {
const update: TelegramUpdate = await req.json();
console.log('[Bot] Received update:', JSON.stringify(update));
// Handle message
if (update.message?.text) {
const chatId = update.message.chat.id;
const text = update.message.text;
if (text === '/start' || text.startsWith('/start ')) {
await sendWelcomeMessage(chatId);
} else if (text === '/help') {
await sendHelpMessage(chatId);
} else if (text === '/app') {
await sendAppLink(chatId);
}
}
// Handle callback query (button clicks)
if (update.callback_query) {
await handleCallbackQuery(update.callback_query.id, update.callback_query.data);
}
return new Response(JSON.stringify({ ok: true }), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Error processing update:', error);
return new Response(JSON.stringify({ ok: false, error: String(error) }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
});
@@ -0,0 +1,508 @@
// verify-deposit-telegram Edge Function
// For Telegram MiniApp users - uses session token authentication
// Verifies blockchain transactions before crediting balances
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
import { ApiPromise, WsProvider } from 'npm:@pezkuwi/api@16.5.36';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
// Platform hot wallet address (PRODUCTION) - Treasury_3
const PLATFORM_WALLET = '5H18ZZBU4LwPYbeEZ1JBGvibCU2edhhM8HNUtFi7GgC36CgS';
// RPC endpoint for PezkuwiChain
const RPC_ENDPOINT = 'wss://rpc.pezkuwichain.io';
// Token decimals
const DECIMALS = 12;
// PEZ asset ID
const PEZ_ASSET_ID = 1;
interface DepositRequest {
sessionToken: string;
txHash: string;
token: 'HEZ' | 'PEZ';
expectedAmount: number;
blockNumber?: number; // Optional: for faster verification of old transactions
}
// Verify session token (same logic as telegram-auth)
function verifySessionToken(token: string): number | null {
try {
const decoded = atob(token);
const [telegramId, timestamp] = decoded.split(':');
const ts = parseInt(timestamp);
// Token valid for 7 days
if (Date.now() - ts > 7 * 24 * 60 * 60 * 1000) {
return null;
}
return parseInt(telegramId);
} catch {
return null;
}
}
// Cache API connection
let apiInstance: ApiPromise | null = null;
async function getApi(): Promise<ApiPromise> {
if (apiInstance && apiInstance.isConnected) {
return apiInstance;
}
const provider = new WsProvider(RPC_ENDPOINT);
apiInstance = await ApiPromise.create({ provider });
return apiInstance;
}
// Verify transaction on blockchain using @pezkuwi/api
async function verifyTransactionOnChain(
txHash: string,
token: string,
expectedAmount: number,
knownBlockNumber?: number
): Promise<{ valid: boolean; actualAmount?: number; from?: string; error?: string }> {
try {
// Validate transaction hash format (0x + 64 hex chars)
if (!txHash.match(/^0x[a-fA-F0-9]{64}$/)) {
return { valid: false, error: 'Invalid transaction hash format' };
}
const api = await getApi();
let foundBlock = null;
let foundExtrinsicIndex = -1;
// If block number is provided, search directly there
if (knownBlockNumber) {
const blockHash = await api.rpc.chain.getBlockHash(knownBlockNumber);
const signedBlock = await api.rpc.chain.getBlock(blockHash);
for (let j = 0; j < signedBlock.block.extrinsics.length; j++) {
const ext = signedBlock.block.extrinsics[j];
if (ext.hash.toHex() === txHash) {
foundBlock = { hash: blockHash, number: knownBlockNumber, block: signedBlock };
foundExtrinsicIndex = j;
break;
}
}
} else {
// Search recent blocks
const latestHeader = await api.rpc.chain.getHeader();
const latestBlockNumber = latestHeader.number.toNumber();
const searchDepth = 100; // Reduced for speed, use blockNumber param for old TXs
for (let i = 0; i < searchDepth; i++) {
const blockNumber = latestBlockNumber - i;
if (blockNumber < 0) break;
const blockHash = await api.rpc.chain.getBlockHash(blockNumber);
const signedBlock = await api.rpc.chain.getBlock(blockHash);
for (let j = 0; j < signedBlock.block.extrinsics.length; j++) {
const ext = signedBlock.block.extrinsics[j];
if (ext.hash.toHex() === txHash) {
foundBlock = { hash: blockHash, number: blockNumber, block: signedBlock };
foundExtrinsicIndex = j;
break;
}
}
if (foundBlock) break;
}
}
if (!foundBlock) {
return {
valid: false,
error: 'Transaction not found in recent blocks. It may be too old or not yet finalized.',
};
}
// Get events for this block
const apiAt = await api.at(foundBlock.hash);
const events = await apiAt.query.system.events();
// Find transfer events for our extrinsic
const extrinsicEvents = events.filter(
(event: {
phase: { isApplyExtrinsic: boolean; asApplyExtrinsic: { toNumber: () => number } };
}) => {
const { phase } = event;
return phase.isApplyExtrinsic && phase.asApplyExtrinsic.toNumber() === foundExtrinsicIndex;
}
);
// Check for success
const successEvent = extrinsicEvents.find((event: { event: unknown }) =>
api.events.system.ExtrinsicSuccess.is(event.event)
);
if (!successEvent) {
const failedEvent = extrinsicEvents.find((event: { event: unknown }) =>
api.events.system.ExtrinsicFailed.is(event.event)
);
if (failedEvent) {
return { valid: false, error: 'Transaction failed on-chain' };
}
return { valid: false, error: 'Transaction status unknown' };
}
// Find transfer event
let transferEvent = null;
let from = '';
let to = '';
let amount = BigInt(0);
if (token === 'HEZ') {
// Native token transfer (balances.Transfer)
transferEvent = extrinsicEvents.find((event: { event: unknown }) =>
api.events.balances.Transfer.is(event.event)
);
if (transferEvent) {
const [fromAddr, toAddr, value] = transferEvent.event.data;
from = fromAddr.toString();
to = toAddr.toString();
amount = BigInt(value.toString());
}
} else if (token === 'PEZ') {
// Asset transfer (assets.Transferred)
transferEvent = extrinsicEvents.find((event: { event: unknown }) =>
api.events.assets.Transferred.is(event.event)
);
if (transferEvent) {
const [assetId, fromAddr, toAddr, value] = transferEvent.event.data;
// Verify it's the correct asset
if (assetId.toNumber() !== PEZ_ASSET_ID) {
return { valid: false, error: 'Wrong asset transferred' };
}
from = fromAddr.toString();
to = toAddr.toString();
amount = BigInt(value.toString());
}
}
if (!transferEvent) {
return { valid: false, error: 'No transfer event found in transaction' };
}
// Verify recipient is platform wallet
if (to !== PLATFORM_WALLET) {
return {
valid: false,
error: `Transaction recipient (${to}) does not match platform wallet`,
};
}
// Convert amount to human readable
const actualAmount = Number(amount) / Math.pow(10, DECIMALS);
// Verify amount matches (allow 0.1% tolerance)
const tolerance = expectedAmount * 0.001;
if (Math.abs(actualAmount - expectedAmount) > tolerance) {
return {
valid: false,
error: `Amount mismatch. Expected: ${expectedAmount}, Actual: ${actualAmount}`,
actualAmount,
};
}
// Check if block is finalized
const finalizedHash = await api.rpc.chain.getFinalizedHead();
const finalizedHeader = await api.rpc.chain.getHeader(finalizedHash);
const finalizedNumber = finalizedHeader.number.toNumber();
if (foundBlock.number > finalizedNumber) {
return {
valid: false,
error: 'Transaction not yet finalized. Please wait a few more blocks.',
};
}
return {
valid: true,
actualAmount,
from,
};
} catch (error) {
console.error('Blockchain verification error:', error);
return {
valid: false,
error: `Verification failed: ${(error as Error).message}`,
};
}
}
serve(async (req) => {
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
try {
// Parse request body with error handling
let body: DepositRequest;
try {
const text = await req.text();
body = JSON.parse(text);
} catch (parseError) {
console.error('JSON parse error:', parseError);
return new Response(
JSON.stringify({ success: false, error: 'Invalid JSON in request body' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
const { sessionToken, txHash, token, expectedAmount, blockNumber } = body;
// Validate session token
if (!sessionToken) {
return new Response(JSON.stringify({ success: false, error: 'Missing session token' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const telegramId = verifySessionToken(sessionToken);
if (!telegramId) {
return new Response(
JSON.stringify({ success: false, error: 'Invalid or expired session token' }),
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Create Supabase service client
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const serviceClient = createClient(supabaseUrl, supabaseServiceKey);
// Get or create auth user for telegram user
// The p2p system uses auth.users for foreign keys
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
// Try to get existing auth user
const {
data: { users: existingUsers },
} = await serviceClient.auth.admin.listUsers();
let authUser = existingUsers?.find((u) => u.email === telegramEmail);
if (!authUser) {
// Get user info from users table
const { data: mainUser } = await serviceClient
.from('users')
.select('telegram_id, username, first_name, last_name')
.eq('telegram_id', telegramId)
.single();
if (!mainUser) {
return new Response(
JSON.stringify({ success: false, error: `User not found for telegram_id ${telegramId}` }),
{ status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Create auth user
const { data: newAuthUser, error: authError } = await serviceClient.auth.admin.createUser({
email: telegramEmail,
email_confirm: true,
user_metadata: {
telegram_id: telegramId,
username: mainUser.username,
first_name: mainUser.first_name,
},
});
if (authError) {
console.error('Failed to create auth user:', authError);
return new Response(
JSON.stringify({
success: false,
error: `Failed to create auth user: ${authError.message}`,
}),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
authUser = newAuthUser.user;
}
const user = { id: authUser!.id, telegram_id: telegramId };
// Validate input
if (!txHash || !token || !expectedAmount) {
return new Response(
JSON.stringify({
success: false,
error: 'Missing required fields: txHash, token, expectedAmount',
}),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
if (!['HEZ', 'PEZ'].includes(token)) {
return new Response(
JSON.stringify({ success: false, error: 'Invalid token. Must be HEZ or PEZ' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
if (expectedAmount <= 0) {
return new Response(
JSON.stringify({ success: false, error: 'Amount must be greater than 0' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Check if TX hash already processed
const { data: existingRequest } = await serviceClient
.from('p2p_deposit_withdraw_requests')
.select('id, status')
.eq('blockchain_tx_hash', txHash)
.single();
if (existingRequest) {
if (existingRequest.status === 'completed') {
return new Response(
JSON.stringify({
success: false,
error: 'This transaction has already been processed',
existingRequestId: existingRequest.id,
}),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
}
// Create deposit request if not exists, or use existing one
let depositRequest = existingRequest;
if (!depositRequest) {
const { data: newRequest, error: requestError } = await serviceClient
.from('p2p_deposit_withdraw_requests')
.insert({
user_id: user.id,
request_type: 'deposit',
token,
amount: expectedAmount,
wallet_address: PLATFORM_WALLET,
blockchain_tx_hash: txHash,
status: 'processing',
})
.select()
.single();
if (requestError) {
console.error('Failed to create deposit request:', requestError);
return new Response(
JSON.stringify({
success: false,
error: `Failed to create deposit request: ${requestError.message}`,
}),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
depositRequest = newRequest;
} else {
// Update existing request to processing
await serviceClient
.from('p2p_deposit_withdraw_requests')
.update({ status: 'processing' })
.eq('id', depositRequest.id);
}
// Verify transaction on blockchain
console.log(
`Verifying deposit: TX=${txHash}, Token=${token}, Amount=${expectedAmount}, TelegramID=${telegramId}`
);
const verification = await verifyTransactionOnChain(txHash, token, expectedAmount, blockNumber);
if (!verification.valid) {
// Update request status to failed
await serviceClient
.from('p2p_deposit_withdraw_requests')
.update({
status: 'failed',
error_message: verification.error,
processed_at: new Date().toISOString(),
})
.eq('id', depositRequest.id);
return new Response(
JSON.stringify({
success: false,
error: verification.error || 'Transaction verification failed',
}),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Transaction verified! Process deposit using service role
const { data: processResult, error: processError } = await serviceClient.rpc(
'process_deposit',
{
p_user_id: user.id,
p_token: token,
p_amount: verification.actualAmount || expectedAmount,
p_tx_hash: txHash,
p_request_id: depositRequest.id,
}
);
if (processError) {
console.error('Failed to process deposit:', processError);
await serviceClient
.from('p2p_deposit_withdraw_requests')
.update({
status: 'failed',
error_message: processError.message,
processed_at: new Date().toISOString(),
})
.eq('id', depositRequest.id);
return new Response(JSON.stringify({ success: false, error: 'Failed to process deposit' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (!processResult?.success) {
return new Response(
JSON.stringify({
success: false,
error: processResult?.error || 'Deposit processing failed',
}),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Success!
console.log(
`Deposit successful: TelegramID=${telegramId}, Amount=${verification.actualAmount || expectedAmount} ${token}`
);
return new Response(
JSON.stringify({
success: true,
amount: verification.actualAmount || expectedAmount,
token,
newBalance: processResult.new_balance,
txHash,
message: 'Deposit verified and credited successfully',
}),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Edge function error:', error);
return new Response(JSON.stringify({ success: false, error: 'Internal server error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
});
+214
View File
@@ -0,0 +1,214 @@
-- Pezkuwi Telegram Mini App Database Schema
-- Run this in Supabase SQL Editor
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Users table (synced from Telegram)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
telegram_id BIGINT UNIQUE NOT NULL,
username TEXT,
first_name TEXT NOT NULL,
last_name TEXT,
photo_url TEXT,
language_code TEXT DEFAULT 'ku',
is_admin BOOLEAN DEFAULT FALSE,
wallet_address TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Announcements (admin posts)
CREATE TABLE announcements (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title TEXT NOT NULL,
content TEXT NOT NULL,
image_url TEXT,
link_url TEXT,
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
likes INTEGER DEFAULT 0,
dislikes INTEGER DEFAULT 0,
views INTEGER DEFAULT 0,
is_published BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Announcement reactions
CREATE TABLE announcement_reactions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
announcement_id UUID NOT NULL REFERENCES announcements(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reaction TEXT NOT NULL CHECK (reaction IN ('like', 'dislike')),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(announcement_id, user_id)
);
-- Forum threads
CREATE TABLE threads (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title TEXT NOT NULL,
content TEXT NOT NULL,
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reply_count INTEGER DEFAULT 0,
likes INTEGER DEFAULT 0,
views INTEGER DEFAULT 0,
last_activity TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Thread likes
CREATE TABLE thread_likes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
thread_id UUID NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(thread_id, user_id)
);
-- Replies to threads
CREATE TABLE replies (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
thread_id UUID NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
content TEXT NOT NULL,
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
likes INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Reply likes
CREATE TABLE reply_likes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
reply_id UUID NOT NULL REFERENCES replies(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(reply_id, user_id)
);
-- Indexes for performance
CREATE INDEX idx_announcements_created_at ON announcements(created_at DESC);
CREATE INDEX idx_announcements_author ON announcements(author_id);
CREATE INDEX idx_threads_created_at ON threads(created_at DESC);
CREATE INDEX idx_threads_last_activity ON threads(last_activity DESC);
CREATE INDEX idx_threads_author ON threads(author_id);
CREATE INDEX idx_replies_thread ON replies(thread_id);
CREATE INDEX idx_replies_author ON replies(author_id);
CREATE INDEX idx_users_telegram_id ON users(telegram_id);
-- Updated at trigger function
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply updated_at triggers
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER update_announcements_updated_at
BEFORE UPDATE ON announcements
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER update_threads_updated_at
BEFORE UPDATE ON threads
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER update_replies_updated_at
BEFORE UPDATE ON replies
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Row Level Security (RLS)
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE announcements ENABLE ROW LEVEL SECURITY;
ALTER TABLE announcement_reactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE threads ENABLE ROW LEVEL SECURITY;
ALTER TABLE thread_likes ENABLE ROW LEVEL SECURITY;
ALTER TABLE replies ENABLE ROW LEVEL SECURITY;
ALTER TABLE reply_likes ENABLE ROW LEVEL SECURITY;
-- RLS Policies
-- Users: everyone can read, only self can update
CREATE POLICY "Users are viewable by everyone" ON users
FOR SELECT USING (true);
CREATE POLICY "Users can update own profile" ON users
FOR UPDATE USING (auth.uid()::text = id::text);
-- Announcements: everyone can read published
CREATE POLICY "Published announcements are viewable" ON announcements
FOR SELECT USING (is_published = true);
CREATE POLICY "Admins can manage announcements" ON announcements
FOR ALL USING (
EXISTS (SELECT 1 FROM users WHERE id::text = auth.uid()::text AND is_admin = true)
);
-- Announcement reactions: authenticated users
CREATE POLICY "Anyone can view reactions" ON announcement_reactions
FOR SELECT USING (true);
CREATE POLICY "Authenticated users can react" ON announcement_reactions
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY "Users can manage own reactions" ON announcement_reactions
FOR DELETE USING (user_id::text = auth.uid()::text);
-- Threads: everyone can read
CREATE POLICY "Threads are viewable by everyone" ON threads
FOR SELECT USING (true);
CREATE POLICY "Authenticated users can create threads" ON threads
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY "Authors can update own threads" ON threads
FOR UPDATE USING (author_id::text = auth.uid()::text);
CREATE POLICY "Authors and admins can delete threads" ON threads
FOR DELETE USING (
author_id::text = auth.uid()::text OR
EXISTS (SELECT 1 FROM users WHERE id::text = auth.uid()::text AND is_admin = true)
);
-- Thread likes
CREATE POLICY "Anyone can view thread likes" ON thread_likes
FOR SELECT USING (true);
CREATE POLICY "Authenticated users can like threads" ON thread_likes
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY "Users can remove own likes" ON thread_likes
FOR DELETE USING (user_id::text = auth.uid()::text);
-- Replies
CREATE POLICY "Replies are viewable by everyone" ON replies
FOR SELECT USING (true);
CREATE POLICY "Authenticated users can create replies" ON replies
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY "Authors can update own replies" ON replies
FOR UPDATE USING (author_id::text = auth.uid()::text);
CREATE POLICY "Authors and admins can delete replies" ON replies
FOR DELETE USING (
author_id::text = auth.uid()::text OR
EXISTS (SELECT 1 FROM users WHERE id::text = auth.uid()::text AND is_admin = true)
);
-- Reply likes
CREATE POLICY "Anyone can view reply likes" ON reply_likes
FOR SELECT USING (true);
CREATE POLICY "Authenticated users can like replies" ON reply_likes
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY "Users can remove own reply likes" ON reply_likes
FOR DELETE USING (user_id::text = auth.uid()::text);
+107
View File
@@ -0,0 +1,107 @@
-- RPC Functions for counter operations
-- These are atomic operations to prevent race conditions
-- Announcement reactions
CREATE OR REPLACE FUNCTION increment_announcement_reaction(
p_announcement_id UUID,
p_reaction TEXT
)
RETURNS VOID AS $$
BEGIN
IF p_reaction = 'like' THEN
UPDATE announcements SET likes = likes + 1 WHERE id = p_announcement_id;
ELSIF p_reaction = 'dislike' THEN
UPDATE announcements SET dislikes = dislikes + 1 WHERE id = p_announcement_id;
END IF;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION decrement_announcement_reaction(
p_announcement_id UUID,
p_reaction TEXT
)
RETURNS VOID AS $$
BEGIN
IF p_reaction = 'like' THEN
UPDATE announcements SET likes = GREATEST(0, likes - 1) WHERE id = p_announcement_id;
ELSIF p_reaction = 'dislike' THEN
UPDATE announcements SET dislikes = GREATEST(0, dislikes - 1) WHERE id = p_announcement_id;
END IF;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION change_announcement_reaction(
p_announcement_id UUID,
p_old_reaction TEXT,
p_new_reaction TEXT
)
RETURNS VOID AS $$
BEGIN
IF p_old_reaction = 'like' THEN
UPDATE announcements SET likes = GREATEST(0, likes - 1) WHERE id = p_announcement_id;
ELSIF p_old_reaction = 'dislike' THEN
UPDATE announcements SET dislikes = GREATEST(0, dislikes - 1) WHERE id = p_announcement_id;
END IF;
IF p_new_reaction = 'like' THEN
UPDATE announcements SET likes = likes + 1 WHERE id = p_announcement_id;
ELSIF p_new_reaction = 'dislike' THEN
UPDATE announcements SET dislikes = dislikes + 1 WHERE id = p_announcement_id;
END IF;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Thread operations
CREATE OR REPLACE FUNCTION increment_thread_views(p_thread_id UUID)
RETURNS VOID AS $$
BEGIN
UPDATE threads SET views = views + 1 WHERE id = p_thread_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION increment_thread_likes(p_thread_id UUID)
RETURNS VOID AS $$
BEGIN
UPDATE threads SET likes = likes + 1 WHERE id = p_thread_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION decrement_thread_likes(p_thread_id UUID)
RETURNS VOID AS $$
BEGIN
UPDATE threads SET likes = GREATEST(0, likes - 1) WHERE id = p_thread_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION increment_thread_reply(p_thread_id UUID)
RETURNS VOID AS $$
BEGIN
UPDATE threads
SET reply_count = reply_count + 1,
last_activity = NOW()
WHERE id = p_thread_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Reply operations
CREATE OR REPLACE FUNCTION increment_reply_likes(p_reply_id UUID)
RETURNS VOID AS $$
BEGIN
UPDATE replies SET likes = likes + 1 WHERE id = p_reply_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION decrement_reply_likes(p_reply_id UUID)
RETURNS VOID AS $$
BEGIN
UPDATE replies SET likes = GREATEST(0, likes - 1) WHERE id = p_reply_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Announcement view increment
CREATE OR REPLACE FUNCTION increment_announcement_views(p_announcement_id UUID)
RETURNS VOID AS $$
BEGIN
UPDATE announcements SET views = views + 1 WHERE id = p_announcement_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
@@ -0,0 +1,26 @@
-- Enable RLS on ALL public tables and create authenticated access policy
-- This migration secures all tables by requiring authentication
DO $$
DECLARE
t RECORD;
BEGIN
FOR t IN
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
AND tablename NOT LIKE 'pg_%'
AND tablename NOT LIKE '_pg_%'
LOOP
-- Enable RLS
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t.tablename);
-- Drop existing policy if exists
EXECUTE format('DROP POLICY IF EXISTS "authenticated_access" ON public.%I', t.tablename);
-- Create policy for authenticated users
EXECUTE format('CREATE POLICY "authenticated_access" ON public.%I FOR ALL TO authenticated USING (true) WITH CHECK (true)', t.tablename);
RAISE NOTICE 'RLS enabled for: %', t.tablename;
END LOOP;
END $$;
+107
View File
@@ -0,0 +1,107 @@
-- Pezkuwichain Forum - Admin First Post Seed
-- Run this in Supabase SQL Editor to create the welcome post
-- First, ensure there's a "General" category (if not exists)
INSERT INTO forum_categories (id, name, description, icon, color, is_active, display_order)
VALUES (
gen_random_uuid(),
'Giştî',
'Mijarên giştî û nîqaşên civakî',
'💬',
'#3B82F6',
true,
1
)
ON CONFLICT (name) DO NOTHING;
-- Get the category ID
DO $$
DECLARE
category_id UUID;
BEGIN
SELECT id INTO category_id FROM forum_categories WHERE name = 'Giştî' LIMIT 1;
-- Insert the welcome discussion
INSERT INTO forum_discussions (
id,
category_id,
title,
content,
image_url,
author_id,
author_name,
is_pinned,
is_locked,
views_count,
replies_count,
tags,
created_at,
updated_at,
last_activity_at
) VALUES (
gen_random_uuid(),
category_id,
'Bi xêr hatî Pezkuwichain! 🎉 Dewleta Dîjîtal a Kurd',
'Silav û rêz ji hemû welatiyên Kurd ên li seranserê cîhanê! 🌍
Pezkuwichain ne tenê blockchaineke e - ew xewna me ye ku Kurdên li her çar perçeyên Kurdistanê û li diasporayê bi hev re bigihîjin.
🔗 **Çima Pezkuwichain?**
Di vê serdema dîjîtal de, em Kurd hewce ne ku platformeke xwe hebe ku:
- Bi azadî bi hev re biaxivin
- Aboriya xwe bi hev re ava bikin
- Nasname û çanda xwe biparêzin
- Ji hêla teknolojiya blockchain ve ewlekariya xwe misoger bikin
🌐 **Armanca Me**
Em dixwazin platformeke ava bikin ku hemû Kurdên cîhanê dikarin:
- Bi hev re pêwendî daynin (connect)
- Projeyên hevbeş bi rê ve bibin
- Bi ziman û çanda xwe bi hev re têkildar bin
- Di aboriya dîjîtal de cîhê xwe bigirin
💪 **Hêza Civakê**
Blockchain ne tenê teknolojî ye - ew şanseke nû ye ji bo gelên wekî me ku di dîrokê de nekarîbûn dewleta xwe ava bikin. Niha em dikarin "Dewleta Dîjîtal"a xwe ava bikin!
Ev platform diyarîyeke ji me ye ji bo hemû Kurdên cîhanê. Bi hev re em dikarin!
#BiHevRe #Pezkuwichain #Blockchain #Kurd #DewletaDîjîtal',
'/tokens/DKState.png',
'admin',
'Pezkuwichain Admin',
true, -- pinned
false, -- not locked
0,
0,
ARRAY['BiHevRe', 'Pezkuwichain', 'Blockchain', 'Kurd', 'Civak'],
NOW(),
NOW(),
NOW()
);
END $$;
-- Create an admin announcement
INSERT INTO admin_announcements (
id,
title,
content,
type,
priority,
is_active,
created_at
) VALUES (
gen_random_uuid(),
'Bi xêr hatî Forum!',
'Ev foruma fermî ya Pezkuwichain e. Li vir tu dikarî mijarên nû vekî, bersivan bidî û bi civakê re têkiliyê ragirî!',
'success',
100,
true,
NOW()
)
ON CONFLICT DO NOTHING;
-- Output success message
SELECT 'Admin post and announcement created successfully!' as result;

Some files were not shown because too many files have changed in this diff Show More