commit bc7ab9b2a41de63ff0829efb2cdbe2501b166a5b Author: Kurdistan Tech Ministry Date: Sat Jan 31 08:51:45 2026 +0300 Initial P2P mobile frontend - Copy P2P components from pwap/web - Mobile-optimized P2P trading interface - To be deployed at telegram.pezkuwichain.io/p2p diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2e7761 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 0000000..0e1285c --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + pezkuwi-p2p-mobile + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..005f1a0 --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "pezkuwi-p2p-mobile", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint ." + }, + "dependencies": { + "@pezkuwi/api": "^16.5.18", + "@pezkuwi/keyring": "^14.0.13", + "@pezkuwi/util": "^14.0.13", + "@pezkuwi/util-crypto": "^14.0.13", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-select": "^2.1.1", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-toast": "^1.2.1", + "@radix-ui/react-tooltip": "^1.1.4", + "@supabase/supabase-js": "^2.49.4", + "@tanstack/react-query": "^5.56.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "lucide-react": "^0.462.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2", + "sonner": "^1.5.0", + "tailwind-merge": "^2.5.2", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.11", + "typescript": "^5.5.3", + "vite": "^7.3.1" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/vite.svg b/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..b21ef4e --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,20 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { P2PDashboard } from './components/p2p/P2PDashboard'; +import { AuthProvider } from './contexts/AuthContext'; +import './index.css'; + +const queryClient = new QueryClient(); + +function App() { + return ( + + +
+ +
+
+
+ ); +} + +export default App; diff --git a/src/components/p2p/AdList.tsx b/src/components/p2p/AdList.tsx new file mode 100644 index 0000000..249f852 --- /dev/null +++ b/src/components/p2p/AdList.tsx @@ -0,0 +1,311 @@ +import React, { useState, useEffect } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { Loader2, Shield, Zap } from 'lucide-react'; +import { useAuth } from '@/contexts/AuthContext'; +import { TradeModal } from './TradeModal'; +import { MerchantTierBadge } from './MerchantTierBadge'; +import { getUserReputation, type P2PFiatOffer, type P2PReputation } from '@shared/lib/p2p-fiat'; +import { supabase } from '@/lib/supabase'; +import type { P2PFilters } from './types'; + +interface AdListProps { + type: 'buy' | 'sell' | 'my-ads'; + filters?: P2PFilters; +} + +interface OfferWithReputation extends P2PFiatOffer { + seller_reputation?: P2PReputation; + payment_method_name?: string; + merchant_tier?: 'lite' | 'super' | 'diamond'; +} + +export function AdList({ type, filters }: AdListProps) { + const { user } = useAuth(); + const [offers, setOffers] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedOffer, setSelectedOffer] = useState(null); + + useEffect(() => { + fetchOffers(); + + // Refresh data when user returns to the tab (visibility change) + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + fetchOffers(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [type, user, filters]); + + const fetchOffers = async () => { + setLoading(true); + try { + let offersData: P2PFiatOffer[] = []; + + // Build base query + let query = supabase.from('p2p_fiat_offers').select('*'); + + if (type === 'buy') { + // Buy tab = show SELL offers (user wants to buy from sellers) + query = query.eq('ad_type', 'sell').eq('status', 'open').gt('remaining_amount', 0); + } else if (type === 'sell') { + // Sell tab = show BUY offers (user wants to sell to buyers) + query = query.eq('ad_type', 'buy').eq('status', 'open').gt('remaining_amount', 0); + } else if (type === 'my-ads' && user) { + // My offers - show all of user's offers + query = query.eq('seller_id', user.id); + } + + // Apply filters if provided + if (filters) { + // Token filter + if (filters.token && filters.token !== 'all') { + query = query.eq('token', filters.token); + } + + // Fiat currency filter + if (filters.fiatCurrency && filters.fiatCurrency !== 'all') { + query = query.eq('fiat_currency', filters.fiatCurrency); + } + + // Payment method filter + if (filters.paymentMethods && filters.paymentMethods.length > 0) { + query = query.in('payment_method_id', filters.paymentMethods); + } + + // Amount range filter + if (filters.minAmount !== null) { + query = query.gte('remaining_amount', filters.minAmount); + } + if (filters.maxAmount !== null) { + query = query.lte('remaining_amount', filters.maxAmount); + } + + // Sort order + const sortColumn = filters.sortBy === 'price' ? 'price_per_unit' : + filters.sortBy === 'completion_rate' ? 'created_at' : + filters.sortBy === 'trades' ? 'created_at' : + 'created_at'; + query = query.order(sortColumn, { ascending: filters.sortOrder === 'asc' }); + } else { + query = query.order('created_at', { ascending: false }); + } + + const { data } = await query; + offersData = data || []; + + // Enrich with reputation, payment method, and merchant tier + const enrichedOffers = await Promise.all( + offersData.map(async (offer) => { + const [reputation, paymentMethod, merchantTier] = await Promise.all([ + getUserReputation(offer.seller_id), + supabase + .from('payment_methods') + .select('method_name') + .eq('id', offer.payment_method_id) + .single(), + supabase + .from('p2p_merchant_tiers') + .select('tier') + .eq('user_id', offer.seller_id) + .single() + ]); + + return { + ...offer, + seller_reputation: reputation || undefined, + payment_method_name: paymentMethod.data?.method_name, + merchant_tier: merchantTier.data?.tier as 'lite' | 'super' | 'diamond' | undefined + }; + }) + ); + + // Apply client-side filters (completion rate, merchant tier) + let filteredOffers = enrichedOffers; + + if (filters) { + // Completion rate filter (needs reputation data) + if (filters.minCompletionRate > 0) { + filteredOffers = filteredOffers.filter(offer => { + if (!offer.seller_reputation) return false; + const rate = (offer.seller_reputation.completed_trades / (offer.seller_reputation.total_trades || 1)) * 100; + return rate >= filters.minCompletionRate; + }); + } + + // Merchant tier filter + if (filters.merchantTiers && filters.merchantTiers.length > 0) { + filteredOffers = filteredOffers.filter(offer => { + if (!offer.merchant_tier) return false; + // If super is selected, include super and diamond + // If diamond is selected, include only diamond + if (filters.merchantTiers.includes('diamond')) { + return offer.merchant_tier === 'diamond'; + } + if (filters.merchantTiers.includes('super')) { + return offer.merchant_tier === 'super' || offer.merchant_tier === 'diamond'; + } + return filters.merchantTiers.includes(offer.merchant_tier); + }); + } + + // Verified only filter + if (filters.verifiedOnly) { + filteredOffers = filteredOffers.filter(offer => offer.seller_reputation?.verified_merchant); + } + } + + setOffers(filteredOffers); + } catch (error) { + if (import.meta.env.DEV) console.error('Fetch offers error:', error); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (offers.length === 0) { + return ( +
+

+ {type === 'my-ads' ? 'You have no active offers' : 'No offers available'} +

+
+ ); + } + + return ( +
+ {offers.map(offer => ( + + +
+ {/* Seller Info */} +
+ + + {offer.seller_wallet.slice(0, 2).toUpperCase()} + + +
+
+

+ {offer.seller_wallet.slice(0, 6)}...{offer.seller_wallet.slice(-4)} +

+ {offer.merchant_tier && ( + + )} + {offer.seller_reputation?.verified_merchant && ( + + )} + {offer.seller_reputation?.fast_trader && ( + + )} +
+ {offer.seller_reputation && ( +

+ {offer.seller_reputation.completed_trades} trades • {' '} + {((offer.seller_reputation.completed_trades / (offer.seller_reputation.total_trades || 1)) * 100).toFixed(0)}% completion +

+ )} +
+
+ + {/* Price */} +
+

Price

+

+ {offer.price_per_unit.toFixed(2)} {offer.fiat_currency} +

+
+ + {/* Available */} +
+

Available

+

+ {offer.remaining_amount} {offer.token} +

+ {offer.min_order_amount && ( +

+ Min: {offer.min_order_amount} {offer.token} +

+ )} +
+ + {/* Payment Method */} +
+

Payment

+ + {offer.payment_method_name || 'N/A'} + +

+ {offer.time_limit_minutes} min limit +

+
+ + {/* Action */} +
+ {offer.seller_id === user?.id && type !== 'my-ads' && ( + + Your Ad + + )} + +
+
+ + {/* Status badge for my-ads */} + {type === 'my-ads' && ( +
+
+ + {offer.status.toUpperCase()} + +

+ Created: {new Date(offer.created_at).toLocaleDateString()} +

+
+
+ )} +
+
+ ))} + + {selectedOffer && ( + { + setSelectedOffer(null); + fetchOffers(); // Refresh list + + }} + /> + )} +
+ ); +} diff --git a/src/components/p2p/BlockTrade.tsx b/src/components/p2p/BlockTrade.tsx new file mode 100644 index 0000000..d2399ca --- /dev/null +++ b/src/components/p2p/BlockTrade.tsx @@ -0,0 +1,385 @@ +/** + * Block Trade Component - OKX-Style OTC Trading + * + * Block trades are for large volume trades that are handled differently + * from regular P2P trades. They offer: + * - Custom pricing negotiation + * - Dedicated support + * - Multi-tranche settlements + * - Enhanced privacy + */ + +import React, { useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Textarea } from '@/components/ui/textarea'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Blocks, Shield, Clock, Lock, MessageSquare, ChevronRight, + Building2, AlertTriangle +} from 'lucide-react'; +import { supabase } from '@/lib/supabase'; +import { useAuth } from '@/contexts/AuthContext'; +import { toast } from 'sonner'; +import type { CryptoToken, FiatCurrency } from '@pezkuwi/lib/p2p-fiat'; + +interface BlockTradeRequest { + id: string; + type: 'buy' | 'sell'; + token: CryptoToken; + fiat_currency: FiatCurrency; + amount: number; + target_price?: number; + message?: string; + status: 'pending' | 'negotiating' | 'approved' | 'in_progress' | 'completed' | 'cancelled'; + created_at: string; +} + +const SUPPORTED_TOKENS: CryptoToken[] = ['HEZ', 'PEZ']; + +// All supported fiat currencies including Kurdish Diaspora countries +const SUPPORTED_FIATS: { code: FiatCurrency; name: string; symbol: string; region: string }[] = [ + // Primary regions (Kurdistan & neighboring) + { code: 'TRY', name: 'Turkish Lira', symbol: '₺', region: 'Bakur' }, + { code: 'IQD', name: 'Iraqi Dinar', symbol: 'د.ع', region: 'Başûr' }, + { code: 'IRR', name: 'Iranian Rial', symbol: '﷼', region: 'Rojhilat' }, + // Eurozone diaspora + { code: 'EUR', name: 'Euro', symbol: '€', region: 'EU' }, + // Other diaspora regions + { code: 'USD', name: 'US Dollar', symbol: '$', region: 'USA' }, + { code: 'GBP', name: 'British Pound', symbol: '£', region: 'UK' }, + { code: 'SEK', name: 'Swedish Krona', symbol: 'kr', region: 'Sweden' }, + { code: 'CHF', name: 'Swiss Franc', symbol: 'Fr.', region: 'Switzerland' }, + { code: 'NOK', name: 'Norwegian Krone', symbol: 'kr', region: 'Norway' }, + { code: 'DKK', name: 'Danish Krone', symbol: 'kr', region: 'Denmark' }, + { code: 'AUD', name: 'Australian Dollar', symbol: 'A$', region: 'Australia' }, + { code: 'CAD', name: 'Canadian Dollar', symbol: 'C$', region: 'Canada' }, +]; + +// Minimum amounts for block trade (in USD equivalent) +const MINIMUM_BLOCK_AMOUNTS: Record = { + HEZ: 10000, // 10,000 HEZ minimum + PEZ: 50000, // 50,000 PEZ minimum +}; + +export function BlockTrade() { + const [showRequestModal, setShowRequestModal] = useState(false); + const [type, setType] = useState<'buy' | 'sell'>('buy'); + const [token, setToken] = useState('HEZ'); + const [fiat, setFiat] = useState('USD'); + const [amount, setAmount] = useState(''); + const [targetPrice, setTargetPrice] = useState(''); + const [message, setMessage] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [requests, setRequests] = useState([]); + + const { user } = useAuth(); + const fiatSymbol = SUPPORTED_FIATS.find(f => f.code === fiat)?.symbol || ''; + const minAmount = MINIMUM_BLOCK_AMOUNTS[token]; + + // Fetch user's block trade requests + React.useEffect(() => { + if (!user) return; + + const fetchRequests = async () => { + const { data, error } = await supabase + .from('p2p_block_trade_requests') + .select('*') + .eq('user_id', user.id) + .order('created_at', { ascending: false }); + + if (!error && data) { + setRequests(data); + } + }; + + fetchRequests(); + }, [user]); + + const handleSubmitRequest = async () => { + if (!user) { + toast.error('Please login to submit a block trade request'); + return; + } + + const amountNum = parseFloat(amount); + if (isNaN(amountNum) || amountNum < minAmount) { + toast.error(`Minimum amount for ${token} block trade is ${minAmount.toLocaleString()} ${token}`); + return; + } + + setIsSubmitting(true); + try { + const { data, error } = await supabase + .from('p2p_block_trade_requests') + .insert({ + user_id: user.id, + type, + token, + fiat_currency: fiat, + amount: amountNum, + target_price: targetPrice ? parseFloat(targetPrice) : null, + message: message || null, + status: 'pending' + }) + .select() + .single(); + + if (error) throw error; + + toast.success('Block trade request submitted! Our OTC desk will contact you within 24 hours.'); + setShowRequestModal(false); + setAmount(''); + setTargetPrice(''); + setMessage(''); + + // Add to local state + setRequests(prev => [data, ...prev]); + } catch (err) { + console.error('Block trade request error:', err); + toast.error('Failed to submit request'); + } finally { + setIsSubmitting(false); + } + }; + + const getStatusBadge = (status: BlockTradeRequest['status']) => { + const styles: Record = { + pending: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', + negotiating: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + approved: 'bg-green-500/20 text-green-400 border-green-500/30', + in_progress: 'bg-purple-500/20 text-purple-400 border-purple-500/30', + completed: 'bg-gray-500/20 text-gray-400 border-gray-500/30', + cancelled: 'bg-red-500/20 text-red-400 border-red-500/30', + }; + return styles[status] || styles.pending; + }; + + return ( + <> + + +
+
+
+ +
+
+ Block Trade (OTC) + + Large volume trades with custom pricing + +
+
+ + VIP + +
+
+ + {/* Features */} +
+
+ + Private Negotiation +
+
+ + Escrow Protected +
+
+ + Dedicated Support +
+
+ + Flexible Settlement +
+
+ + {/* Minimum Amounts Info */} +
+

Minimum Block Trade Amounts:

+
+ {Object.entries(MINIMUM_BLOCK_AMOUNTS).map(([t, min]) => ( + + {min.toLocaleString()} {t} + + ))} +
+
+ + {/* Request Button */} + + + {/* Active Requests */} + {requests.length > 0 && ( +
+

Your Requests:

+ {requests.slice(0, 3).map(req => ( +
+
+ + {req.type.toUpperCase()} + + + {req.amount.toLocaleString()} {req.token} + +
+ + {req.status.replace('_', ' ')} + +
+ ))} +
+ )} +
+
+ + {/* Request Modal */} + + + + + + Block Trade Request + + + Submit a request for our OTC desk to handle your large volume trade. + + + +
+ {/* Buy/Sell Toggle */} + setType(v as 'buy' | 'sell')}> + + + Buy + + + Sell + + + + + {/* Token & Fiat */} +
+
+ + +
+
+ + +
+
+ + {/* Amount */} +
+ + setAmount(e.target.value)} + className="bg-gray-800 border-gray-700" + /> +

+ Minimum: {minAmount.toLocaleString()} {token} +

+
+ + {/* Target Price (Optional) */} +
+ +
+ setTargetPrice(e.target.value)} + className="bg-gray-800 border-gray-700 pr-16" + /> + + {fiatSymbol}/{token} + +
+
+ + {/* Message */} +
+ +