mirror of
https://github.com/pezkuwichain/pezkuwi-telegram-miniapp.git
synced 2026-06-19 22:41:05 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 698c014682 |
@@ -1,47 +0,0 @@
|
||||
name: Auto Merge
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'pull_request'
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Find and merge master → main PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
HEAD_BRANCH="${{ github.event.workflow_run.head_branch }}"
|
||||
echo "Workflow ran on branch: $HEAD_BRANCH"
|
||||
|
||||
if [ "$HEAD_BRANCH" != "master" ]; then
|
||||
echo "Not a master branch PR, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PR_NUMBER=$(gh pr list \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--base main \
|
||||
--head master \
|
||||
--state open \
|
||||
--json number \
|
||||
--jq '.[0].number')
|
||||
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "No open PR from master to main found, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Merging PR #$PR_NUMBER"
|
||||
gh pr merge "$PR_NUMBER" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--merge \
|
||||
--delete-branch=false
|
||||
@@ -1,40 +0,0 @@
|
||||
name: Auto PR (master → main)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
create-pr:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create or update PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Check if there's already an open PR from master to main
|
||||
EXISTING_PR=$(gh pr list --base main --head master --state open --json number --jq '.[0].number')
|
||||
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
echo "PR #$EXISTING_PR already exists — new commits will appear automatically"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Creating new PR: master → main"
|
||||
if gh pr create \
|
||||
--base main \
|
||||
--head master \
|
||||
--title "Sync: master → main" \
|
||||
--body "Automated PR to sync master branch changes to main.
|
||||
|
||||
This PR was created automatically and will be merged once CI checks pass."; then
|
||||
echo "PR created successfully"
|
||||
else
|
||||
echo "PR creation skipped (branches may already be in sync)"
|
||||
fi
|
||||
@@ -55,11 +55,3 @@ jobs:
|
||||
source: 'dist/*'
|
||||
target: '/var/www/telegram.pezkiwi.app'
|
||||
strip_components: 1
|
||||
|
||||
- name: Cleanup old assets on VPS
|
||||
uses: appleboy/ssh-action@v1.0.0
|
||||
with:
|
||||
host: ${{ secrets.VPS2_HOST }}
|
||||
username: ${{ secrets.VPS2_USER }}
|
||||
key: ${{ secrets.VPS2_SSH_KEY }}
|
||||
script: bash /opt/cleanup-miniapp.sh
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Sync main and master branches
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Sync branches
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
echo "main was updated, syncing master..."
|
||||
git checkout master
|
||||
git reset --hard origin/main
|
||||
git push origin master --force
|
||||
elif [ "${{ github.ref }}" = "refs/heads/master" ]; then
|
||||
echo "master was updated, syncing main..."
|
||||
git checkout main
|
||||
git reset --hard origin/master
|
||||
git push origin main --force
|
||||
fi
|
||||
@@ -1,578 +0,0 @@
|
||||
/**
|
||||
* In-App Citizenship Application Modal
|
||||
* Self-contained modal with 3 steps: form → processing → success
|
||||
* Uses the already-connected wallet keypair (no seed phrase needed)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { X, Plus, Trash2, Shield, CheckCircle, Clock, ArrowRight, AlertCircle } from 'lucide-react';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { useTelegram } from '@/hooks/useTelegram';
|
||||
import { useTranslation } from '@/i18n';
|
||||
import { KurdistanSun } from '@/components/KurdistanSun';
|
||||
import { formatAddress } from '@/lib/utils';
|
||||
import type { Region, MaritalStatus, ChildInfo } from '@/lib/citizenship';
|
||||
import {
|
||||
calculateIdentityHash,
|
||||
saveCitizenshipLocally,
|
||||
uploadToIPFS,
|
||||
applyCitizenship,
|
||||
} from '@/lib/citizenship';
|
||||
|
||||
interface CitizenshipModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Step = 'form' | 'processing' | 'success';
|
||||
|
||||
const REGIONS: { value: Region; labelKey: string }[] = [
|
||||
{ value: 'bakur', labelKey: 'citizen.regionBakur' },
|
||||
{ value: 'basur', labelKey: 'citizen.regionBasur' },
|
||||
{ value: 'rojava', labelKey: 'citizen.regionRojava' },
|
||||
{ value: 'rojhelat', labelKey: 'citizen.regionRojhelat' },
|
||||
{ value: 'kurdistan_a_sor', labelKey: 'citizen.regionKurdistanASor' },
|
||||
{ value: 'diaspora', labelKey: 'citizen.regionDiaspora' },
|
||||
];
|
||||
|
||||
export function CitizenshipModal({ isOpen, onClose }: CitizenshipModalProps) {
|
||||
const { peopleApi, keypair, address } = useWallet();
|
||||
const { hapticImpact, hapticNotification } = useTelegram();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [step, setStep] = useState<Step>('form');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Form fields
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [fatherName, setFatherName] = useState('');
|
||||
const [grandfatherName, setGrandfatherName] = useState('');
|
||||
const [motherName, setMotherName] = useState('');
|
||||
const [tribe, setTribe] = useState('');
|
||||
const [maritalStatus, setMaritalStatus] = useState<MaritalStatus>('nezewici');
|
||||
const [childrenCount, setChildrenCount] = useState(0);
|
||||
const [children, setChildren] = useState<ChildInfo[]>([]);
|
||||
const [region, setRegion] = useState<Region | ''>('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [profession, setProfession] = useState('');
|
||||
const [referrerAddress, setReferrerAddress] = useState('');
|
||||
const [consent, setConsent] = useState(false);
|
||||
|
||||
// Success data
|
||||
const [identityHash, setIdentityHash] = useState('');
|
||||
|
||||
// Processing cancel ref
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setStep('form');
|
||||
setError('');
|
||||
setFullName('');
|
||||
setFatherName('');
|
||||
setGrandfatherName('');
|
||||
setMotherName('');
|
||||
setTribe('');
|
||||
setMaritalStatus('nezewici');
|
||||
setChildrenCount(0);
|
||||
setChildren([]);
|
||||
setRegion('');
|
||||
setEmail('');
|
||||
setProfession('');
|
||||
setReferrerAddress('');
|
||||
setConsent(false);
|
||||
setIdentityHash('');
|
||||
cancelledRef.current = false;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// --- Form handlers ---
|
||||
|
||||
const handleMaritalChange = (status: MaritalStatus) => {
|
||||
hapticImpact('light');
|
||||
setMaritalStatus(status);
|
||||
if (status === 'nezewici') {
|
||||
setChildrenCount(0);
|
||||
setChildren([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChildrenCountChange = (count: number) => {
|
||||
const c = Math.max(0, Math.min(20, count));
|
||||
setChildrenCount(c);
|
||||
setChildren((prev) => {
|
||||
if (c > prev.length) {
|
||||
return [
|
||||
...prev,
|
||||
...Array.from({ length: c - prev.length }, () => ({ name: '', birthYear: 2000 })),
|
||||
];
|
||||
}
|
||||
return prev.slice(0, c);
|
||||
});
|
||||
};
|
||||
|
||||
const updateChild = (index: number, field: keyof ChildInfo, value: string | number) => {
|
||||
setChildren((prev) =>
|
||||
prev.map((child, i) => (i === index ? { ...child, [field]: value } : child))
|
||||
);
|
||||
};
|
||||
|
||||
const addChild = () => {
|
||||
hapticImpact('light');
|
||||
handleChildrenCountChange(childrenCount + 1);
|
||||
};
|
||||
|
||||
const removeChild = (index: number) => {
|
||||
hapticImpact('light');
|
||||
setChildren((prev) => prev.filter((_, i) => i !== index));
|
||||
setChildrenCount((prev) => prev - 1);
|
||||
};
|
||||
|
||||
const handleFormSubmit = () => {
|
||||
setError('');
|
||||
|
||||
if (
|
||||
!fullName ||
|
||||
!fatherName ||
|
||||
!grandfatherName ||
|
||||
!motherName ||
|
||||
!tribe ||
|
||||
!region ||
|
||||
!email ||
|
||||
!profession
|
||||
) {
|
||||
setError(t('citizen.fillAllFields'));
|
||||
hapticNotification('error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!consent) {
|
||||
setError(t('citizen.acceptConsent'));
|
||||
hapticNotification('error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!peopleApi) {
|
||||
setError(t('citizen.peopleChainNotConnected'));
|
||||
hapticNotification('error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!keypair || !address) {
|
||||
setError(t('citizen.walletNotConnected'));
|
||||
hapticNotification('error');
|
||||
return;
|
||||
}
|
||||
|
||||
hapticImpact('medium');
|
||||
setStep('processing');
|
||||
};
|
||||
|
||||
// --- Processing logic ---
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 'processing') return;
|
||||
|
||||
cancelledRef.current = false;
|
||||
|
||||
const process = async () => {
|
||||
try {
|
||||
if (!peopleApi || !keypair || !address) {
|
||||
setError(t('citizen.walletNotConnected'));
|
||||
setStep('form');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build citizenship data for local save
|
||||
const citizenshipData = {
|
||||
fullName,
|
||||
fatherName,
|
||||
grandfatherName,
|
||||
motherName,
|
||||
tribe,
|
||||
maritalStatus,
|
||||
childrenCount: maritalStatus === 'zewici' ? childrenCount : undefined,
|
||||
children: maritalStatus === 'zewici' && children.length > 0 ? children : undefined,
|
||||
region: region as Region,
|
||||
email,
|
||||
profession,
|
||||
referrerAddress: referrerAddress || undefined,
|
||||
walletAddress: address,
|
||||
seedPhrase: '',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Mock IPFS upload
|
||||
const ipfsCid = await uploadToIPFS(citizenshipData);
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
// Calculate identity hash
|
||||
const hash = calculateIdentityHash(fullName, email, [ipfsCid]);
|
||||
if (cancelledRef.current) return;
|
||||
setIdentityHash(hash);
|
||||
|
||||
// Save encrypted data locally
|
||||
saveCitizenshipLocally(citizenshipData);
|
||||
|
||||
// Sign and submit extrinsic
|
||||
const result = await applyCitizenship(peopleApi, keypair, hash, referrerAddress || null);
|
||||
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
if (result.success) {
|
||||
hapticNotification('success');
|
||||
setStep('success');
|
||||
} else {
|
||||
hapticNotification('error');
|
||||
setError(result.error || t('citizen.submissionFailed'));
|
||||
setStep('form');
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelledRef.current) return;
|
||||
hapticNotification('error');
|
||||
setError(err instanceof Error ? err.message : t('citizen.submissionFailed'));
|
||||
setStep('form');
|
||||
}
|
||||
};
|
||||
|
||||
process();
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const inputClass = 'w-full px-4 py-3 bg-muted rounded-xl text-sm';
|
||||
const labelClass = 'text-sm text-muted-foreground mb-1 block';
|
||||
|
||||
// --- Processing Step ---
|
||||
if (step === 'processing') {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md bg-card rounded-2xl shadow-xl border border-border p-6">
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-6">
|
||||
<KurdistanSun size={100} />
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-lg font-medium">{t('citizen.applyingCitizenship')}</p>
|
||||
<p className="text-sm text-muted-foreground">{fullName}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Success Step ---
|
||||
if (step === 'success') {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex flex-col items-center p-6 space-y-6">
|
||||
{/* Success Icon */}
|
||||
<div className="w-20 h-20 bg-green-500/20 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="w-12 h-12 text-green-500" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-xl font-bold">{t('citizen.applicationSuccess')}</h2>
|
||||
|
||||
{/* 3-Step Process */}
|
||||
<div className="w-full space-y-3">
|
||||
<div className="flex items-start gap-3 p-3 bg-green-500/10 border border-green-500/30 rounded-xl">
|
||||
<CheckCircle className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-green-400">{t('citizen.stepApplicationSent')}</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-xl">
|
||||
<Clock className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-yellow-400">{t('citizen.stepReferrerApproval')}</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 bg-muted/50 border border-border rounded-xl">
|
||||
<ArrowRight className="w-5 h-5 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-muted-foreground">{t('citizen.stepConfirm')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Application Info */}
|
||||
<div className="w-full bg-muted/50 rounded-2xl p-5 space-y-4 border border-border">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t('citizen.identityHash')}</p>
|
||||
<p className="text-sm font-mono break-all">{formatAddress(identityHash)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t('citizen.walletAddress')}</p>
|
||||
<p className="text-sm font-mono">{formatAddress(address || '')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next Steps Info */}
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t('citizen.nextStepsInfo')}
|
||||
</p>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full py-3 bg-primary text-primary-foreground rounded-xl font-semibold"
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Form Step ---
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border sticky top-0 bg-card z-10">
|
||||
<h2 className="text-lg font-semibold">{t('citizen.pageTitle')}</h2>
|
||||
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted">
|
||||
<X className="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Privacy Notice */}
|
||||
<div className="flex gap-3 p-3 bg-blue-500/10 border border-blue-500/30 rounded-xl">
|
||||
<Shield className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-blue-300">{t('citizen.privacyNotice')}</p>
|
||||
</div>
|
||||
|
||||
{/* Full Name */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('citizen.fullName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder={t('citizen.fullNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Father's Name */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('citizen.fatherName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fatherName}
|
||||
onChange={(e) => setFatherName(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder={t('citizen.fatherNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grandfather's Name */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('citizen.grandfatherName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={grandfatherName}
|
||||
onChange={(e) => setGrandfatherName(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder={t('citizen.grandfatherNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mother's Name */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('citizen.motherName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={motherName}
|
||||
onChange={(e) => setMotherName(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder={t('citizen.motherNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tribe */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('citizen.tribe')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tribe}
|
||||
onChange={(e) => setTribe(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder={t('citizen.tribePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Marital Status */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('citizen.maritalStatus')}</label>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMaritalChange('nezewici')}
|
||||
className={`flex-1 py-3 rounded-xl text-sm font-medium transition-colors ${
|
||||
maritalStatus === 'nezewici'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{t('citizen.single')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMaritalChange('zewici')}
|
||||
className={`flex-1 py-3 rounded-xl text-sm font-medium transition-colors ${
|
||||
maritalStatus === 'zewici'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{t('citizen.married')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Children (if married) */}
|
||||
{maritalStatus === 'zewici' && (
|
||||
<div className="space-y-3">
|
||||
<label className={labelClass}>{t('citizen.childrenCount')}</label>
|
||||
{children.map((child, index) => (
|
||||
<div key={index} className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t('citizen.childName', { index: String(index + 1) })}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={child.name}
|
||||
onChange={(e) => updateChild(index, 'name', e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder={t('citizen.childNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t('citizen.childBirthYear')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={child.birthYear}
|
||||
onChange={(e) =>
|
||||
updateChild(index, 'birthYear', parseInt(e.target.value) || 2000)
|
||||
}
|
||||
className={inputClass}
|
||||
min={1950}
|
||||
max={2026}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeChild(index)}
|
||||
className="p-3 text-red-400 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addChild}
|
||||
className="flex items-center gap-2 text-sm text-primary"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('citizen.addChild')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Region */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('citizen.region')}</label>
|
||||
<select
|
||||
value={region}
|
||||
onChange={(e) => {
|
||||
setRegion(e.target.value as Region);
|
||||
hapticImpact('light');
|
||||
}}
|
||||
className={`${inputClass} appearance-none`}
|
||||
>
|
||||
<option value="">{t('citizen.regionPlaceholder')}</option>
|
||||
{REGIONS.map((r) => (
|
||||
<option key={r.value} value={r.value}>
|
||||
{t(r.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('citizen.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder={t('citizen.emailPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Profession */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('citizen.profession')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profession}
|
||||
onChange={(e) => setProfession(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder={t('citizen.professionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Referrer Address */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('citizen.referrerAddress')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={referrerAddress}
|
||||
onChange={(e) => setReferrerAddress(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder={t('citizen.referrerPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Consent */}
|
||||
<label className="flex items-start gap-3 p-3 bg-muted/50 rounded-xl cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
className="mt-1 w-5 h-5 rounded accent-primary flex-shrink-0"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t('citizen.consentCheckbox')}</span>
|
||||
</label>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
onClick={handleFormSubmit}
|
||||
disabled={!consent || !fullName || !region || !email}
|
||||
className="w-full py-3 bg-primary text-primary-foreground rounded-xl font-semibold disabled:opacity-50"
|
||||
>
|
||||
{t('citizen.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,12 +14,10 @@ import {
|
||||
subscribeToReferralEvents,
|
||||
type ReferralStats,
|
||||
} from '@/lib/referral';
|
||||
import { getPendingApprovals, getCitizenshipStatus, type PendingApproval } from '@/lib/citizenship';
|
||||
|
||||
interface ReferralContextValue {
|
||||
stats: ReferralStats | null;
|
||||
myReferrals: string[];
|
||||
pendingApprovals: PendingApproval[];
|
||||
loading: boolean;
|
||||
refreshStats: () => Promise<void>;
|
||||
}
|
||||
@@ -33,7 +31,6 @@ export function ReferralProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const [stats, setStats] = useState<ReferralStats | null>(null);
|
||||
const [myReferrals, setMyReferrals] = useState<string[]>([]);
|
||||
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Fetch referral statistics from People Chain
|
||||
@@ -41,7 +38,6 @@ export function ReferralProvider({ children }: { children: ReactNode }) {
|
||||
if (!peopleApi || !address) {
|
||||
setStats(null);
|
||||
setMyReferrals([]);
|
||||
setPendingApprovals([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -56,15 +52,6 @@ export function ReferralProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
setStats(fetchedStats);
|
||||
setMyReferrals(fetchedReferrals);
|
||||
|
||||
// Fetch pending approvals only if user is an approved citizen (can be a referrer)
|
||||
const citizenStatus = await getCitizenshipStatus(peopleApi, address);
|
||||
if (citizenStatus === 'Approved') {
|
||||
const approvals = await getPendingApprovals(peopleApi, address);
|
||||
setPendingApprovals(approvals);
|
||||
} else {
|
||||
setPendingApprovals([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching referral stats:', error);
|
||||
showAlert(translate('context.referralStatsError'));
|
||||
@@ -105,7 +92,6 @@ export function ReferralProvider({ children }: { children: ReactNode }) {
|
||||
const value: ReferralContextValue = {
|
||||
stats,
|
||||
myReferrals,
|
||||
pendingApprovals,
|
||||
loading,
|
||||
refreshStats: fetchStats,
|
||||
};
|
||||
|
||||
@@ -171,12 +171,6 @@ const ar: Translations = {
|
||||
noUnclaimedRewards: 'لا توجد مكافآت غير مطالب بها',
|
||||
rewardHistory: 'سجل المكافآت',
|
||||
era: 'حقبة',
|
||||
pendingApprovals: 'الموافقات المعلّقة',
|
||||
approveReferral: 'موافقة',
|
||||
approvingReferral: 'جاري الموافقة...',
|
||||
referralApprovalSuccess: 'تمت الموافقة على الإحالة!',
|
||||
referralApprovalFailed: 'فشلت الموافقة',
|
||||
pendingReferralStatus: 'بانتظار موافقتك',
|
||||
},
|
||||
|
||||
wallet: {
|
||||
@@ -832,8 +826,6 @@ const ar: Translations = {
|
||||
alreadyPending: 'لديك طلب قيد الانتظار',
|
||||
alreadyApproved: 'مواطنتك معتمدة بالفعل!',
|
||||
insufficientBalance: 'رصيد غير كافٍ (١ HEZ وديعة مطلوبة)',
|
||||
applyingCitizenship: 'جاري تقديم طلب المواطنة...',
|
||||
applicationSuccess: 'تم تقديم الطلب!',
|
||||
selectLanguage: 'اختر اللغة',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -172,12 +172,6 @@ const ckb: Translations = {
|
||||
noUnclaimedRewards: 'خەڵاتی داوانەکراو نییە',
|
||||
rewardHistory: 'مێژووی خەڵاتەکان',
|
||||
era: 'سەردەم',
|
||||
pendingApprovals: 'پەسەندکردنە چاوەڕوانەکان',
|
||||
approveReferral: 'پەسەندکردن',
|
||||
approvingReferral: 'پەسەند دەکرێت...',
|
||||
referralApprovalSuccess: 'بانگهێشتکردن پەسەندکرا!',
|
||||
referralApprovalFailed: 'پەسەندکردن سەرنەکەوت',
|
||||
pendingReferralStatus: 'چاوەڕوانی پەسەندکردنی تۆیە',
|
||||
},
|
||||
|
||||
wallet: {
|
||||
@@ -835,8 +829,6 @@ const ckb: Translations = {
|
||||
alreadyPending: 'داواکارییەکی چاوەڕوانت هەیە',
|
||||
alreadyApproved: 'هاوڵاتیبوونت پێشتر پەسەند کراوە!',
|
||||
insufficientBalance: 'باڵانسی پێویست نییە (١ HEZ ئەمانەت پێویستە)',
|
||||
applyingCitizenship: 'داواکاری هاوڵاتیبوون دەنێردرێت...',
|
||||
applicationSuccess: 'داواکاری نێردرا!',
|
||||
selectLanguage: 'زمان هەڵبژێرە',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -171,12 +171,6 @@ const en: Translations = {
|
||||
noUnclaimedRewards: 'No unclaimed rewards',
|
||||
rewardHistory: 'Reward History',
|
||||
era: 'Era',
|
||||
pendingApprovals: 'Pending Approvals',
|
||||
approveReferral: 'Approve',
|
||||
approvingReferral: 'Approving...',
|
||||
referralApprovalSuccess: 'Referral approved!',
|
||||
referralApprovalFailed: 'Approval failed',
|
||||
pendingReferralStatus: 'Awaiting your approval',
|
||||
},
|
||||
|
||||
wallet: {
|
||||
@@ -835,8 +829,6 @@ const en: Translations = {
|
||||
alreadyPending: 'You already have a pending application',
|
||||
alreadyApproved: 'Your citizenship is already approved!',
|
||||
insufficientBalance: 'Insufficient balance (1 HEZ deposit required)',
|
||||
applyingCitizenship: 'Applying for citizenship...',
|
||||
applicationSuccess: 'Application submitted!',
|
||||
selectLanguage: 'Select language',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -171,12 +171,6 @@ const fa: Translations = {
|
||||
noUnclaimedRewards: 'پاداش مطالبه نشدهای وجود ندارد',
|
||||
rewardHistory: 'تاریخچه پاداشها',
|
||||
era: 'دوره',
|
||||
pendingApprovals: 'تأییدهای در انتظار',
|
||||
approveReferral: 'تأیید',
|
||||
approvingReferral: 'در حال تأیید...',
|
||||
referralApprovalSuccess: 'دعوت تأیید شد!',
|
||||
referralApprovalFailed: 'تأیید ناموفق بود',
|
||||
pendingReferralStatus: 'در انتظار تأیید شما',
|
||||
},
|
||||
|
||||
wallet: {
|
||||
@@ -835,8 +829,6 @@ const fa: Translations = {
|
||||
alreadyPending: 'درخواست در انتظار دارید',
|
||||
alreadyApproved: 'شهروندی شما قبلاً تأیید شده!',
|
||||
insufficientBalance: 'موجودی ناکافی (۱ HEZ سپرده لازم است)',
|
||||
applyingCitizenship: 'در حال ارسال درخواست شهروندی...',
|
||||
applicationSuccess: 'درخواست ارسال شد!',
|
||||
selectLanguage: 'انتخاب زبان',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -176,12 +176,6 @@ const krd: Translations = {
|
||||
noUnclaimedRewards: 'Xelatên nedaxwazkir tune ne',
|
||||
rewardHistory: 'Dîroka Xelatan',
|
||||
era: 'Era',
|
||||
pendingApprovals: 'Pejirandina li bendê',
|
||||
approveReferral: 'Pejirîne',
|
||||
approvingReferral: 'Tê pejirandin...',
|
||||
referralApprovalSuccess: 'Referans hat pejirandin!',
|
||||
referralApprovalFailed: 'Pejirandin biserneket',
|
||||
pendingReferralStatus: 'Li benda pejirandina we',
|
||||
},
|
||||
|
||||
wallet: {
|
||||
@@ -862,8 +856,6 @@ const krd: Translations = {
|
||||
alreadyPending: 'Serlêdanek te ya li bendê heye',
|
||||
alreadyApproved: 'Welatîbûna te berê hatiye pejirandin!',
|
||||
insufficientBalance: 'Balansa têr nîne (1 HEZ depozîto pêwîst e)',
|
||||
applyingCitizenship: 'Daxwaza welatîbûnê tê şandin...',
|
||||
applicationSuccess: 'Daxwaz hat şandin!',
|
||||
selectLanguage: 'Ziman hilbijêre',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -171,12 +171,6 @@ const tr: Translations = {
|
||||
noUnclaimedRewards: 'Talep edilmemiş ödül yok',
|
||||
rewardHistory: 'Ödül Geçmişi',
|
||||
era: 'Era',
|
||||
pendingApprovals: 'Bekleyen Onaylar',
|
||||
approveReferral: 'Onayla',
|
||||
approvingReferral: 'Onaylanıyor...',
|
||||
referralApprovalSuccess: 'Referans onaylandı!',
|
||||
referralApprovalFailed: 'Onaylama başarısız',
|
||||
pendingReferralStatus: 'Onayınızı bekliyor',
|
||||
},
|
||||
|
||||
wallet: {
|
||||
@@ -835,8 +829,6 @@ const tr: Translations = {
|
||||
alreadyPending: 'Bekleyen bir başvurunuz var',
|
||||
alreadyApproved: 'Vatandaşlığınız zaten onaylanmış!',
|
||||
insufficientBalance: 'Yetersiz bakiye (1 HEZ depozito gerekli)',
|
||||
applyingCitizenship: 'Vatandaşlık başvurusu yapılıyor...',
|
||||
applicationSuccess: 'Başvuru gönderildi!',
|
||||
selectLanguage: 'Dil seçin',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -173,12 +173,6 @@ export interface Translations {
|
||||
noUnclaimedRewards: string;
|
||||
rewardHistory: string;
|
||||
era: string;
|
||||
pendingApprovals: string;
|
||||
approveReferral: string;
|
||||
approvingReferral: string;
|
||||
referralApprovalSuccess: string;
|
||||
referralApprovalFailed: string;
|
||||
pendingReferralStatus: string;
|
||||
};
|
||||
|
||||
// Wallet section
|
||||
@@ -857,9 +851,6 @@ export interface Translations {
|
||||
alreadyPending: string;
|
||||
alreadyApproved: string;
|
||||
insufficientBalance: string;
|
||||
// In-app modal
|
||||
applyingCitizenship: string;
|
||||
applicationSuccess: string;
|
||||
// Language selector
|
||||
selectLanguage: string;
|
||||
};
|
||||
|
||||
@@ -46,11 +46,6 @@ export interface CitizenshipResult {
|
||||
identityHash?: string;
|
||||
}
|
||||
|
||||
export interface PendingApproval {
|
||||
applicantAddress: string;
|
||||
identityHash: string;
|
||||
}
|
||||
|
||||
// ── Identity Hash (Keccak-256) ──────────────────────────────────────
|
||||
|
||||
export function calculateIdentityHash(name: string, email: string, documentCids: string[]): string {
|
||||
@@ -145,128 +140,6 @@ export async function getCitizenshipStatus(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pending Approvals ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get pending referral applications that need approval from the given referrer.
|
||||
* Queries identityKyc.applications entries and filters by referrer + PendingReferral status.
|
||||
*/
|
||||
export async function getPendingApprovals(
|
||||
api: ApiPromise,
|
||||
referrerAddress: string
|
||||
): Promise<PendingApproval[]> {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (!(api?.query as any)?.identityKyc?.applications) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const entries = await (api.query as any).identityKyc.applications.entries();
|
||||
const pending: PendingApproval[] = [];
|
||||
|
||||
// Convert referrer SS58 address to raw hex account ID for comparison
|
||||
// Application value is SCALE-encoded: identity_hash (32 bytes) + referrer (32 bytes)
|
||||
const referrerAccountId = api
|
||||
.createType('AccountId32', referrerAddress)
|
||||
.toHex()
|
||||
.replace('0x', '')
|
||||
.toLowerCase();
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
const applicantAddress = key.args[0].toString();
|
||||
|
||||
// Extract referrer from raw hex (last 32 bytes = 64 hex chars)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rawHex = (value as any).toHex().replace('0x', '');
|
||||
if (rawHex.length < 128) continue; // need at least 64 bytes
|
||||
|
||||
const identityHashHex = '0x' + rawHex.slice(0, 64);
|
||||
const referrerHex = rawHex.slice(64, 128).toLowerCase();
|
||||
|
||||
// Compare raw account IDs
|
||||
if (referrerHex !== referrerAccountId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if status is PendingReferral
|
||||
const status = await getCitizenshipStatus(api, applicantAddress);
|
||||
if (status === 'PendingReferral') {
|
||||
pending.push({
|
||||
applicantAddress,
|
||||
identityHash: identityHashHex,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return pending;
|
||||
} catch (error) {
|
||||
console.error('[Citizenship] Error fetching pending approvals:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Approve Referral ────────────────────────────────────────────────
|
||||
|
||||
export async function approveReferral(
|
||||
api: ApiPromise,
|
||||
keypair: KeyringPair,
|
||||
applicantAddress: string
|
||||
): Promise<CitizenshipResult> {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tx = api.tx as any;
|
||||
if (!tx?.identityKyc?.approveReferral) {
|
||||
return { success: false, error: 'Identity KYC pallet not available' };
|
||||
}
|
||||
|
||||
const result = await new Promise<CitizenshipResult>((resolve) => {
|
||||
tx.identityKyc
|
||||
.approveReferral(applicantAddress)
|
||||
.signAndSend(
|
||||
keypair,
|
||||
{ nonce: -1 },
|
||||
({
|
||||
status,
|
||||
dispatchError,
|
||||
}: {
|
||||
status: {
|
||||
isInBlock: boolean;
|
||||
isFinalized: boolean;
|
||||
asInBlock?: { toString: () => string };
|
||||
asFinalized?: { toString: () => string };
|
||||
};
|
||||
dispatchError?: { isModule: boolean; asModule: unknown; toString: () => string };
|
||||
}) => {
|
||||
if (status.isInBlock || status.isFinalized) {
|
||||
if (dispatchError) {
|
||||
let errorMessage = 'Referral approval failed';
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(
|
||||
dispatchError.asModule as Parameters<typeof api.registry.findMetaError>[0]
|
||||
);
|
||||
errorMessage = `${decoded.section}.${decoded.name}`;
|
||||
}
|
||||
resolve({ success: false, error: errorMessage });
|
||||
return;
|
||||
}
|
||||
const blockHash = status.asFinalized?.toString() || status.asInBlock?.toString();
|
||||
resolve({ success: true, blockHash });
|
||||
}
|
||||
}
|
||||
)
|
||||
.catch((error: Error) => resolve({ success: false, error: error.message }));
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blockchain Submission ───────────────────────────────────────────
|
||||
|
||||
export async function applyCitizenship(
|
||||
|
||||
@@ -24,12 +24,10 @@ import {
|
||||
Play,
|
||||
PenTool,
|
||||
Globe2,
|
||||
Clock,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { cn, formatAddress } from '@/lib/utils';
|
||||
import { useTelegram } from '@/hooks/useTelegram';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
import { useReferral } from '@/contexts/ReferralContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { SocialLinks } from '@/components/SocialLinks';
|
||||
@@ -64,11 +62,9 @@ import {
|
||||
getCitizenshipStatus,
|
||||
getCitizenCount,
|
||||
confirmCitizenship,
|
||||
approveReferral,
|
||||
type CitizenshipStatus,
|
||||
} from '@/lib/citizenship';
|
||||
import { KurdistanSun } from '@/components/KurdistanSun';
|
||||
import { CitizenshipModal } from '@/components/CitizenshipModal';
|
||||
|
||||
// Activity tracking constants
|
||||
const ACTIVITY_STORAGE_KEY = 'pezkuwi_last_active';
|
||||
@@ -76,8 +72,8 @@ const ACTIVITY_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
export function RewardsSection() {
|
||||
const { hapticImpact, hapticNotification, shareUrl, showAlert } = useTelegram();
|
||||
const { user: authUser } = useAuth();
|
||||
const { stats, myReferrals, pendingApprovals, loading, refreshStats } = useReferral();
|
||||
|
||||
const { stats, myReferrals, loading, refreshStats } = useReferral();
|
||||
const { isConnected, address, peopleApi, assetHubApi, keypair } = useWallet();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -99,8 +95,6 @@ export function RewardsSection() {
|
||||
const [unclaimedRewards, setUnclaimedRewards] = useState<UnclaimedRewardsResult | null>(null);
|
||||
const [claimingStaking, setClaimingStaking] = useState(false);
|
||||
const [claimingStakingEra, setClaimingStakingEra] = useState<number | null>(null);
|
||||
const [approvingAddress, setApprovingAddress] = useState<string | null>(null);
|
||||
const [showCitizenshipModal, setShowCitizenshipModal] = useState(false);
|
||||
|
||||
// Check activity status
|
||||
const checkActivityStatus = useCallback(() => {
|
||||
@@ -364,31 +358,9 @@ export function RewardsSection() {
|
||||
showAlert(t('rewards.activatedAlert'));
|
||||
};
|
||||
|
||||
const handleApproveReferral = async (applicantAddress: string) => {
|
||||
if (!peopleApi || !keypair) return;
|
||||
setApprovingAddress(applicantAddress);
|
||||
hapticImpact('medium');
|
||||
try {
|
||||
const result = await approveReferral(peopleApi, keypair, applicantAddress);
|
||||
if (result.success) {
|
||||
hapticNotification('success');
|
||||
showAlert(t('rewards.referralApprovalSuccess'));
|
||||
refreshStats();
|
||||
} else {
|
||||
hapticNotification('error');
|
||||
showAlert(result.error || t('rewards.referralApprovalFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
hapticNotification('error');
|
||||
showAlert(err instanceof Error ? err.message : t('rewards.referralApprovalFailed'));
|
||||
} finally {
|
||||
setApprovingAddress(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Telegram referral link (for sharing) - use authenticated user ID
|
||||
const referralLink = authUser?.telegram_id
|
||||
? `https://t.me/pezkuwichainBot?start=ref_${authUser.telegram_id}`
|
||||
// Citizenship referral link - wallet address in start param for auto-fill
|
||||
const referralLink = address
|
||||
? `https://t.me/pezkuwichainBot?start=${address}`
|
||||
: 'https://t.me/pezkuwichainBot';
|
||||
|
||||
// Full share message: invitation text + link + wallet address for manual paste
|
||||
@@ -524,7 +496,7 @@ export function RewardsSection() {
|
||||
<button
|
||||
onClick={() => {
|
||||
hapticImpact('medium');
|
||||
setShowCitizenshipModal(true);
|
||||
window.location.href = `${window.location.origin}/citizens${window.location.hash}`;
|
||||
}}
|
||||
className="w-full py-2.5 bg-white/20 hover:bg-white/30 rounded-xl text-sm font-medium flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
@@ -848,53 +820,6 @@ export function RewardsSection() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pending Approvals Section */}
|
||||
{pendingApprovals.length > 0 && (
|
||||
<div className="bg-gradient-to-r from-orange-500/20 to-amber-500/20 border border-orange-500/30 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Clock className="w-5 h-5 text-orange-400" />
|
||||
<h3 className="font-semibold text-orange-100">{t('rewards.pendingApprovals')}</h3>
|
||||
<span className="bg-orange-500/30 text-orange-300 text-xs font-medium px-2 py-0.5 rounded-full">
|
||||
{pendingApprovals.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{pendingApprovals.map((approval) => (
|
||||
<div
|
||||
key={approval.applicantAddress}
|
||||
className="flex items-center gap-3 bg-black/20 rounded-lg p-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<code className="text-sm text-foreground">
|
||||
{formatAddress(approval.applicantAddress, 8)}
|
||||
</code>
|
||||
<p className="text-xs text-orange-300">
|
||||
{t('rewards.pendingReferralStatus')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleApproveReferral(approval.applicantAddress)}
|
||||
disabled={approvingAddress !== null}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/30 text-orange-200 hover:bg-orange-500/40 transition-all disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{approvingAddress === approval.applicantAddress ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
{t('rewards.approvingReferral')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{t('rewards.approveReferral')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
@@ -1377,17 +1302,6 @@ export function RewardsSection() {
|
||||
<p className="text-white/60 text-sm mt-2">{t('rewards.signingBlockchain')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Citizenship Application Modal */}
|
||||
<CitizenshipModal
|
||||
isOpen={showCitizenshipModal}
|
||||
onClose={() => {
|
||||
setShowCitizenshipModal(false);
|
||||
if (peopleApi && address) {
|
||||
getCitizenshipStatus(peopleApi, address).then(setCitizenshipStatus);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.0.230",
|
||||
"buildTime": "2026-02-27T23:33:39.279Z",
|
||||
"buildNumber": 1772235219280
|
||||
"buildTime": "2026-04-27T10:30:10.174Z",
|
||||
"buildNumber": 1777285810176
|
||||
}
|
||||
|
||||
@@ -130,10 +130,13 @@ serve(async (req) => {
|
||||
|
||||
// Validate required fields
|
||||
if (!offerId || !amount || !buyerWallet) {
|
||||
return new Response(JSON.stringify({ error: 'Missing required fields: offerId, amount, buyerWallet' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Missing required fields: offerId, amount, buyerWallet' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (amount <= 0) {
|
||||
@@ -148,21 +151,22 @@ serve(async (req) => {
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Get auth user ID for this telegram user
|
||||
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
|
||||
const {
|
||||
data: { users: authUsers },
|
||||
} = await supabase.auth.admin.listUsers({ perPage: 1000 });
|
||||
const authUser = authUsers?.find((u: { email?: string }) => u.email === telegramEmail);
|
||||
// Resolve P2P user ID: prefer p2p_user_id (citizen/visa UUID, same as pwap/web)
|
||||
// fallback to tg_users.id (legacy Supabase Auth UUID)
|
||||
const { data: tgUser, error: tgUserError } = await supabase
|
||||
.from('tg_users')
|
||||
.select('id, p2p_user_id')
|
||||
.eq('telegram_id', telegramId)
|
||||
.single();
|
||||
|
||||
if (!authUser) {
|
||||
if (tgUserError || !tgUser) {
|
||||
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const userId = authUser.id;
|
||||
const userId = tgUser.p2p_user_id ?? tgUser.id;
|
||||
|
||||
// Call the accept_p2p_offer RPC function
|
||||
const { data: rpcResult, error: rpcError } = await supabase.rpc('accept_p2p_offer', {
|
||||
@@ -187,13 +191,10 @@ serve(async (req) => {
|
||||
const result = typeof rpcResult === 'string' ? JSON.parse(rpcResult) : rpcResult;
|
||||
|
||||
if (!result.success) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: result.error || 'Failed to accept offer' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
return new Response(JSON.stringify({ error: result.error || 'Failed to accept offer' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Log to p2p_audit_log
|
||||
|
||||
@@ -160,21 +160,22 @@ serve(async (req) => {
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Get auth user ID for this telegram user
|
||||
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
|
||||
const {
|
||||
data: { users: authUsers },
|
||||
} = await supabase.auth.admin.listUsers({ perPage: 1000 });
|
||||
const authUser = authUsers?.find((u: { email?: string }) => u.email === telegramEmail);
|
||||
// Resolve P2P user ID: prefer p2p_user_id (citizen/visa UUID, same as pwap/web)
|
||||
// fallback to tg_users.id (legacy Supabase Auth UUID)
|
||||
const { data: tgUser, error: tgUserError } = await supabase
|
||||
.from('tg_users')
|
||||
.select('id, p2p_user_id')
|
||||
.eq('telegram_id', telegramId)
|
||||
.single();
|
||||
|
||||
if (!authUser) {
|
||||
if (tgUserError || !tgUser) {
|
||||
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const userId = authUser.id;
|
||||
const userId = tgUser.p2p_user_id ?? tgUser.id;
|
||||
|
||||
// 1. Lock escrow from internal balance
|
||||
const { data: lockResult, error: lockError } = await supabase.rpc('lock_escrow_internal', {
|
||||
|
||||
@@ -28,7 +28,13 @@ interface P2PDisputeRequest {
|
||||
reason?: string;
|
||||
category?: 'payment_not_received' | 'wrong_amount' | 'fake_payment_proof' | 'other';
|
||||
evidenceUrl?: string;
|
||||
evidenceType?: 'screenshot' | 'receipt' | 'bank_statement' | 'chat_log' | 'transaction_proof' | 'other';
|
||||
evidenceType?:
|
||||
| 'screenshot'
|
||||
| 'receipt'
|
||||
| 'bank_statement'
|
||||
| 'chat_log'
|
||||
| 'transaction_proof'
|
||||
| 'other';
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -97,7 +103,16 @@ serve(async (req) => {
|
||||
|
||||
try {
|
||||
const body: P2PDisputeRequest = await req.json();
|
||||
const { sessionToken, action, tradeId, reason, category, evidenceUrl, evidenceType, description } = body;
|
||||
const {
|
||||
sessionToken,
|
||||
action,
|
||||
tradeId,
|
||||
reason,
|
||||
category,
|
||||
evidenceUrl,
|
||||
evidenceType,
|
||||
description,
|
||||
} = body;
|
||||
|
||||
// Get bot tokens for session verification (dual bot support)
|
||||
const botTokens: string[] = [];
|
||||
@@ -141,10 +156,13 @@ serve(async (req) => {
|
||||
}
|
||||
|
||||
if (!['open', 'add_evidence'].includes(action)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid action. Must be "open" or "add_evidence"' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid action. Must be "open" or "add_evidence"' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Create Supabase admin client (bypasses RLS)
|
||||
@@ -152,21 +170,22 @@ serve(async (req) => {
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Get auth user ID for this telegram user
|
||||
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
|
||||
const {
|
||||
data: { users: authUsers },
|
||||
} = await supabase.auth.admin.listUsers({ perPage: 1000 });
|
||||
const authUser = authUsers?.find((u: { email?: string }) => u.email === telegramEmail);
|
||||
// Resolve P2P user ID: prefer p2p_user_id (citizen/visa UUID, same as pwap/web)
|
||||
// fallback to tg_users.id (legacy Supabase Auth UUID)
|
||||
const { data: tgUser, error: tgUserError } = await supabase
|
||||
.from('tg_users')
|
||||
.select('id, p2p_user_id')
|
||||
.eq('telegram_id', telegramId)
|
||||
.single();
|
||||
|
||||
if (!authUser) {
|
||||
if (tgUserError || !tgUser) {
|
||||
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const userId = authUser.id;
|
||||
const userId = tgUser.p2p_user_id ?? tgUser.id;
|
||||
|
||||
// Verify user is a party to this trade
|
||||
const { data: trade, error: tradeError } = await supabase
|
||||
@@ -193,20 +212,35 @@ serve(async (req) => {
|
||||
if (action === 'open') {
|
||||
// Trade must be in active status to dispute
|
||||
if (!['pending', 'payment_sent'].includes(trade.status)) {
|
||||
return new Response(JSON.stringify({ error: `Cannot open dispute: trade status is '${trade.status}', must be 'pending' or 'payment_sent'` }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: `Cannot open dispute: trade status is '${trade.status}', must be 'pending' or 'payment_sent'`,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Validate category
|
||||
const validCategories = ['payment_not_received', 'wrong_amount', 'fake_payment_proof', 'other'];
|
||||
const validCategories = [
|
||||
'payment_not_received',
|
||||
'wrong_amount',
|
||||
'fake_payment_proof',
|
||||
'other',
|
||||
];
|
||||
const disputeCategory = category || 'other';
|
||||
if (!validCategories.includes(disputeCategory)) {
|
||||
return new Response(JSON.stringify({ error: `Invalid category. Must be one of: ${validCategories.join(', ')}` }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: `Invalid category. Must be one of: ${validCategories.join(', ')}`,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const disputeReason = reason || 'No reason provided';
|
||||
@@ -300,13 +334,25 @@ serve(async (req) => {
|
||||
});
|
||||
}
|
||||
|
||||
const validEvidenceTypes = ['screenshot', 'receipt', 'bank_statement', 'chat_log', 'transaction_proof', 'other'];
|
||||
const validEvidenceTypes = [
|
||||
'screenshot',
|
||||
'receipt',
|
||||
'bank_statement',
|
||||
'chat_log',
|
||||
'transaction_proof',
|
||||
'other',
|
||||
];
|
||||
const evType = evidenceType || 'other';
|
||||
if (!validEvidenceTypes.includes(evType)) {
|
||||
return new Response(JSON.stringify({ error: `Invalid evidence type. Must be one of: ${validEvidenceTypes.join(', ')}` }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: `Invalid evidence type. Must be one of: ${validEvidenceTypes.join(', ')}`,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Find the dispute for this trade
|
||||
|
||||
@@ -148,21 +148,22 @@ serve(async (req) => {
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Get auth user ID for this telegram user
|
||||
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
|
||||
const {
|
||||
data: { users: authUsers },
|
||||
} = await supabase.auth.admin.listUsers({ perPage: 1000 });
|
||||
const authUser = authUsers?.find((u: { email?: string }) => u.email === telegramEmail);
|
||||
// Resolve P2P user ID: prefer p2p_user_id (citizen/visa UUID, same as pwap/web)
|
||||
// fallback to tg_users.id (legacy Supabase Auth UUID)
|
||||
const { data: tgUser, error: tgUserError } = await supabase
|
||||
.from('tg_users')
|
||||
.select('id, p2p_user_id')
|
||||
.eq('telegram_id', telegramId)
|
||||
.single();
|
||||
|
||||
if (!authUser) {
|
||||
if (tgUserError || !tgUser) {
|
||||
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const userId = authUser.id;
|
||||
const userId = tgUser.p2p_user_id ?? tgUser.id;
|
||||
|
||||
// Verify user is a party to this trade
|
||||
const { data: trade, error: tradeError } = await supabase
|
||||
@@ -248,7 +249,10 @@ serve(async (req) => {
|
||||
// Mark unread messages as read for this user
|
||||
// (messages sent by the OTHER party that haven't been read yet)
|
||||
const unreadMessageIds = (messages || [])
|
||||
.filter((m: { sender_id: string; is_read: boolean; id: string }) => m.sender_id !== userId && !m.is_read)
|
||||
.filter(
|
||||
(m: { sender_id: string; is_read: boolean; id: string }) =>
|
||||
m.sender_id !== userId && !m.is_read
|
||||
)
|
||||
.map((m: { id: string }) => m.id);
|
||||
|
||||
if (unreadMessageIds.length > 0) {
|
||||
|
||||
@@ -162,44 +162,55 @@ async function sendTokens(
|
||||
return new Promise((resolve) => {
|
||||
let txHash: string;
|
||||
|
||||
tx.signAndSend(hotWallet, { nonce }, (result: { txHash: { toHex: () => string }; status: { isInBlock: boolean; asInBlock: { toHex: () => string }; isFinalized: boolean }; dispatchError: { isModule: boolean; asModule: unknown; toString: () => string } | undefined; isError: boolean }) => {
|
||||
txHash = result.txHash.toHex();
|
||||
tx.signAndSend(
|
||||
hotWallet,
|
||||
{ nonce },
|
||||
(result: {
|
||||
txHash: { toHex: () => string };
|
||||
status: { isInBlock: boolean; asInBlock: { toHex: () => string }; isFinalized: boolean };
|
||||
dispatchError:
|
||||
| { isModule: boolean; asModule: unknown; toString: () => string }
|
||||
| undefined;
|
||||
isError: boolean;
|
||||
}) => {
|
||||
txHash = result.txHash.toHex();
|
||||
|
||||
if (result.status.isInBlock) {
|
||||
console.log(`TX in block: ${result.status.asInBlock.toHex()}`);
|
||||
}
|
||||
if (result.status.isInBlock) {
|
||||
console.log(`TX in block: ${result.status.asInBlock.toHex()}`);
|
||||
}
|
||||
|
||||
if (result.status.isFinalized) {
|
||||
const dispatchError = result.dispatchError;
|
||||
if (result.status.isFinalized) {
|
||||
const dispatchError = result.dispatchError;
|
||||
|
||||
if (dispatchError) {
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
resolve({
|
||||
success: false,
|
||||
txHash,
|
||||
error: `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`,
|
||||
});
|
||||
if (dispatchError) {
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
resolve({
|
||||
success: false,
|
||||
txHash,
|
||||
error: `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`,
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
txHash,
|
||||
error: dispatchError.toString(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
txHash,
|
||||
error: dispatchError.toString(),
|
||||
});
|
||||
resolve({ success: true, txHash });
|
||||
}
|
||||
} else {
|
||||
resolve({ success: true, txHash });
|
||||
}
|
||||
|
||||
if (result.isError) {
|
||||
resolve({
|
||||
success: false,
|
||||
txHash,
|
||||
error: 'Transaction failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (result.isError) {
|
||||
resolve({
|
||||
success: false,
|
||||
txHash,
|
||||
error: 'Transaction failed',
|
||||
});
|
||||
}
|
||||
}).catch((error: Error) => {
|
||||
).catch((error: Error) => {
|
||||
resolve({ success: false, error: error.message });
|
||||
});
|
||||
|
||||
@@ -250,18 +261,18 @@ serve(async (req) => {
|
||||
if (_mainToken) botTokens.push(_mainToken);
|
||||
if (_krdToken) botTokens.push(_krdToken);
|
||||
if (botTokens.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: 'Server configuration error' }),
|
||||
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
return new Response(JSON.stringify({ success: false, error: 'Server configuration error' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Validate session token
|
||||
if (!sessionToken) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: 'Missing session token' }),
|
||||
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
return new Response(JSON.stringify({ success: false, error: 'Missing session token' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
let telegramId: number | null = null;
|
||||
@@ -281,21 +292,22 @@ serve(async (req) => {
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const serviceClient = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Get auth user for telegram user
|
||||
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
|
||||
const {
|
||||
data: { users: existingUsers },
|
||||
} = await serviceClient.auth.admin.listUsers({ perPage: 1000 });
|
||||
const authUser = existingUsers?.find((u: { email?: string }) => u.email === telegramEmail);
|
||||
// Resolve P2P user ID: prefer p2p_user_id (citizen/visa UUID, same as pwap/web)
|
||||
// fallback to tg_users.id (legacy Supabase Auth UUID)
|
||||
const { data: tgUser, error: tgUserError } = await serviceClient
|
||||
.from('tg_users')
|
||||
.select('id, p2p_user_id')
|
||||
.eq('telegram_id', telegramId)
|
||||
.single();
|
||||
|
||||
if (!authUser) {
|
||||
if (tgUserError || !tgUser) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: 'User not found. Please deposit first.' }),
|
||||
{ status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = authUser.id;
|
||||
const userId = tgUser.p2p_user_id ?? tgUser.id;
|
||||
|
||||
// Validate input
|
||||
if (!token || !amount || !walletAddress) {
|
||||
|
||||
@@ -59,9 +59,6 @@ Cûzdanê xwe biafirînin, zimanê xwe hilbijêrin û welatiyê Pezkuwî bibin.
|
||||
|
||||
<i>Start your digital journey with Pezkuwi.
|
||||
Create your wallet, choose your language and become a citizen.</i>
|
||||
|
||||
🤖 Dijital Kurdistan AI agentıyla sohbet etmek ve daha detaylı bilgi almak için → @DKSkurdistanBot
|
||||
<i>Chat with Digital Kurdistan AI agent for more info → @DKSkurdistanBot</i>
|
||||
`;
|
||||
|
||||
// ── DKS bot (@DKSKurdistanBot) welcome ──────────────────────────────
|
||||
|
||||
@@ -142,10 +142,13 @@ serve(async (req) => {
|
||||
|
||||
const validActions = ['mark_paid', 'confirm', 'cancel', 'rate'];
|
||||
if (!validActions.includes(action)) {
|
||||
return new Response(JSON.stringify({ error: `Invalid action. Must be one of: ${validActions.join(', ')}` }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Invalid action. Must be one of: ${validActions.join(', ')}` }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Create Supabase admin client (bypasses RLS)
|
||||
@@ -153,21 +156,22 @@ serve(async (req) => {
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Get auth user ID for this telegram user
|
||||
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
|
||||
const {
|
||||
data: { users: authUsers },
|
||||
} = await supabase.auth.admin.listUsers({ perPage: 1000 });
|
||||
const authUser = authUsers?.find((u: { email?: string }) => u.email === telegramEmail);
|
||||
// Resolve P2P user ID: prefer p2p_user_id (citizen/visa UUID, same as pwap/web)
|
||||
// fallback to tg_users.id (legacy Supabase Auth UUID)
|
||||
const { data: tgUser, error: tgUserError } = await supabase
|
||||
.from('tg_users')
|
||||
.select('id, p2p_user_id')
|
||||
.eq('telegram_id', telegramId)
|
||||
.single();
|
||||
|
||||
if (!authUser) {
|
||||
if (tgUserError || !tgUser) {
|
||||
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const userId = authUser.id;
|
||||
const userId = tgUser.p2p_user_id ?? tgUser.id;
|
||||
|
||||
// Fetch the trade
|
||||
const { data: trade, error: tradeError } = await supabase
|
||||
@@ -205,10 +209,15 @@ serve(async (req) => {
|
||||
|
||||
// Trade must be in pending status
|
||||
if (trade.status !== 'pending') {
|
||||
return new Response(JSON.stringify({ error: `Cannot mark paid: trade status is '${trade.status}', expected 'pending'` }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: `Cannot mark paid: trade status is '${trade.status}', expected 'pending'`,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
@@ -256,18 +265,26 @@ serve(async (req) => {
|
||||
else if (action === 'confirm') {
|
||||
// Only seller can confirm
|
||||
if (trade.seller_id !== userId) {
|
||||
return new Response(JSON.stringify({ error: 'Only the seller can confirm and release crypto' }), {
|
||||
status: 403,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Only the seller can confirm and release crypto' }),
|
||||
{
|
||||
status: 403,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Trade must be in payment_sent status
|
||||
if (trade.status !== 'payment_sent') {
|
||||
return new Response(JSON.stringify({ error: `Cannot confirm: trade status is '${trade.status}', expected 'payment_sent'` }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: `Cannot confirm: trade status is '${trade.status}', expected 'payment_sent'`,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get offer details to know the token
|
||||
@@ -308,7 +325,8 @@ serve(async (req) => {
|
||||
);
|
||||
}
|
||||
|
||||
const releaseResponse = typeof releaseResult === 'string' ? JSON.parse(releaseResult) : releaseResult;
|
||||
const releaseResponse =
|
||||
typeof releaseResult === 'string' ? JSON.parse(releaseResult) : releaseResult;
|
||||
|
||||
if (releaseResponse && !releaseResponse.success) {
|
||||
return new Response(
|
||||
@@ -337,7 +355,9 @@ serve(async (req) => {
|
||||
if (updateError) {
|
||||
console.error('Confirm trade update error:', updateError);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Escrow released but failed to update trade status: ' + updateError.message }),
|
||||
JSON.stringify({
|
||||
error: 'Escrow released but failed to update trade status: ' + updateError.message,
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
@@ -365,10 +385,13 @@ serve(async (req) => {
|
||||
else if (action === 'cancel') {
|
||||
// Trade must be in pending or payment_sent status to cancel
|
||||
if (!['pending', 'payment_sent'].includes(trade.status)) {
|
||||
return new Response(JSON.stringify({ error: `Cannot cancel: trade status is '${trade.status}'` }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Cannot cancel: trade status is '${trade.status}'` }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const cancelReason = payload?.reason || 'Cancelled by user';
|
||||
@@ -441,13 +464,10 @@ serve(async (req) => {
|
||||
const result = typeof rpcResult === 'string' ? JSON.parse(rpcResult) : rpcResult;
|
||||
|
||||
if (result && !result.success) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: result.error || 'Failed to cancel trade' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
return new Response(JSON.stringify({ error: result.error || 'Failed to cancel trade' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch updated trade
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Add p2p_user_id to tg_users for unified P2P identity across platforms
|
||||
--
|
||||
-- p2p_user_id = UUID v5 derived from citizen/visa number (same as pwap/web)
|
||||
-- When a Telegram user links their wallet, this is populated.
|
||||
-- Edge functions use: p2p_user_id ?? id (fallback keeps old behaviour)
|
||||
|
||||
ALTER TABLE tg_users ADD COLUMN IF NOT EXISTS p2p_user_id UUID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tg_users_p2p_user_id ON tg_users(p2p_user_id);
|
||||
Reference in New Issue
Block a user