mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 07:57:55 +00:00
feat: replace supabase auth with citizen/visa identity system for P2P
Replace all supabase.auth.getUser() calls with P2PIdentityContext that resolves identity from on-chain citizen NFT or off-chain visa system. - Add identityToUUID() in shared/lib/identity.ts (UUID v5 from citizen/visa number) - Add P2PIdentityContext with citizen NFT detection and visa fallback - Add p2p_visa migration for off-chain visa issuance - Refactor p2p-fiat.ts: all functions now accept userId parameter - Fix all P2P components to use useP2PIdentity() instead of useAuth() - Update verify-deposit edge function: walletToUUID -> identityToUUID - Add P2PLayout with identity gate (wallet/citizen/visa checks) - Wrap all P2P routes with P2PLayout in App.tsx
This commit is contained in:
@@ -1,4 +1,41 @@
|
|||||||
// Identity verification types and utilities
|
// Identity verification types and utilities
|
||||||
|
|
||||||
|
// UUID v5 namespace (RFC 4122 DNS namespace)
|
||||||
|
const UUID_V5_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Citizen Number or Visa Number to a deterministic UUID v5.
|
||||||
|
* Uses SHA-1 hashing per RFC 4122. Works in both browser and Deno.
|
||||||
|
*
|
||||||
|
* @param identityId - Citizen number (e.g. "#42-0-832967") or Visa number (e.g. "V-123456")
|
||||||
|
* @returns Deterministic UUID v5 string
|
||||||
|
*/
|
||||||
|
export async function identityToUUID(identityId: string): Promise<string> {
|
||||||
|
const namespaceHex = UUID_V5_NAMESPACE.replace(/-/g, '');
|
||||||
|
const namespaceBytes = new Uint8Array(16);
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
namespaceBytes[i] = parseInt(namespaceHex.substr(i * 2, 2), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameBytes = new TextEncoder().encode(identityId);
|
||||||
|
const combined = new Uint8Array(namespaceBytes.length + nameBytes.length);
|
||||||
|
combined.set(namespaceBytes);
|
||||||
|
combined.set(nameBytes, namespaceBytes.length);
|
||||||
|
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-1', combined);
|
||||||
|
const h = new Uint8Array(hashBuffer);
|
||||||
|
|
||||||
|
// Set version 5 and RFC 4122 variant
|
||||||
|
h[6] = (h[6] & 0x0f) | 0x50;
|
||||||
|
h[8] = (h[8] & 0x3f) | 0x80;
|
||||||
|
|
||||||
|
const hex = Array.from(h.slice(0, 16))
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IdentityProfile {
|
export interface IdentityProfile {
|
||||||
address: string;
|
address: string;
|
||||||
verificationLevel: 'none' | 'basic' | 'advanced' | 'verified';
|
verificationLevel: 'none' | 'basic' | 'advanced' | 'verified';
|
||||||
|
|||||||
+35
-38
@@ -118,6 +118,8 @@ export interface P2PReputation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateOfferParams {
|
export interface CreateOfferParams {
|
||||||
|
userId: string;
|
||||||
|
sellerWallet: string;
|
||||||
token: CryptoToken;
|
token: CryptoToken;
|
||||||
amountCrypto: number;
|
amountCrypto: number;
|
||||||
fiatCurrency: FiatCurrency;
|
fiatCurrency: FiatCurrency;
|
||||||
@@ -127,14 +129,14 @@ export interface CreateOfferParams {
|
|||||||
timeLimitMinutes?: number;
|
timeLimitMinutes?: number;
|
||||||
minOrderAmount?: number;
|
minOrderAmount?: number;
|
||||||
maxOrderAmount?: number;
|
maxOrderAmount?: number;
|
||||||
// NOTE: api and account no longer needed - uses internal ledger
|
adType?: 'buy' | 'sell';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AcceptOfferParams {
|
export interface AcceptOfferParams {
|
||||||
offerId: string;
|
offerId: string;
|
||||||
|
buyerUserId: string;
|
||||||
buyerWallet: string;
|
buyerWallet: string;
|
||||||
amount?: number; // If partial order
|
amount?: number; // If partial order
|
||||||
// NOTE: api and account no longer needed - uses internal ledger
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
@@ -375,6 +377,8 @@ async function decryptPaymentDetails(encrypted: string): Promise<Record<string,
|
|||||||
*/
|
*/
|
||||||
export async function createFiatOffer(params: CreateOfferParams): Promise<string> {
|
export async function createFiatOffer(params: CreateOfferParams): Promise<string> {
|
||||||
const {
|
const {
|
||||||
|
userId,
|
||||||
|
sellerWallet,
|
||||||
token,
|
token,
|
||||||
amountCrypto,
|
amountCrypto,
|
||||||
fiatCurrency,
|
fiatCurrency,
|
||||||
@@ -383,14 +387,12 @@ export async function createFiatOffer(params: CreateOfferParams): Promise<string
|
|||||||
paymentDetails,
|
paymentDetails,
|
||||||
timeLimitMinutes = DEFAULT_PAYMENT_DEADLINE_MINUTES,
|
timeLimitMinutes = DEFAULT_PAYMENT_DEADLINE_MINUTES,
|
||||||
minOrderAmount,
|
minOrderAmount,
|
||||||
maxOrderAmount
|
maxOrderAmount,
|
||||||
|
adType = 'sell'
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get current user
|
if (!userId) throw new Error('Identity required for P2P trading');
|
||||||
const { data: userData } = await supabase.auth.getUser();
|
|
||||||
const userId = userData.user?.id;
|
|
||||||
if (!userId) throw new Error('Not authenticated');
|
|
||||||
|
|
||||||
toast.info('Locking crypto from your balance...');
|
toast.info('Locking crypto from your balance...');
|
||||||
|
|
||||||
@@ -420,7 +422,8 @@ export async function createFiatOffer(params: CreateOfferParams): Promise<string
|
|||||||
.from('p2p_fiat_offers')
|
.from('p2p_fiat_offers')
|
||||||
.insert({
|
.insert({
|
||||||
seller_id: userId,
|
seller_id: userId,
|
||||||
seller_wallet: '', // No longer needed with internal ledger
|
seller_wallet: sellerWallet,
|
||||||
|
ad_type: adType,
|
||||||
token,
|
token,
|
||||||
amount_crypto: amountCrypto,
|
amount_crypto: amountCrypto,
|
||||||
fiat_currency: fiatCurrency,
|
fiat_currency: fiatCurrency,
|
||||||
@@ -461,7 +464,7 @@ export async function createFiatOffer(params: CreateOfferParams): Promise<string
|
|||||||
fiat_currency: fiatCurrency,
|
fiat_currency: fiatCurrency,
|
||||||
fiat_amount: fiatAmount,
|
fiat_amount: fiatAmount,
|
||||||
escrow_type: 'internal_ledger'
|
escrow_type: 'internal_ledger'
|
||||||
});
|
}, userId);
|
||||||
|
|
||||||
toast.success(`Offer created! Selling ${amountCrypto} ${token} for ${fiatAmount} ${fiatCurrency}`);
|
toast.success(`Offer created! Selling ${amountCrypto} ${token} for ${fiatAmount} ${fiatCurrency}`);
|
||||||
|
|
||||||
@@ -482,12 +485,10 @@ export async function createFiatOffer(params: CreateOfferParams): Promise<string
|
|||||||
* Accept a P2P fiat offer (buyer)
|
* Accept a P2P fiat offer (buyer)
|
||||||
*/
|
*/
|
||||||
export async function acceptFiatOffer(params: AcceptOfferParams): Promise<string> {
|
export async function acceptFiatOffer(params: AcceptOfferParams): Promise<string> {
|
||||||
const { offerId, amount } = params;
|
const { offerId, buyerUserId, buyerWallet, amount } = params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Get current user
|
if (!buyerUserId) throw new Error('Identity required for P2P trading');
|
||||||
const { data: user } = await supabase.auth.getUser();
|
|
||||||
if (!user.user) throw new Error('Not authenticated');
|
|
||||||
|
|
||||||
// 2. Get offer to determine amount if not specified
|
// 2. Get offer to determine amount if not specified
|
||||||
const { data: offer, error: offerError } = await supabase
|
const { data: offer, error: offerError } = await supabase
|
||||||
@@ -506,7 +507,7 @@ export async function acceptFiatOffer(params: AcceptOfferParams): Promise<string
|
|||||||
const { data: reputation } = await supabase
|
const { data: reputation } = await supabase
|
||||||
.from('p2p_reputation')
|
.from('p2p_reputation')
|
||||||
.select('completed_trades, reputation_score')
|
.select('completed_trades, reputation_score')
|
||||||
.eq('user_id', user.user.id)
|
.eq('user_id', buyerUserId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (!reputation) {
|
if (!reputation) {
|
||||||
@@ -524,8 +525,8 @@ export async function acceptFiatOffer(params: AcceptOfferParams): Promise<string
|
|||||||
// This uses FOR UPDATE lock to ensure only one buyer can claim the amount
|
// This uses FOR UPDATE lock to ensure only one buyer can claim the amount
|
||||||
const { data: result, error: rpcError } = await supabase.rpc('accept_p2p_offer', {
|
const { data: result, error: rpcError } = await supabase.rpc('accept_p2p_offer', {
|
||||||
p_offer_id: offerId,
|
p_offer_id: offerId,
|
||||||
p_buyer_id: user.user.id,
|
p_buyer_id: buyerUserId,
|
||||||
p_buyer_wallet: params.buyerWallet,
|
p_buyer_wallet: buyerWallet,
|
||||||
p_amount: tradeAmount
|
p_amount: tradeAmount
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -543,7 +544,7 @@ export async function acceptFiatOffer(params: AcceptOfferParams): Promise<string
|
|||||||
offer_id: offerId,
|
offer_id: offerId,
|
||||||
crypto_amount: response.crypto_amount,
|
crypto_amount: response.crypto_amount,
|
||||||
fiat_amount: response.fiat_amount
|
fiat_amount: response.fiat_amount
|
||||||
});
|
}, buyerUserId);
|
||||||
|
|
||||||
toast.success('Trade started! Send payment within time limit.');
|
toast.success('Trade started! Send payment within time limit.');
|
||||||
|
|
||||||
@@ -618,12 +619,9 @@ export async function markPaymentSent(
|
|||||||
*
|
*
|
||||||
* Buyer can later withdraw to external wallet if needed (separate blockchain tx).
|
* Buyer can later withdraw to external wallet if needed (separate blockchain tx).
|
||||||
*/
|
*/
|
||||||
export async function confirmPaymentReceived(tradeId: string): Promise<void> {
|
export async function confirmPaymentReceived(tradeId: string, sellerId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// 1. Get current user (seller)
|
if (!sellerId) throw new Error('Identity required for P2P trading');
|
||||||
const { data: userData } = await supabase.auth.getUser();
|
|
||||||
const sellerId = userData.user?.id;
|
|
||||||
if (!sellerId) throw new Error('Not authenticated');
|
|
||||||
|
|
||||||
// 2. Get trade details
|
// 2. Get trade details
|
||||||
const { data: trade, error: tradeError } = await supabase
|
const { data: trade, error: tradeError } = await supabase
|
||||||
@@ -697,7 +695,7 @@ export async function confirmPaymentReceived(tradeId: string): Promise<void> {
|
|||||||
released_amount: trade.crypto_amount,
|
released_amount: trade.crypto_amount,
|
||||||
token: offer.token,
|
token: offer.token,
|
||||||
escrow_type: 'internal_ledger'
|
escrow_type: 'internal_ledger'
|
||||||
});
|
}, sellerId);
|
||||||
|
|
||||||
toast.success('Payment confirmed! Crypto released to buyer\'s balance.');
|
toast.success('Payment confirmed! Crypto released to buyer\'s balance.');
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -766,12 +764,11 @@ async function logAction(
|
|||||||
entityType: string,
|
entityType: string,
|
||||||
entityId: string,
|
entityId: string,
|
||||||
action: string,
|
action: string,
|
||||||
details: Record<string, any>
|
details: Record<string, any>,
|
||||||
|
userId?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { data: user } = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
await supabase.from('p2p_audit_log').insert({
|
await supabase.from('p2p_audit_log').insert({
|
||||||
user_id: user.user?.id,
|
user_id: userId || null,
|
||||||
action,
|
action,
|
||||||
entity_type: entityType,
|
entity_type: entityType,
|
||||||
entity_id: entityId,
|
entity_id: entityId,
|
||||||
@@ -917,7 +914,7 @@ export async function cancelTrade(
|
|||||||
await logAction('trade', tradeId, 'cancel_trade', {
|
await logAction('trade', tradeId, 'cancel_trade', {
|
||||||
cancelled_by: cancelledBy,
|
cancelled_by: cancelledBy,
|
||||||
reason,
|
reason,
|
||||||
});
|
}, cancelledBy);
|
||||||
|
|
||||||
toast.success('Trade cancelled successfully');
|
toast.success('Trade cancelled successfully');
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -985,11 +982,9 @@ export async function updateUserReputation(
|
|||||||
/**
|
/**
|
||||||
* Get user's internal balances for P2P trading
|
* Get user's internal balances for P2P trading
|
||||||
*/
|
*/
|
||||||
export async function getInternalBalances(): Promise<InternalBalance[]> {
|
export async function getInternalBalances(userId: string): Promise<InternalBalance[]> {
|
||||||
try {
|
try {
|
||||||
const { data: userData } = await supabase.auth.getUser();
|
if (!userId) throw new Error('Identity required for P2P trading');
|
||||||
const userId = userData.user?.id;
|
|
||||||
if (!userId) throw new Error('Not authenticated');
|
|
||||||
|
|
||||||
const { data, error } = await supabase.rpc('get_user_internal_balance', {
|
const { data, error } = await supabase.rpc('get_user_internal_balance', {
|
||||||
p_user_id: userId
|
p_user_id: userId
|
||||||
@@ -1009,8 +1004,8 @@ export async function getInternalBalances(): Promise<InternalBalance[]> {
|
|||||||
/**
|
/**
|
||||||
* Get user's internal balance for a specific token
|
* Get user's internal balance for a specific token
|
||||||
*/
|
*/
|
||||||
export async function getInternalBalance(token: CryptoToken): Promise<InternalBalance | null> {
|
export async function getInternalBalance(userId: string, token: CryptoToken): Promise<InternalBalance | null> {
|
||||||
const balances = await getInternalBalances();
|
const balances = await getInternalBalances(userId);
|
||||||
return balances.find(b => b.token === token) || null;
|
return balances.find(b => b.token === token) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1019,14 +1014,13 @@ export async function getInternalBalance(token: CryptoToken): Promise<InternalBa
|
|||||||
* This creates a pending request that will be processed by backend service
|
* This creates a pending request that will be processed by backend service
|
||||||
*/
|
*/
|
||||||
export async function requestWithdraw(
|
export async function requestWithdraw(
|
||||||
|
userId: string,
|
||||||
token: CryptoToken,
|
token: CryptoToken,
|
||||||
amount: number,
|
amount: number,
|
||||||
walletAddress: string
|
walletAddress: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const { data: userData } = await supabase.auth.getUser();
|
if (!userId) throw new Error('Identity required for P2P trading');
|
||||||
const userId = userData.user?.id;
|
|
||||||
if (!userId) throw new Error('Not authenticated');
|
|
||||||
|
|
||||||
// Validate amount
|
// Validate amount
|
||||||
if (amount <= 0) throw new Error('Amount must be greater than 0');
|
if (amount <= 0) throw new Error('Amount must be greater than 0');
|
||||||
@@ -1069,11 +1063,14 @@ export async function requestWithdraw(
|
|||||||
/**
|
/**
|
||||||
* Get user's deposit/withdraw request history
|
* Get user's deposit/withdraw request history
|
||||||
*/
|
*/
|
||||||
export async function getDepositWithdrawHistory(): Promise<DepositWithdrawRequest[]> {
|
export async function getDepositWithdrawHistory(userId: string): Promise<DepositWithdrawRequest[]> {
|
||||||
try {
|
try {
|
||||||
|
if (!userId) throw new Error('Identity required for P2P trading');
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('p2p_deposit_withdraw_requests')
|
.from('p2p_deposit_withdraw_requests')
|
||||||
.select('*')
|
.select('*')
|
||||||
|
.eq('user_id', userId)
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(50);
|
.limit(50);
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -10,6 +10,7 @@ import { AuthProvider } from '@/contexts/AuthContext';
|
|||||||
import { DashboardProvider } from '@/contexts/DashboardContext';
|
import { DashboardProvider } from '@/contexts/DashboardContext';
|
||||||
import { ReferralProvider } from '@/contexts/ReferralContext';
|
import { ReferralProvider } from '@/contexts/ReferralContext';
|
||||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||||
|
import { P2PLayout } from '@/components/p2p/P2PLayout';
|
||||||
import { Toaster } from '@/components/ui/toaster';
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||||
import { initSentry } from '@/lib/sentry';
|
import { initSentry } from '@/lib/sentry';
|
||||||
@@ -180,27 +181,27 @@ function App() {
|
|||||||
} />
|
} />
|
||||||
<Route path="/p2p" element={
|
<Route path="/p2p" element={
|
||||||
<ProtectedRoute allowTelegramSession>
|
<ProtectedRoute allowTelegramSession>
|
||||||
<P2PPlatform />
|
<P2PLayout><P2PPlatform /></P2PLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/p2p/trade/:tradeId" element={
|
<Route path="/p2p/trade/:tradeId" element={
|
||||||
<ProtectedRoute allowTelegramSession>
|
<ProtectedRoute allowTelegramSession>
|
||||||
<P2PTrade />
|
<P2PLayout><P2PTrade /></P2PLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/p2p/orders" element={
|
<Route path="/p2p/orders" element={
|
||||||
<ProtectedRoute allowTelegramSession>
|
<ProtectedRoute allowTelegramSession>
|
||||||
<P2POrders />
|
<P2PLayout><P2POrders /></P2PLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/p2p/dispute/:disputeId" element={
|
<Route path="/p2p/dispute/:disputeId" element={
|
||||||
<ProtectedRoute allowTelegramSession>
|
<ProtectedRoute allowTelegramSession>
|
||||||
<P2PDispute />
|
<P2PLayout><P2PDispute /></P2PLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/p2p/merchant" element={
|
<Route path="/p2p/merchant" element={
|
||||||
<ProtectedRoute allowTelegramSession>
|
<ProtectedRoute allowTelegramSession>
|
||||||
<P2PMerchantDashboard />
|
<P2PLayout><P2PMerchantDashboard /></P2PLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/dex" element={
|
<Route path="/dex" element={
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Loader2, Shield, Zap } from 'lucide-react';
|
import { Loader2, Shield, Zap } from 'lucide-react';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
import { TradeModal } from './TradeModal';
|
import { TradeModal } from './TradeModal';
|
||||||
import { MerchantTierBadge } from './MerchantTierBadge';
|
import { MerchantTierBadge } from './MerchantTierBadge';
|
||||||
import { getUserReputation, type P2PFiatOffer, type P2PReputation } from '@shared/lib/p2p-fiat';
|
import { getUserReputation, type P2PFiatOffer, type P2PReputation } from '@shared/lib/p2p-fiat';
|
||||||
@@ -25,7 +25,7 @@ interface OfferWithReputation extends P2PFiatOffer {
|
|||||||
|
|
||||||
export function AdList({ type, filters }: AdListProps) {
|
export function AdList({ type, filters }: AdListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { userId } = useP2PIdentity();
|
||||||
const [offers, setOffers] = useState<OfferWithReputation[]>([]);
|
const [offers, setOffers] = useState<OfferWithReputation[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedOffer, setSelectedOffer] = useState<OfferWithReputation | null>(null);
|
const [selectedOffer, setSelectedOffer] = useState<OfferWithReputation | null>(null);
|
||||||
@@ -46,7 +46,7 @@ export function AdList({ type, filters }: AdListProps) {
|
|||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [type, user, filters]);
|
}, [type, userId, filters]);
|
||||||
|
|
||||||
const fetchOffers = async () => {
|
const fetchOffers = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -62,9 +62,9 @@ export function AdList({ type, filters }: AdListProps) {
|
|||||||
} else if (type === 'sell') {
|
} else if (type === 'sell') {
|
||||||
// Sell tab = show BUY offers (user wants to sell to buyers)
|
// Sell tab = show BUY offers (user wants to sell to buyers)
|
||||||
query = query.eq('ad_type', 'buy').eq('status', 'open').gt('remaining_amount', 0);
|
query = query.eq('ad_type', 'buy').eq('status', 'open').gt('remaining_amount', 0);
|
||||||
} else if (type === 'my-ads' && user) {
|
} else if (type === 'my-ads' && userId) {
|
||||||
// My offers - show all of user's offers
|
// My offers - show all of user's offers
|
||||||
query = query.eq('seller_id', user.id);
|
query = query.eq('seller_id', userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply filters if provided
|
// Apply filters if provided
|
||||||
@@ -263,16 +263,16 @@ export function AdList({ type, filters }: AdListProps) {
|
|||||||
|
|
||||||
{/* Action */}
|
{/* Action */}
|
||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-end gap-1">
|
||||||
{offer.seller_id === user?.id && type !== 'my-ads' && (
|
{offer.seller_id === userId && type !== 'my-ads' && (
|
||||||
<Badge variant="outline" className="text-xs bg-blue-500/10 text-blue-400 border-blue-500/30">
|
<Badge variant="outline" className="text-xs bg-blue-500/10 text-blue-400 border-blue-500/30">
|
||||||
{t('p2pAd.yourAd')}
|
{t('p2pAd.yourAd')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setSelectedOffer(offer)}
|
onClick={() => setSelectedOffer(offer)}
|
||||||
disabled={type === 'my-ads' || offer.seller_id === user?.id}
|
disabled={type === 'my-ads' || offer.seller_id === userId}
|
||||||
className="w-full md:w-auto"
|
className="w-full md:w-auto"
|
||||||
title={offer.seller_id === user?.id ? t('p2pAd.cantTradeOwnAd') : ''}
|
title={offer.seller_id === userId ? t('p2pAd.cantTradeOwnAd') : ''}
|
||||||
>
|
>
|
||||||
{type === 'buy' ? t('p2pAd.buyToken', { token: offer.token }) : t('p2pAd.sellToken', { token: offer.token })}
|
{type === 'buy' ? t('p2pAd.buyToken', { token: offer.token }) : t('p2pAd.sellToken', { token: offer.token })}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
Building2, AlertTriangle
|
Building2, AlertTriangle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { CryptoToken, FiatCurrency } from '@pezkuwi/lib/p2p-fiat';
|
import type { CryptoToken, FiatCurrency } from '@pezkuwi/lib/p2p-fiat';
|
||||||
@@ -80,19 +80,19 @@ export function BlockTrade() {
|
|||||||
const [requests, setRequests] = useState<BlockTradeRequest[]>([]);
|
const [requests, setRequests] = useState<BlockTradeRequest[]>([]);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { userId } = useP2PIdentity();
|
||||||
const fiatSymbol = SUPPORTED_FIATS.find(f => f.code === fiat)?.symbol || '';
|
const fiatSymbol = SUPPORTED_FIATS.find(f => f.code === fiat)?.symbol || '';
|
||||||
const minAmount = MINIMUM_BLOCK_AMOUNTS[token];
|
const minAmount = MINIMUM_BLOCK_AMOUNTS[token];
|
||||||
|
|
||||||
// Fetch user's block trade requests
|
// Fetch user's block trade requests
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!user) return;
|
if (!userId) return;
|
||||||
|
|
||||||
const fetchRequests = async () => {
|
const fetchRequests = async () => {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('p2p_block_trade_requests')
|
.from('p2p_block_trade_requests')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', userId)
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
if (!error && data) {
|
if (!error && data) {
|
||||||
@@ -101,10 +101,10 @@ export function BlockTrade() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchRequests();
|
fetchRequests();
|
||||||
}, [user]);
|
}, [userId]);
|
||||||
|
|
||||||
const handleSubmitRequest = async () => {
|
const handleSubmitRequest = async () => {
|
||||||
if (!user) {
|
if (!userId) {
|
||||||
toast.error(t('p2pBlock.loginRequired'));
|
toast.error(t('p2pBlock.loginRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,7 @@ export function BlockTrade() {
|
|||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('p2p_block_trade_requests')
|
.from('p2p_block_trade_requests')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: user.id,
|
user_id: userId,
|
||||||
type,
|
type,
|
||||||
token,
|
token,
|
||||||
fiat_currency: fiat,
|
fiat_currency: fiat,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||||
import { useWallet } from '@/contexts/WalletContext';
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -9,8 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { supabase } from '@/lib/supabase';
|
|
||||||
import {
|
import {
|
||||||
|
createFiatOffer,
|
||||||
getPaymentMethods,
|
getPaymentMethods,
|
||||||
validatePaymentDetails,
|
validatePaymentDetails,
|
||||||
type PaymentMethod,
|
type PaymentMethod,
|
||||||
@@ -24,8 +24,8 @@ interface CreateAdProps {
|
|||||||
|
|
||||||
export function CreateAd({ onAdCreated }: CreateAdProps) {
|
export function CreateAd({ onAdCreated }: CreateAdProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { selectedAccount } = usePezkuwi();
|
||||||
const { account } = useWallet();
|
const { userId } = useP2PIdentity();
|
||||||
|
|
||||||
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
||||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<PaymentMethod | null>(null);
|
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<PaymentMethod | null>(null);
|
||||||
@@ -77,11 +77,8 @@ export function CreateAd({ onAdCreated }: CreateAdProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateAd = async () => {
|
const handleCreateAd = async () => {
|
||||||
console.log('🔥 handleCreateAd called', { account, user: user?.id });
|
if (!selectedAccount || !userId) {
|
||||||
|
|
||||||
if (!account || !user) {
|
|
||||||
toast.error(t('p2p.connectWalletAndLogin'));
|
toast.error(t('p2p.connectWalletAndLogin'));
|
||||||
console.log('❌ No account or user', { account, user });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,37 +126,21 @@ export function CreateAd({ onAdCreated }: CreateAdProps) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Insert offer into Supabase
|
await createFiatOffer({
|
||||||
// Note: payment_details_encrypted is stored as JSON string (encryption handled server-side in prod)
|
userId,
|
||||||
const { data, error } = await supabase
|
sellerWallet: selectedAccount.address,
|
||||||
.from('p2p_fiat_offers')
|
|
||||||
.insert({
|
|
||||||
seller_id: user.id,
|
|
||||||
seller_wallet: account,
|
|
||||||
ad_type: adType,
|
|
||||||
token,
|
token,
|
||||||
amount_crypto: cryptoAmt,
|
amountCrypto: cryptoAmt,
|
||||||
remaining_amount: cryptoAmt,
|
fiatCurrency,
|
||||||
fiat_currency: fiatCurrency,
|
fiatAmount: fiatAmt,
|
||||||
fiat_amount: fiatAmt,
|
paymentMethodId: selectedPaymentMethod.id,
|
||||||
payment_method_id: selectedPaymentMethod.id,
|
paymentDetails,
|
||||||
payment_details_encrypted: JSON.stringify(paymentDetails),
|
timeLimitMinutes: timeLimit,
|
||||||
time_limit_minutes: timeLimit,
|
minOrderAmount: minOrderAmount ? parseFloat(minOrderAmount) : undefined,
|
||||||
min_order_amount: minOrderAmount ? parseFloat(minOrderAmount) : null,
|
maxOrderAmount: maxOrderAmount ? parseFloat(maxOrderAmount) : undefined,
|
||||||
max_order_amount: maxOrderAmount ? parseFloat(maxOrderAmount) : null,
|
adType,
|
||||||
status: 'open'
|
});
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('❌ Supabase error:', error);
|
|
||||||
toast.error(error.message || t('p2pCreate.failedToCreate'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ Offer created successfully:', data);
|
|
||||||
toast.success(t('p2pCreate.adCreated'));
|
|
||||||
onAdCreated();
|
onAdCreated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (import.meta.env.DEV) console.error('Create ad error:', error);
|
if (import.meta.env.DEV) console.error('Create ad error:', error);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||||
import { useWallet } from '@/contexts/WalletContext';
|
import { useWallet } from '@/contexts/WalletContext';
|
||||||
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +51,7 @@ export function DepositModal({ isOpen, onClose, onSuccess }: DepositModalProps)
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { api, selectedAccount } = usePezkuwi();
|
const { api, selectedAccount } = usePezkuwi();
|
||||||
const { balances, signTransaction } = useWallet();
|
const { balances, signTransaction } = useWallet();
|
||||||
|
const { identityId } = useP2PIdentity();
|
||||||
|
|
||||||
const [step, setStep] = useState<DepositStep>('select');
|
const [step, setStep] = useState<DepositStep>('select');
|
||||||
const [token, setToken] = useState<CryptoToken>('HEZ');
|
const [token, setToken] = useState<CryptoToken>('HEZ');
|
||||||
@@ -192,6 +194,7 @@ export function DepositModal({ isOpen, onClose, onSuccess }: DepositModalProps)
|
|||||||
token,
|
token,
|
||||||
expectedAmount: depositAmount,
|
expectedAmount: depositAmount,
|
||||||
walletAddress: selectedAccount?.address,
|
walletAddress: selectedAccount?.address,
|
||||||
|
identityId,
|
||||||
...(blockNumber ? { blockNumber } : {})
|
...(blockNumber ? { blockNumber } : {})
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { AlertTriangle, Upload, X, FileText } from 'lucide-react';
|
import { AlertTriangle, Upload, X, FileText } from 'lucide-react';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
|
|
||||||
interface DisputeModalProps {
|
interface DisputeModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -62,6 +63,7 @@ export function DisputeModal({
|
|||||||
isBuyer,
|
isBuyer,
|
||||||
}: DisputeModalProps) {
|
}: DisputeModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { userId } = useP2PIdentity();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
@@ -172,15 +174,14 @@ export function DisputeModal({
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
if (!userId) throw new Error('Not authenticated');
|
||||||
if (!user) throw new Error('Not authenticated');
|
|
||||||
|
|
||||||
// Create dispute
|
// Create dispute
|
||||||
const { data: dispute, error: disputeError } = await supabase
|
const { data: dispute, error: disputeError } = await supabase
|
||||||
.from('p2p_disputes')
|
.from('p2p_disputes')
|
||||||
.insert({
|
.insert({
|
||||||
trade_id: tradeId,
|
trade_id: tradeId,
|
||||||
opened_by: user.id,
|
opened_by: userId,
|
||||||
reason,
|
reason,
|
||||||
description,
|
description,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
@@ -197,7 +198,7 @@ export function DisputeModal({
|
|||||||
// Insert evidence records
|
// Insert evidence records
|
||||||
const evidenceRecords = evidenceUrls.map((url, index) => ({
|
const evidenceRecords = evidenceUrls.map((url, index) => ({
|
||||||
dispute_id: dispute.id,
|
dispute_id: dispute.id,
|
||||||
uploaded_by: user.id,
|
uploaded_by: userId,
|
||||||
evidence_type: evidenceFiles[index].type === 'image' ? 'screenshot' : 'document',
|
evidence_type: evidenceFiles[index].type === 'image' ? 'screenshot' : 'document',
|
||||||
file_url: url,
|
file_url: url,
|
||||||
description: `Evidence ${index + 1}`,
|
description: `Evidence ${index + 1}`,
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Zap, ArrowRight, Shield, Clock, Star, AlertCircle, CheckCircle2 } from 'lucide-react';
|
import { Zap, ArrowRight, Shield, Clock, Star, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||||
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -69,7 +70,8 @@ export function ExpressMode({ onTradeStarted }: ExpressModeProps) {
|
|||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { selectedAccount } = usePezkuwi();
|
||||||
|
const { userId } = useP2PIdentity();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Calculate conversion
|
// Calculate conversion
|
||||||
@@ -140,8 +142,8 @@ export function ExpressMode({ onTradeStarted }: ExpressModeProps) {
|
|||||||
|
|
||||||
// Handle express trade
|
// Handle express trade
|
||||||
const handleExpressTrade = async () => {
|
const handleExpressTrade = async () => {
|
||||||
if (!user) {
|
if (!userId || !selectedAccount) {
|
||||||
toast.error(t('p2pExpress.loginRequired'));
|
toast.error(t('p2p.connectWalletAndLogin'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,8 +162,8 @@ export function ExpressMode({ onTradeStarted }: ExpressModeProps) {
|
|||||||
// Accept the best offer
|
// Accept the best offer
|
||||||
const { data: result, error } = await supabase.rpc('accept_p2p_offer', {
|
const { data: result, error } = await supabase.rpc('accept_p2p_offer', {
|
||||||
p_offer_id: bestOffer.id,
|
p_offer_id: bestOffer.id,
|
||||||
p_buyer_id: user.id,
|
p_buyer_id: userId,
|
||||||
p_buyer_wallet: '', // Will be set from user profile
|
p_buyer_wallet: selectedAccount.address,
|
||||||
p_amount: cryptoAmount
|
p_amount: cryptoAmount
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -342,7 +344,7 @@ export function ExpressMode({ onTradeStarted }: ExpressModeProps) {
|
|||||||
className="w-full bg-yellow-500 hover:bg-yellow-600 text-black font-semibold"
|
className="w-full bg-yellow-500 hover:bg-yellow-600 text-black font-semibold"
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={handleExpressTrade}
|
onClick={handleExpressTrade}
|
||||||
disabled={!bestOffer || isLoading || isProcessing || !user}
|
disabled={!bestOffer || isLoading || isProcessing || !userId || !selectedAccount}
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<>{t('p2pExpress.processing')}</>
|
<>{t('p2pExpress.processing')}</>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Unlock
|
Unlock
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getInternalBalances, type InternalBalance } from '@shared/lib/p2p-fiat';
|
import { getInternalBalances, type InternalBalance } from '@shared/lib/p2p-fiat';
|
||||||
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
|
|
||||||
interface InternalBalanceCardProps {
|
interface InternalBalanceCardProps {
|
||||||
onDeposit?: () => void;
|
onDeposit?: () => void;
|
||||||
@@ -21,13 +22,15 @@ interface InternalBalanceCardProps {
|
|||||||
|
|
||||||
export function InternalBalanceCard({ onDeposit, onWithdraw }: InternalBalanceCardProps) {
|
export function InternalBalanceCard({ onDeposit, onWithdraw }: InternalBalanceCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { userId } = useP2PIdentity();
|
||||||
const [balances, setBalances] = useState<InternalBalance[]>([]);
|
const [balances, setBalances] = useState<InternalBalance[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
const fetchBalances = async () => {
|
const fetchBalances = async () => {
|
||||||
|
if (!userId) return;
|
||||||
try {
|
try {
|
||||||
const data = await getInternalBalances();
|
const data = await getInternalBalances(userId);
|
||||||
setBalances(data);
|
setBalances(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch balances:', error);
|
console.error('Failed to fetch balances:', error);
|
||||||
@@ -39,7 +42,7 @@ export function InternalBalanceCard({ onDeposit, onWithdraw }: InternalBalanceCa
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBalances();
|
fetchBalances();
|
||||||
}, []);
|
}, [userId]);
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
@@ -107,6 +108,7 @@ const TIER_COLORS = {
|
|||||||
|
|
||||||
export function MerchantApplication() {
|
export function MerchantApplication() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { userId } = useP2PIdentity();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [requirements, setRequirements] = useState<TierRequirements[]>(DEFAULT_REQUIREMENTS);
|
const [requirements, setRequirements] = useState<TierRequirements[]>(DEFAULT_REQUIREMENTS);
|
||||||
const [userStats, setUserStats] = useState<UserStats>({ completed_trades: 0, completion_rate: 0, volume_30d: 0 });
|
const [userStats, setUserStats] = useState<UserStats>({ completed_trades: 0, completion_rate: 0, volume_30d: 0 });
|
||||||
@@ -123,8 +125,7 @@ export function MerchantApplication() {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
if (!userId) return;
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
// Fetch tier requirements
|
// Fetch tier requirements
|
||||||
const { data: reqData } = await supabase
|
const { data: reqData } = await supabase
|
||||||
@@ -140,21 +141,21 @@ export function MerchantApplication() {
|
|||||||
const { data: repData } = await supabase
|
const { data: repData } = await supabase
|
||||||
.from('p2p_reputation')
|
.from('p2p_reputation')
|
||||||
.select('completed_trades')
|
.select('completed_trades')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', userId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
// Fetch merchant stats
|
// Fetch merchant stats
|
||||||
const { data: statsData } = await supabase
|
const { data: statsData } = await supabase
|
||||||
.from('p2p_merchant_stats')
|
.from('p2p_merchant_stats')
|
||||||
.select('completion_rate_30d, total_volume_30d')
|
.select('completion_rate_30d, total_volume_30d')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', userId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
// Fetch current tier
|
// Fetch current tier
|
||||||
const { data: tierData } = await supabase
|
const { data: tierData } = await supabase
|
||||||
.from('p2p_merchant_tiers')
|
.from('p2p_merchant_tiers')
|
||||||
.select('tier, application_status, applied_for_tier')
|
.select('tier, application_status, applied_for_tier')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', userId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
setUserStats({
|
setUserStats({
|
||||||
@@ -207,11 +208,10 @@ export function MerchantApplication() {
|
|||||||
|
|
||||||
setApplying(true);
|
setApplying(true);
|
||||||
try {
|
try {
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
if (!userId) throw new Error('Not authenticated');
|
||||||
if (!user) throw new Error('Not authenticated');
|
|
||||||
|
|
||||||
const { data, error } = await supabase.rpc('apply_for_tier_upgrade', {
|
const { data, error } = await supabase.rpc('apply_for_tier_upgrade', {
|
||||||
p_user_id: user.id,
|
p_user_id: userId,
|
||||||
p_target_tier: selectedTier
|
p_target_tier: selectedTier
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
|
||||||
interface Notification {
|
interface Notification {
|
||||||
@@ -40,7 +40,7 @@ interface Notification {
|
|||||||
export function NotificationBell() {
|
export function NotificationBell() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { userId } = useP2PIdentity();
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -48,13 +48,13 @@ export function NotificationBell() {
|
|||||||
|
|
||||||
// Fetch notifications
|
// Fetch notifications
|
||||||
const fetchNotifications = useCallback(async () => {
|
const fetchNotifications = useCallback(async () => {
|
||||||
if (!user) return;
|
if (!userId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('p2p_notifications')
|
.from('p2p_notifications')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', userId)
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(20);
|
.limit(20);
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ export function NotificationBell() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [userId]);
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -76,17 +76,17 @@ export function NotificationBell() {
|
|||||||
|
|
||||||
// Real-time subscription
|
// Real-time subscription
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!userId) return;
|
||||||
|
|
||||||
const channel = supabase
|
const channel = supabase
|
||||||
.channel(`notifications-${user.id}`)
|
.channel(`notifications-${userId}`)
|
||||||
.on(
|
.on(
|
||||||
'postgres_changes',
|
'postgres_changes',
|
||||||
{
|
{
|
||||||
event: 'INSERT',
|
event: 'INSERT',
|
||||||
schema: 'public',
|
schema: 'public',
|
||||||
table: 'p2p_notifications',
|
table: 'p2p_notifications',
|
||||||
filter: `user_id=eq.${user.id}`,
|
filter: `user_id=eq.${userId}`,
|
||||||
},
|
},
|
||||||
(payload) => {
|
(payload) => {
|
||||||
const newNotif = payload.new as Notification;
|
const newNotif = payload.new as Notification;
|
||||||
@@ -99,7 +99,7 @@ export function NotificationBell() {
|
|||||||
return () => {
|
return () => {
|
||||||
supabase.removeChannel(channel);
|
supabase.removeChannel(channel);
|
||||||
};
|
};
|
||||||
}, [user]);
|
}, [userId]);
|
||||||
|
|
||||||
// Mark as read
|
// Mark as read
|
||||||
const markAsRead = async (notificationId: string) => {
|
const markAsRead = async (notificationId: string) => {
|
||||||
@@ -120,13 +120,13 @@ export function NotificationBell() {
|
|||||||
|
|
||||||
// Mark all as read
|
// Mark all as read
|
||||||
const markAllAsRead = async () => {
|
const markAllAsRead = async () => {
|
||||||
if (!user) return;
|
if (!userId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await supabase
|
await supabase
|
||||||
.from('p2p_notifications')
|
.from('p2p_notifications')
|
||||||
.update({ is_read: true })
|
.update({ is_read: true })
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', userId)
|
||||||
.eq('is_read', false);
|
.eq('is_read', false);
|
||||||
|
|
||||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||||
@@ -184,7 +184,7 @@ export function NotificationBell() {
|
|||||||
return t('p2p.daysAgo', { count: days });
|
return t('p2p.daysAgo', { count: days });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!user) return null;
|
if (!userId) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { WithdrawModal } from './WithdrawModal';
|
|||||||
import { ExpressMode } from './ExpressMode';
|
import { ExpressMode } from './ExpressMode';
|
||||||
import { BlockTrade } from './BlockTrade';
|
import { BlockTrade } from './BlockTrade';
|
||||||
import { DEFAULT_FILTERS, type P2PFilters } from './types';
|
import { DEFAULT_FILTERS, type P2PFilters } from './types';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
|
||||||
interface UserStats {
|
interface UserStats {
|
||||||
@@ -34,7 +34,7 @@ export function P2PDashboard() {
|
|||||||
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
|
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
|
||||||
const [balanceRefreshKey, setBalanceRefreshKey] = useState(0);
|
const [balanceRefreshKey, setBalanceRefreshKey] = useState(0);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { userId } = useP2PIdentity();
|
||||||
|
|
||||||
const handleBalanceUpdated = () => {
|
const handleBalanceUpdated = () => {
|
||||||
setBalanceRefreshKey(prev => prev + 1);
|
setBalanceRefreshKey(prev => prev + 1);
|
||||||
@@ -43,28 +43,28 @@ export function P2PDashboard() {
|
|||||||
// Fetch user stats
|
// Fetch user stats
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
if (!user) return;
|
if (!userId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Count active trades
|
// Count active trades
|
||||||
const { count: activeCount } = await supabase
|
const { count: activeCount } = await supabase
|
||||||
.from('p2p_fiat_trades')
|
.from('p2p_fiat_trades')
|
||||||
.select('*', { count: 'exact', head: true })
|
.select('*', { count: 'exact', head: true })
|
||||||
.or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`)
|
.or(`seller_id.eq.${userId},buyer_id.eq.${userId}`)
|
||||||
.in('status', ['pending', 'payment_sent']);
|
.in('status', ['pending', 'payment_sent']);
|
||||||
|
|
||||||
// Count completed trades
|
// Count completed trades
|
||||||
const { count: completedCount } = await supabase
|
const { count: completedCount } = await supabase
|
||||||
.from('p2p_fiat_trades')
|
.from('p2p_fiat_trades')
|
||||||
.select('*', { count: 'exact', head: true })
|
.select('*', { count: 'exact', head: true })
|
||||||
.or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`)
|
.or(`seller_id.eq.${userId},buyer_id.eq.${userId}`)
|
||||||
.eq('status', 'completed');
|
.eq('status', 'completed');
|
||||||
|
|
||||||
// Calculate total volume
|
// Calculate total volume
|
||||||
const { data: trades } = await supabase
|
const { data: trades } = await supabase
|
||||||
.from('p2p_fiat_trades')
|
.from('p2p_fiat_trades')
|
||||||
.select('fiat_amount')
|
.select('fiat_amount')
|
||||||
.or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`)
|
.or(`seller_id.eq.${userId},buyer_id.eq.${userId}`)
|
||||||
.eq('status', 'completed');
|
.eq('status', 'completed');
|
||||||
|
|
||||||
const totalVolume = trades?.reduce((sum, t) => sum + (t.fiat_amount || 0), 0) || 0;
|
const totalVolume = trades?.reduce((sum, t) => sum + (t.fiat_amount || 0), 0) || 0;
|
||||||
@@ -80,7 +80,7 @@ export function P2PDashboard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchStats();
|
fetchStats();
|
||||||
}, [user]);
|
}, [userId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8 max-w-7xl">
|
<div className="container mx-auto px-4 py-8 max-w-7xl">
|
||||||
@@ -120,7 +120,7 @@ export function P2PDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards and Balance Card */}
|
{/* Stats Cards and Balance Card */}
|
||||||
{user && (
|
{userId && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
|
||||||
{/* Internal Balance Card - Takes more space */}
|
{/* Internal Balance Card - Takes more space */}
|
||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { P2PIdentityProvider, useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
|
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Loader2, Shield, UserCheck, Wallet, Home } from 'lucide-react';
|
||||||
|
|
||||||
|
function IdentityGate({ children }: { children: ReactNode }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { selectedAccount } = usePezkuwi();
|
||||||
|
const { hasIdentity, loading, applyForVisa } = useP2PIdentity();
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-muted-foreground">{t('p2pIdentity.resolving')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No wallet connected
|
||||||
|
if (!selectedAccount) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-16 max-w-lg">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<Wallet className="h-8 w-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>{t('p2pIdentity.connectWalletTitle')}</CardTitle>
|
||||||
|
<CardDescription>{t('p2pIdentity.connectWalletDesc')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex justify-center">
|
||||||
|
<Button variant="outline" onClick={() => navigate('/')}>
|
||||||
|
<Home className="h-4 w-4 mr-2" />
|
||||||
|
{t('p2p.backToHome')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has identity - show content with identity badge
|
||||||
|
if (hasIdentity) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No identity - show visa application
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-16 max-w-lg">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-yellow-500/10 flex items-center justify-center">
|
||||||
|
<Shield className="h-8 w-8 text-yellow-500" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>{t('p2pIdentity.identityRequired')}</CardTitle>
|
||||||
|
<CardDescription>{t('p2pIdentity.identityRequiredDesc')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="p-4 rounded-lg border bg-muted/30">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<UserCheck className="h-5 w-5 text-green-500" />
|
||||||
|
<span className="font-medium">{t('p2pIdentity.citizenOption')}</span>
|
||||||
|
<Badge className="bg-green-500/20 text-green-500 border-green-500/30">{t('p2pIdentity.fullAccess')}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('p2pIdentity.citizenDesc')}</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-3 w-full"
|
||||||
|
onClick={() => navigate('/be-citizen')}
|
||||||
|
>
|
||||||
|
{t('p2pIdentity.applyCitizenship')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-lg border bg-muted/30">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<Shield className="h-5 w-5 text-blue-500" />
|
||||||
|
<span className="font-medium">{t('p2pIdentity.visaOption')}</span>
|
||||||
|
<Badge className="bg-blue-500/20 text-blue-500 border-blue-500/30">{t('p2pIdentity.limitedAccess')}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('p2pIdentity.visaDesc')}</p>
|
||||||
|
<Button
|
||||||
|
className="mt-3 w-full"
|
||||||
|
onClick={async () => {
|
||||||
|
const result = await applyForVisa();
|
||||||
|
if (!result) {
|
||||||
|
// toast handled inside applyForVisa or context
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('p2pIdentity.applyVisa')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="ghost" className="w-full" onClick={() => navigate('/')}>
|
||||||
|
<Home className="h-4 w-4 mr-2" />
|
||||||
|
{t('p2p.backToHome')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function P2PLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<P2PIdentityProvider>
|
||||||
|
<IdentityGate>{children}</IdentityGate>
|
||||||
|
</P2PIdentityProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Star, Loader2, ThumbsUp, ThumbsDown } from 'lucide-react';
|
import { Star, Loader2, ThumbsUp, ThumbsDown } from 'lucide-react';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
@@ -34,14 +34,14 @@ export function RatingModal({
|
|||||||
isBuyer,
|
isBuyer,
|
||||||
}: RatingModalProps) {
|
}: RatingModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { userId } = useP2PIdentity();
|
||||||
const [rating, setRating] = useState(0);
|
const [rating, setRating] = useState(0);
|
||||||
const [hoveredRating, setHoveredRating] = useState(0);
|
const [hoveredRating, setHoveredRating] = useState(0);
|
||||||
const [review, setReview] = useState('');
|
const [review, setReview] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!user || rating === 0) {
|
if (!userId || rating === 0) {
|
||||||
toast.error(t('p2pRating.selectRatingError'));
|
toast.error(t('p2pRating.selectRatingError'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ export function RatingModal({
|
|||||||
.from('p2p_ratings')
|
.from('p2p_ratings')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('trade_id', tradeId)
|
.eq('trade_id', tradeId)
|
||||||
.eq('rater_id', user.id)
|
.eq('rater_id', userId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (existingRating) {
|
if (existingRating) {
|
||||||
@@ -66,7 +66,7 @@ export function RatingModal({
|
|||||||
// Insert rating
|
// Insert rating
|
||||||
const { error: ratingError } = await supabase.from('p2p_ratings').insert({
|
const { error: ratingError } = await supabase.from('p2p_ratings').insert({
|
||||||
trade_id: tradeId,
|
trade_id: tradeId,
|
||||||
rater_id: user.id,
|
rater_id: userId,
|
||||||
rated_id: counterpartyId,
|
rated_id: counterpartyId,
|
||||||
rating,
|
rating,
|
||||||
review: review.trim() || null,
|
review: review.trim() || null,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Bot,
|
Bot,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
@@ -43,7 +43,7 @@ export function TradeChat({
|
|||||||
isTradeActive,
|
isTradeActive,
|
||||||
}: TradeChatProps) {
|
}: TradeChatProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { userId } = useP2PIdentity();
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [newMessage, setNewMessage] = useState('');
|
const [newMessage, setNewMessage] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -72,9 +72,9 @@ export function TradeChat({
|
|||||||
setMessages(data || []);
|
setMessages(data || []);
|
||||||
|
|
||||||
// Mark messages as read
|
// Mark messages as read
|
||||||
if (user && data && data.length > 0) {
|
if (userId && data && data.length > 0) {
|
||||||
const unreadIds = data
|
const unreadIds = data
|
||||||
.filter(m => m.sender_id !== user.id && !m.is_read)
|
.filter(m => m.sender_id !== userId && !m.is_read)
|
||||||
.map(m => m.id);
|
.map(m => m.id);
|
||||||
|
|
||||||
if (unreadIds.length > 0) {
|
if (unreadIds.length > 0) {
|
||||||
@@ -89,7 +89,7 @@ export function TradeChat({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [tradeId, user]);
|
}, [tradeId, userId]);
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,7 +117,7 @@ export function TradeChat({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mark as read if from counterparty
|
// Mark as read if from counterparty
|
||||||
if (user && newMsg.sender_id !== user.id) {
|
if (userId && newMsg.sender_id !== userId) {
|
||||||
supabase
|
supabase
|
||||||
.from('p2p_messages')
|
.from('p2p_messages')
|
||||||
.update({ is_read: true })
|
.update({ is_read: true })
|
||||||
@@ -130,7 +130,7 @@ export function TradeChat({
|
|||||||
return () => {
|
return () => {
|
||||||
supabase.removeChannel(channel);
|
supabase.removeChannel(channel);
|
||||||
};
|
};
|
||||||
}, [tradeId, user]);
|
}, [tradeId, userId]);
|
||||||
|
|
||||||
// Scroll to bottom on new messages
|
// Scroll to bottom on new messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -139,7 +139,7 @@ export function TradeChat({
|
|||||||
|
|
||||||
// Send message
|
// Send message
|
||||||
const handleSendMessage = async () => {
|
const handleSendMessage = async () => {
|
||||||
if (!newMessage.trim() || !user || sending) return;
|
if (!newMessage.trim() || !userId || sending) return;
|
||||||
|
|
||||||
const messageText = newMessage.trim();
|
const messageText = newMessage.trim();
|
||||||
setNewMessage('');
|
setNewMessage('');
|
||||||
@@ -148,7 +148,7 @@ export function TradeChat({
|
|||||||
try {
|
try {
|
||||||
const { error } = await supabase.from('p2p_messages').insert({
|
const { error } = await supabase.from('p2p_messages').insert({
|
||||||
trade_id: tradeId,
|
trade_id: tradeId,
|
||||||
sender_id: user.id,
|
sender_id: userId,
|
||||||
message: messageText,
|
message: messageText,
|
||||||
message_type: 'text',
|
message_type: 'text',
|
||||||
is_read: false,
|
is_read: false,
|
||||||
@@ -186,7 +186,7 @@ export function TradeChat({
|
|||||||
// Upload image
|
// Upload image
|
||||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file || !user) return;
|
if (!file || !userId) return;
|
||||||
|
|
||||||
// Validate file
|
// Validate file
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
@@ -218,7 +218,7 @@ export function TradeChat({
|
|||||||
// Insert message with image
|
// Insert message with image
|
||||||
const { error: msgError } = await supabase.from('p2p_messages').insert({
|
const { error: msgError } = await supabase.from('p2p_messages').insert({
|
||||||
trade_id: tradeId,
|
trade_id: tradeId,
|
||||||
sender_id: user.id,
|
sender_id: userId,
|
||||||
message: t('p2pChat.sentImage'),
|
message: t('p2pChat.sentImage'),
|
||||||
message_type: 'image',
|
message_type: 'image',
|
||||||
attachment_url: urlData.publicUrl,
|
attachment_url: urlData.publicUrl,
|
||||||
@@ -258,7 +258,7 @@ export function TradeChat({
|
|||||||
|
|
||||||
// Render message
|
// Render message
|
||||||
const renderMessage = (message: Message) => {
|
const renderMessage = (message: Message) => {
|
||||||
const isOwn = message.sender_id === user?.id;
|
const isOwn = message.sender_id === userId;
|
||||||
const isSystem = message.message_type === 'system';
|
const isSystem = message.message_type === 'system';
|
||||||
|
|
||||||
if (isSystem) {
|
if (isSystem) {
|
||||||
@@ -332,9 +332,9 @@ export function TradeChat({
|
|||||||
<CardHeader className="py-3 px-4 border-b border-gray-800">
|
<CardHeader className="py-3 px-4 border-b border-gray-800">
|
||||||
<CardTitle className="text-white text-base flex items-center gap-2">
|
<CardTitle className="text-white text-base flex items-center gap-2">
|
||||||
<span>{t('p2pChat.title')}</span>
|
<span>{t('p2pChat.title')}</span>
|
||||||
{messages.filter(m => m.sender_id !== user?.id && !m.is_read).length > 0 && (
|
{messages.filter(m => m.sender_id !== userId && !m.is_read).length > 0 && (
|
||||||
<span className="px-1.5 py-0.5 text-xs bg-green-500 text-white rounded-full">
|
<span className="px-1.5 py-0.5 text-xs bg-green-500 text-white rounded-full">
|
||||||
{messages.filter(m => m.sender_id !== user?.id && !m.is_read).length}
|
{messages.filter(m => m.sender_id !== userId && !m.is_read).length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { Loader2, AlertTriangle, Clock } from 'lucide-react';
|
import { Loader2, AlertTriangle, Clock } from 'lucide-react';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
|
||||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||||
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { acceptFiatOffer, type P2PFiatOffer } from '@shared/lib/p2p-fiat';
|
import { acceptFiatOffer, type P2PFiatOffer } from '@shared/lib/p2p-fiat';
|
||||||
|
|
||||||
@@ -27,8 +27,8 @@ interface TradeModalProps {
|
|||||||
export function TradeModal({ offer, onClose }: TradeModalProps) {
|
export function TradeModal({ offer, onClose }: TradeModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { selectedAccount } = usePezkuwi();
|
||||||
const { api, selectedAccount } = usePezkuwi();
|
const { userId } = useP2PIdentity();
|
||||||
const [amount, setAmount] = useState('');
|
const [amount, setAmount] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -41,13 +41,13 @@ export function TradeModal({ offer, onClose }: TradeModalProps) {
|
|||||||
const meetsMaxOrder = !offer.max_order_amount || cryptoAmount <= offer.max_order_amount;
|
const meetsMaxOrder = !offer.max_order_amount || cryptoAmount <= offer.max_order_amount;
|
||||||
|
|
||||||
const handleInitiateTrade = async () => {
|
const handleInitiateTrade = async () => {
|
||||||
if (!api || !selectedAccount || !user) {
|
if (!selectedAccount || !userId) {
|
||||||
toast.error(t('p2p.connectWalletAndLogin'));
|
toast.error(t('p2p.connectWalletAndLogin'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent self-trading
|
// Prevent self-trading
|
||||||
if (offer.seller_id === user.id) {
|
if (offer.seller_id === userId) {
|
||||||
toast.error(t('p2pTrade.cannotTradeOwn'));
|
toast.error(t('p2pTrade.cannotTradeOwn'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -71,9 +71,9 @@ export function TradeModal({ offer, onClose }: TradeModalProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const tradeId = await acceptFiatOffer({
|
const tradeId = await acceptFiatOffer({
|
||||||
api,
|
|
||||||
account: selectedAccount,
|
|
||||||
offerId: offer.id,
|
offerId: offer.id,
|
||||||
|
buyerUserId: userId,
|
||||||
|
buyerWallet: selectedAccount.address,
|
||||||
amount: cryptoAmount
|
amount: cryptoAmount
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
Info
|
Info
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||||
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
getInternalBalances,
|
getInternalBalances,
|
||||||
@@ -51,6 +52,7 @@ type WithdrawStep = 'form' | 'confirm' | 'success';
|
|||||||
export function WithdrawModal({ isOpen, onClose, onSuccess }: WithdrawModalProps) {
|
export function WithdrawModal({ isOpen, onClose, onSuccess }: WithdrawModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { selectedAccount } = usePezkuwi();
|
const { selectedAccount } = usePezkuwi();
|
||||||
|
const { userId } = useP2PIdentity();
|
||||||
|
|
||||||
const [step, setStep] = useState<WithdrawStep>('form');
|
const [step, setStep] = useState<WithdrawStep>('form');
|
||||||
const [token, setToken] = useState<CryptoToken>('HEZ');
|
const [token, setToken] = useState<CryptoToken>('HEZ');
|
||||||
@@ -80,9 +82,10 @@ export function WithdrawModal({ isOpen, onClose, onSuccess }: WithdrawModalProps
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
if (!userId) return;
|
||||||
const [balanceData, historyData] = await Promise.all([
|
const [balanceData, historyData] = await Promise.all([
|
||||||
getInternalBalances(),
|
getInternalBalances(userId),
|
||||||
getDepositWithdrawHistory()
|
getDepositWithdrawHistory(userId)
|
||||||
]);
|
]);
|
||||||
setBalances(balanceData);
|
setBalances(balanceData);
|
||||||
// Filter for pending withdrawal requests
|
// Filter for pending withdrawal requests
|
||||||
@@ -180,7 +183,8 @@ export function WithdrawModal({ isOpen, onClose, onSuccess }: WithdrawModalProps
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const withdrawAmount = parseFloat(amount);
|
const withdrawAmount = parseFloat(amount);
|
||||||
const id = await requestWithdraw(token, withdrawAmount, walletAddress);
|
if (!userId) throw new Error('Identity required');
|
||||||
|
const id = await requestWithdraw(userId, token, withdrawAmount, walletAddress);
|
||||||
setRequestId(id);
|
setRequestId(id);
|
||||||
setStep('success');
|
setStep('success');
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { useDashboard } from '@/contexts/DashboardContext';
|
||||||
|
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||||
|
import { identityToUUID } from '@shared/lib/identity';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
|
||||||
|
interface P2PIdentity {
|
||||||
|
/** UUID v5 derived from identityId, used as user_id in DB */
|
||||||
|
userId: string | null;
|
||||||
|
/** Full citizen number (#42-0-832967) or visa number (V-123456) */
|
||||||
|
identityId: string | null;
|
||||||
|
/** Wallet address (SS58) */
|
||||||
|
walletAddress: string | null;
|
||||||
|
/** Whether user is a citizen with on-chain NFT */
|
||||||
|
isCitizen: boolean;
|
||||||
|
/** Whether user has an off-chain visa */
|
||||||
|
isVisa: boolean;
|
||||||
|
/** Whether user has any P2P identity (citizen or visa) */
|
||||||
|
hasIdentity: boolean;
|
||||||
|
/** Loading state during identity resolution */
|
||||||
|
loading: boolean;
|
||||||
|
/** Apply for a visa (for non-citizens) */
|
||||||
|
applyForVisa: () => Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const P2PIdentityContext = createContext<P2PIdentity | undefined>(undefined);
|
||||||
|
|
||||||
|
export function P2PIdentityProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { citizenNumber, nftDetails, loading: dashboardLoading } = useDashboard();
|
||||||
|
const { selectedAccount } = usePezkuwi();
|
||||||
|
|
||||||
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
|
const [identityId, setIdentityId] = useState<string | null>(null);
|
||||||
|
const [visaNumber, setVisaNumber] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const walletAddress = selectedAccount?.address || null;
|
||||||
|
const isCitizen = !!(nftDetails.citizenNFT && citizenNumber !== 'N/A');
|
||||||
|
const isVisa = !!visaNumber;
|
||||||
|
const hasIdentity = isCitizen || isVisa;
|
||||||
|
|
||||||
|
// Resolve identity when wallet/dashboard data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (dashboardLoading) return;
|
||||||
|
|
||||||
|
const resolve = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isCitizen) {
|
||||||
|
// Citizen: use full citizen number as identity
|
||||||
|
const fullCitizenNumber = `#${nftDetails.citizenNFT!.collectionId}-${nftDetails.citizenNFT!.itemId}-${citizenNumber}`;
|
||||||
|
setIdentityId(fullCitizenNumber);
|
||||||
|
const uuid = await identityToUUID(fullCitizenNumber);
|
||||||
|
setUserId(uuid);
|
||||||
|
setVisaNumber(null);
|
||||||
|
} else if (walletAddress) {
|
||||||
|
// Non-citizen: check for existing visa
|
||||||
|
const { data: visa } = await supabase
|
||||||
|
.from('p2p_visa')
|
||||||
|
.select('visa_number, status')
|
||||||
|
.eq('wallet_address', walletAddress)
|
||||||
|
.eq('status', 'active')
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (visa) {
|
||||||
|
setVisaNumber(visa.visa_number);
|
||||||
|
setIdentityId(visa.visa_number);
|
||||||
|
const uuid = await identityToUUID(visa.visa_number);
|
||||||
|
setUserId(uuid);
|
||||||
|
} else {
|
||||||
|
setVisaNumber(null);
|
||||||
|
setIdentityId(null);
|
||||||
|
setUserId(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIdentityId(null);
|
||||||
|
setUserId(null);
|
||||||
|
setVisaNumber(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('P2P identity resolution error:', error);
|
||||||
|
setIdentityId(null);
|
||||||
|
setUserId(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
}, [isCitizen, citizenNumber, nftDetails.citizenNFT, walletAddress, dashboardLoading]);
|
||||||
|
|
||||||
|
const applyForVisa = async (): Promise<string | null> => {
|
||||||
|
if (!walletAddress) return null;
|
||||||
|
if (isCitizen) return null; // Citizens don't need visas
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.rpc('issue_p2p_visa', {
|
||||||
|
p_wallet_address: walletAddress,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
if (data?.success) {
|
||||||
|
const vn = data.visa_number as string;
|
||||||
|
setVisaNumber(vn);
|
||||||
|
setIdentityId(vn);
|
||||||
|
const uuid = await identityToUUID(vn);
|
||||||
|
setUserId(uuid);
|
||||||
|
return vn;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to apply for visa:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<P2PIdentityContext.Provider value={{
|
||||||
|
userId,
|
||||||
|
identityId,
|
||||||
|
walletAddress,
|
||||||
|
isCitizen,
|
||||||
|
isVisa,
|
||||||
|
hasIdentity,
|
||||||
|
loading,
|
||||||
|
applyForVisa,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</P2PIdentityContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useP2PIdentity() {
|
||||||
|
const context = useContext(P2PIdentityContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useP2PIdentity must be used within a P2PIdentityProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { formatAddress } from '@pezkuwi/utils/formatting';
|
import { formatAddress } from '@pezkuwi/utils/formatting';
|
||||||
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
|
|
||||||
interface DisputeDetails {
|
interface DisputeDetails {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -105,11 +106,11 @@ export default function P2PDispute() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { userId } = useP2PIdentity();
|
||||||
|
|
||||||
const [dispute, setDispute] = useState<DisputeDetails | null>(null);
|
const [dispute, setDispute] = useState<DisputeDetails | null>(null);
|
||||||
const [evidence, setEvidence] = useState<Evidence[]>([]);
|
const [evidence, setEvidence] = useState<Evidence[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -118,10 +119,6 @@ export default function P2PDispute() {
|
|||||||
if (!disputeId) return;
|
if (!disputeId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get current user
|
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
|
||||||
setCurrentUserId(user?.id || null);
|
|
||||||
|
|
||||||
// Fetch dispute with trade info
|
// Fetch dispute with trade info
|
||||||
const { data: disputeData, error: disputeError } = await supabase
|
const { data: disputeData, error: disputeError } = await supabase
|
||||||
.from('p2p_disputes')
|
.from('p2p_disputes')
|
||||||
@@ -199,7 +196,7 @@ export default function P2PDispute() {
|
|||||||
|
|
||||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (!files || files.length === 0 || !dispute || !currentUserId) return;
|
if (!files || files.length === 0 || !dispute || !userId) return;
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
|
||||||
@@ -224,7 +221,7 @@ export default function P2PDispute() {
|
|||||||
// Insert evidence record
|
// Insert evidence record
|
||||||
await supabase.from('p2p_dispute_evidence').insert({
|
await supabase.from('p2p_dispute_evidence').insert({
|
||||||
dispute_id: dispute.id,
|
dispute_id: dispute.id,
|
||||||
uploaded_by: currentUserId,
|
uploaded_by: userId,
|
||||||
evidence_type: file.type.startsWith('image/') ? 'screenshot' : 'document',
|
evidence_type: file.type.startsWith('image/') ? 'screenshot' : 'document',
|
||||||
file_url: urlData.publicUrl,
|
file_url: urlData.publicUrl,
|
||||||
description: file.name,
|
description: file.name,
|
||||||
@@ -244,11 +241,11 @@ export default function P2PDispute() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isParticipant = dispute?.trade &&
|
const isParticipant = dispute?.trade &&
|
||||||
(dispute.trade.buyer_id === currentUserId || dispute.trade.seller_id === currentUserId);
|
(dispute.trade.buyer_id === userId || dispute.trade.seller_id === userId);
|
||||||
|
|
||||||
const isBuyer = dispute?.trade?.buyer_id === currentUserId;
|
const isBuyer = dispute?.trade?.buyer_id === userId;
|
||||||
const isSeller = dispute?.trade?.seller_id === currentUserId;
|
const isSeller = dispute?.trade?.seller_id === userId;
|
||||||
const isOpener = dispute?.opened_by === currentUserId;
|
const isOpener = dispute?.opened_by === userId;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -444,7 +441,7 @@ export default function P2PDispute() {
|
|||||||
{evidence.map((item) => {
|
{evidence.map((item) => {
|
||||||
const isImage = item.evidence_type === 'screenshot' ||
|
const isImage = item.evidence_type === 'screenshot' ||
|
||||||
item.file_url.match(/\.(jpg|jpeg|png|gif|webp)$/i);
|
item.file_url.match(/\.(jpg|jpeg|png|gif|webp)$/i);
|
||||||
const isMyEvidence = item.uploaded_by === currentUserId;
|
const isMyEvidence = item.uploaded_by === userId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
import { MerchantTierBadge } from '@/components/p2p/MerchantTierBadge';
|
import { MerchantTierBadge } from '@/components/p2p/MerchantTierBadge';
|
||||||
import { MerchantApplication } from '@/components/p2p/MerchantApplication';
|
import { MerchantApplication } from '@/components/p2p/MerchantApplication';
|
||||||
import { CreateAd } from '@/components/p2p/CreateAd';
|
import { CreateAd } from '@/components/p2p/CreateAd';
|
||||||
@@ -91,6 +92,7 @@ interface ChartDataPoint {
|
|||||||
export default function P2PMerchantDashboard() {
|
export default function P2PMerchantDashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { userId } = useP2PIdentity();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [stats, setStats] = useState<MerchantStats | null>(null);
|
const [stats, setStats] = useState<MerchantStats | null>(null);
|
||||||
const [tierInfo, setTierInfo] = useState<MerchantTier | null>(null);
|
const [tierInfo, setTierInfo] = useState<MerchantTier | null>(null);
|
||||||
@@ -111,19 +113,18 @@ export default function P2PMerchantDashboard() {
|
|||||||
|
|
||||||
// Fetch merchant data
|
// Fetch merchant data
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
if (!userId) {
|
||||||
try {
|
setLoading(false);
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
|
||||||
if (!user) {
|
|
||||||
navigate('/login');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
// Fetch stats
|
// Fetch stats
|
||||||
const { data: statsData } = await supabase
|
const { data: statsData } = await supabase
|
||||||
.from('p2p_merchant_stats')
|
.from('p2p_merchant_stats')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', userId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (statsData) {
|
if (statsData) {
|
||||||
@@ -134,7 +135,7 @@ export default function P2PMerchantDashboard() {
|
|||||||
const { data: tierData } = await supabase
|
const { data: tierData } = await supabase
|
||||||
.from('p2p_merchant_tiers')
|
.from('p2p_merchant_tiers')
|
||||||
.select('tier, max_pending_orders, max_order_amount, featured_ads_allowed')
|
.select('tier, max_pending_orders, max_order_amount, featured_ads_allowed')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', userId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (tierData) {
|
if (tierData) {
|
||||||
@@ -145,7 +146,7 @@ export default function P2PMerchantDashboard() {
|
|||||||
const { data: adsData } = await supabase
|
const { data: adsData } = await supabase
|
||||||
.from('p2p_fiat_offers')
|
.from('p2p_fiat_offers')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('seller_id', user.id)
|
.eq('seller_id', userId)
|
||||||
.in('status', ['open', 'paused'])
|
.in('status', ['open', 'paused'])
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
@@ -160,7 +161,7 @@ export default function P2PMerchantDashboard() {
|
|||||||
const { data: tradesData } = await supabase
|
const { data: tradesData } = await supabase
|
||||||
.from('p2p_fiat_trades')
|
.from('p2p_fiat_trades')
|
||||||
.select('created_at, fiat_amount, status')
|
.select('created_at, fiat_amount, status')
|
||||||
.or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`)
|
.or(`seller_id.eq.${userId},buyer_id.eq.${userId}`)
|
||||||
.gte('created_at', thirtyDaysAgo.toISOString())
|
.gte('created_at', thirtyDaysAgo.toISOString())
|
||||||
.order('created_at', { ascending: true });
|
.order('created_at', { ascending: true });
|
||||||
|
|
||||||
@@ -197,7 +198,7 @@ export default function P2PMerchantDashboard() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [navigate]);
|
}, [userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -290,13 +291,14 @@ export default function P2PMerchantDashboard() {
|
|||||||
|
|
||||||
// Save auto-reply message
|
// Save auto-reply message
|
||||||
const saveAutoReply = async () => {
|
const saveAutoReply = async () => {
|
||||||
|
if (!userId) return;
|
||||||
setSavingAutoReply(true);
|
setSavingAutoReply(true);
|
||||||
try {
|
try {
|
||||||
// Save to all active ads
|
// Save to all active ads
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('p2p_fiat_offers')
|
.from('p2p_fiat_offers')
|
||||||
.update({ auto_reply_message: autoReplyMessage })
|
.update({ auto_reply_message: autoReplyMessage })
|
||||||
.eq('seller_id', (await supabase.auth.getUser()).data.user?.id);
|
.eq('seller_id', userId);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
FileText,
|
FileText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
import { type P2PFiatTrade, type P2PFiatOffer } from '@shared/lib/p2p-fiat';
|
import { type P2PFiatTrade, type P2PFiatOffer } from '@shared/lib/p2p-fiat';
|
||||||
@@ -34,7 +34,7 @@ interface TradeWithOffer extends P2PFiatTrade {
|
|||||||
export default function P2POrders() {
|
export default function P2POrders() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { userId } = useP2PIdentity();
|
||||||
|
|
||||||
const [trades, setTrades] = useState<TradeWithOffer[]>([]);
|
const [trades, setTrades] = useState<TradeWithOffer[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -42,7 +42,7 @@ export default function P2POrders() {
|
|||||||
|
|
||||||
// Fetch user's trades
|
// Fetch user's trades
|
||||||
const fetchTrades = async () => {
|
const fetchTrades = async () => {
|
||||||
if (!user) {
|
if (!userId) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ export default function P2POrders() {
|
|||||||
const { data: tradesData, error } = await supabase
|
const { data: tradesData, error } = await supabase
|
||||||
.from('p2p_fiat_trades')
|
.from('p2p_fiat_trades')
|
||||||
.select('*')
|
.select('*')
|
||||||
.or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`)
|
.or(`seller_id.eq.${userId},buyer_id.eq.${userId}`)
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
@@ -86,7 +86,7 @@ export default function P2POrders() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTrades();
|
fetchTrades();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user]);
|
}, [userId]);
|
||||||
|
|
||||||
// Filter trades by status
|
// Filter trades by status
|
||||||
const activeTrades = trades.filter(t =>
|
const activeTrades = trades.filter(t =>
|
||||||
@@ -149,7 +149,7 @@ export default function P2POrders() {
|
|||||||
|
|
||||||
// Render trade card
|
// Render trade card
|
||||||
const renderTradeCard = (trade: TradeWithOffer) => {
|
const renderTradeCard = (trade: TradeWithOffer) => {
|
||||||
const isBuyer = trade.buyer_id === user?.id;
|
const isBuyer = trade.buyer_id === userId;
|
||||||
const counterpartyWallet = isBuyer
|
const counterpartyWallet = isBuyer
|
||||||
? trade.offer?.seller_wallet || 'Unknown'
|
? trade.offer?.seller_wallet || 'Unknown'
|
||||||
: trade.buyer_wallet;
|
: trade.buyer_wallet;
|
||||||
@@ -245,7 +245,7 @@ export default function P2POrders() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!user) {
|
if (!userId) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||||
<Card className="bg-gray-900 border-gray-800">
|
<Card className="bg-gray-900 border-gray-800">
|
||||||
@@ -253,7 +253,7 @@ export default function P2POrders() {
|
|||||||
<AlertTriangle className="w-16 h-16 text-yellow-500 mx-auto mb-4" />
|
<AlertTriangle className="w-16 h-16 text-yellow-500 mx-auto mb-4" />
|
||||||
<h2 className="text-xl font-semibold text-white mb-2">{t('p2p.loginRequired')}</h2>
|
<h2 className="text-xl font-semibold text-white mb-2">{t('p2p.loginRequired')}</h2>
|
||||||
<p className="text-gray-400 mb-6">{t('p2p.loginToView')}</p>
|
<p className="text-gray-400 mb-6">{t('p2p.loginToView')}</p>
|
||||||
<Button onClick={() => navigate('/login')}>{t('p2p.logIn')}</Button>
|
<Button onClick={() => navigate('/p2p')}>{t('p2pTrade.backToP2P')}</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
-30
@@ -33,13 +33,13 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Star,
|
Star,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
import {
|
import {
|
||||||
markPaymentSent,
|
markPaymentSent,
|
||||||
confirmPaymentReceived,
|
confirmPaymentReceived,
|
||||||
|
cancelTrade,
|
||||||
getUserReputation,
|
getUserReputation,
|
||||||
type P2PFiatTrade,
|
type P2PFiatTrade,
|
||||||
type P2PFiatOffer,
|
type P2PFiatOffer,
|
||||||
@@ -74,8 +74,7 @@ export default function P2PTrade() {
|
|||||||
const { tradeId } = useParams<{ tradeId: string }>();
|
const { tradeId } = useParams<{ tradeId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { userId } = useP2PIdentity();
|
||||||
const { api, selectedAccount } = usePezkuwi();
|
|
||||||
|
|
||||||
const [trade, setTrade] = useState<TradeWithDetails | null>(null);
|
const [trade, setTrade] = useState<TradeWithDetails | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -91,8 +90,8 @@ export default function P2PTrade() {
|
|||||||
const [cancelReason, setCancelReason] = useState('');
|
const [cancelReason, setCancelReason] = useState('');
|
||||||
|
|
||||||
// Determine user role
|
// Determine user role
|
||||||
const isSeller = trade?.seller_id === user?.id;
|
const isSeller = trade?.seller_id === userId;
|
||||||
const isBuyer = trade?.buyer_id === user?.id;
|
const isBuyer = trade?.buyer_id === userId;
|
||||||
const isParticipant = isSeller || isBuyer;
|
const isParticipant = isSeller || isBuyer;
|
||||||
|
|
||||||
// Fetch trade details
|
// Fetch trade details
|
||||||
@@ -265,7 +264,7 @@ export default function P2PTrade() {
|
|||||||
|
|
||||||
// Handle mark as paid
|
// Handle mark as paid
|
||||||
const handleMarkAsPaid = async () => {
|
const handleMarkAsPaid = async () => {
|
||||||
if (!trade || !user) return;
|
if (!trade || !userId) return;
|
||||||
|
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -293,14 +292,14 @@ export default function P2PTrade() {
|
|||||||
|
|
||||||
// Handle release crypto
|
// Handle release crypto
|
||||||
const handleReleaseCrypto = async () => {
|
const handleReleaseCrypto = async () => {
|
||||||
if (!trade || !api || !selectedAccount) {
|
if (!trade || !userId) {
|
||||||
toast.error(t('p2p.connectWallet'));
|
toast.error(t('p2p.connectWalletAndLogin'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
try {
|
try {
|
||||||
await confirmPaymentReceived(api, selectedAccount, trade.id);
|
await confirmPaymentReceived(trade.id, userId);
|
||||||
toast.success(t('p2pTrade.cryptoReleasedToast'));
|
toast.success(t('p2pTrade.cryptoReleasedToast'));
|
||||||
fetchTrade();
|
fetchTrade();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -312,29 +311,11 @@ export default function P2PTrade() {
|
|||||||
|
|
||||||
// Handle cancel trade
|
// Handle cancel trade
|
||||||
const handleCancelTrade = async () => {
|
const handleCancelTrade = async () => {
|
||||||
if (!trade) return;
|
if (!trade || !userId) return;
|
||||||
|
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase
|
await cancelTrade(trade.id, userId, cancelReason || undefined);
|
||||||
.from('p2p_fiat_trades')
|
|
||||||
.update({
|
|
||||||
status: 'cancelled',
|
|
||||||
cancelled_by: user?.id,
|
|
||||||
cancel_reason: cancelReason,
|
|
||||||
})
|
|
||||||
.eq('id', trade.id);
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
// Restore offer remaining amount
|
|
||||||
await supabase
|
|
||||||
.from('p2p_fiat_offers')
|
|
||||||
.update({
|
|
||||||
remaining_amount: (trade.offer?.remaining_amount || 0) + trade.crypto_amount,
|
|
||||||
status: 'open',
|
|
||||||
})
|
|
||||||
.eq('id', trade.offer_id);
|
|
||||||
|
|
||||||
setShowCancelModal(false);
|
setShowCancelModal(false);
|
||||||
toast.success(t('p2pTrade.tradeCancelledToast'));
|
toast.success(t('p2pTrade.tradeCancelledToast'));
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ const RPC_WS = 'wss://rpc.pezkuwichain.io'
|
|||||||
// Token decimals
|
// Token decimals
|
||||||
const DECIMALS = 12
|
const DECIMALS = 12
|
||||||
|
|
||||||
// Generate deterministic UUID v5 from wallet address
|
// Generate deterministic UUID v5 from identity ID (citizen number or visa number)
|
||||||
async function walletToUUID(walletAddress: string): Promise<string> {
|
async function identityToUUID(identityId: string): Promise<string> {
|
||||||
const NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'
|
const NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'
|
||||||
const data = new TextEncoder().encode(walletAddress)
|
const data = new TextEncoder().encode(identityId)
|
||||||
const namespaceBytes = new Uint8Array(16)
|
const namespaceBytes = new Uint8Array(16)
|
||||||
const hex = NAMESPACE.replace(/-/g, '')
|
const hex = NAMESPACE.replace(/-/g, '')
|
||||||
for (let i = 0; i < 16; i++) {
|
for (let i = 0; i < 16; i++) {
|
||||||
@@ -66,6 +66,7 @@ interface DepositRequest {
|
|||||||
token: 'HEZ' | 'PEZ'
|
token: 'HEZ' | 'PEZ'
|
||||||
expectedAmount: number
|
expectedAmount: number
|
||||||
walletAddress: string
|
walletAddress: string
|
||||||
|
identityId: string
|
||||||
blockNumber?: number
|
blockNumber?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,11 +372,11 @@ serve(async (req) => {
|
|||||||
const serviceClient = createClient(supabaseUrl, supabaseServiceKey)
|
const serviceClient = createClient(supabaseUrl, supabaseServiceKey)
|
||||||
|
|
||||||
const body: DepositRequest = await req.json()
|
const body: DepositRequest = await req.json()
|
||||||
const { txHash, token, expectedAmount, walletAddress, blockNumber } = body
|
const { txHash, token, expectedAmount, walletAddress, identityId, blockNumber } = body
|
||||||
|
|
||||||
if (!txHash || !token || !expectedAmount || !walletAddress) {
|
if (!txHash || !token || !expectedAmount || !walletAddress || !identityId) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ success: false, error: 'Missing required fields: txHash, token, expectedAmount, walletAddress' }),
|
JSON.stringify({ success: false, error: 'Missing required fields: txHash, token, expectedAmount, walletAddress, identityId' }),
|
||||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -414,8 +415,8 @@ serve(async (req) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map wallet address to deterministic UUID
|
// Map identity (citizen/visa number) to deterministic UUID
|
||||||
const userId = await walletToUUID(walletAddress)
|
const userId = await identityToUUID(identityId)
|
||||||
|
|
||||||
// Create or update deposit request
|
// Create or update deposit request
|
||||||
const { data: depositRequest, error: requestError } = await serviceClient
|
const { data: depositRequest, error: requestError } = await serviceClient
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
-- P2P Visa System
|
||||||
|
-- Provides identity for non-citizen P2P traders
|
||||||
|
-- Citizens use their on-chain Citizen Number (from NFT)
|
||||||
|
-- Non-citizens apply for a Visa (off-chain, stored in Supabase)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.p2p_visa (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
visa_number TEXT UNIQUE NOT NULL,
|
||||||
|
wallet_address TEXT UNIQUE NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
trust_level INTEGER NOT NULL DEFAULT 1,
|
||||||
|
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
expires_at TIMESTAMPTZ DEFAULT (now() + interval '1 year'),
|
||||||
|
metadata JSONB DEFAULT '{}'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_visa_wallet ON public.p2p_visa(wallet_address);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_visa_status ON public.p2p_visa(status);
|
||||||
|
|
||||||
|
-- Generate unique visa number: V-XXXXXX (6 digits)
|
||||||
|
CREATE OR REPLACE FUNCTION generate_visa_number()
|
||||||
|
RETURNS TEXT
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
num TEXT;
|
||||||
|
BEGIN
|
||||||
|
LOOP
|
||||||
|
num := 'V-' || lpad(floor(random() * 1000000)::text, 6, '0');
|
||||||
|
EXIT WHEN NOT EXISTS (SELECT 1 FROM public.p2p_visa WHERE visa_number = num);
|
||||||
|
END LOOP;
|
||||||
|
RETURN num;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Issue a visa for a wallet address (returns the visa record)
|
||||||
|
CREATE OR REPLACE FUNCTION issue_p2p_visa(p_wallet_address TEXT)
|
||||||
|
RETURNS JSONB
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_visa_number TEXT;
|
||||||
|
v_result JSONB;
|
||||||
|
BEGIN
|
||||||
|
-- Check if wallet already has a visa
|
||||||
|
IF EXISTS (SELECT 1 FROM public.p2p_visa WHERE wallet_address = p_wallet_address AND status = 'active') THEN
|
||||||
|
SELECT jsonb_build_object(
|
||||||
|
'success', true,
|
||||||
|
'visa_number', visa_number,
|
||||||
|
'already_exists', true
|
||||||
|
) INTO v_result
|
||||||
|
FROM public.p2p_visa
|
||||||
|
WHERE wallet_address = p_wallet_address AND status = 'active';
|
||||||
|
RETURN v_result;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Generate unique visa number
|
||||||
|
v_visa_number := generate_visa_number();
|
||||||
|
|
||||||
|
-- Insert new visa
|
||||||
|
INSERT INTO public.p2p_visa (visa_number, wallet_address)
|
||||||
|
VALUES (v_visa_number, p_wallet_address);
|
||||||
|
|
||||||
|
RETURN jsonb_build_object(
|
||||||
|
'success', true,
|
||||||
|
'visa_number', v_visa_number,
|
||||||
|
'already_exists', false
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- RLS: service role only (P2P operations go through edge functions)
|
||||||
|
ALTER TABLE public.p2p_visa ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Service role full access on p2p_visa"
|
||||||
|
ON public.p2p_visa
|
||||||
|
FOR ALL
|
||||||
|
USING (auth.role() = 'service_role');
|
||||||
|
|
||||||
|
-- Allow anon/authenticated to read their own visa by wallet address
|
||||||
|
CREATE POLICY "Users can read own visa"
|
||||||
|
ON public.p2p_visa
|
||||||
|
FOR SELECT
|
||||||
|
USING (true);
|
||||||
Reference in New Issue
Block a user