import { useEffect, useState } from 'react';
import { usePrivy } from '@privy-io/react-auth';
import { useT } from '../../i18n/LanguageContext';
import { readPusdBalance } from '../../utils/dwApprovals';
interface MarketTrade {
yes_price: number;
no_price: number;
clob_token_ids: string[];
}
interface Props {
marketId: string;
question: string;
side: 'yes' | 'no';
onClose: () => void;
}
const PRESETS = [5, 10, 25, 50];
// Custody "Войти": places a manual BUY from the user's deposit wallet (same pUSD
// pool as copy-trading) — no wallet signature. Posts an intent to
// /api/copy/manual-order under the Privy session; the daemon executes it (~8s).
export default function EnterTradeModal({ marketId, question, side, onClose }: Props) {
const { t } = useT();
const { authenticated, getAccessToken } = usePrivy();
const [data, setData] = useState<MarketTrade | null>(null);
const [loadErr, setLoadErr] = useState(false);
const [loading, setLoading] = useState(true);
const [amount, setAmount] = useState('');
const [submitting, setSubmitting] = useState(false);
const [toast, setToast] = useState<{ msg: string; ok: boolean } | null>(null);
const [pusdBalance, setPusdBalance] = useState<number>(-1); // -1 = unknown (don't block)
useEffect(() => {
if (!authenticated) return;
let alive = true;
(async () => {
try {
const token = await getAccessToken();
const r = await fetch('/api/copy/wallet', { credentials: 'include', headers: token ? { authorization: `Bearer ${token}` } : {} });
const d = await r.json();
const dw = d?.data?.depositWallet;
if (alive && dw) setPusdBalance(Number(await readPusdBalance(dw)) / 1e6);
} catch { /* leave unknown → don't block submit */ }
})();
return () => { alive = false; };
}, [authenticated, getAccessToken]);
useEffect(() => {
let alive = true;
fetch(`/api/screener/market/${encodeURIComponent(marketId)}`)
.then(r => r.json())
.then(d => { if (alive) { d.success ? setData(d.data) : setLoadErr(true); } })
.catch(() => { if (alive) setLoadErr(true); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [marketId]);
const ids = data?.clob_token_ids ?? [];
const tokenId = side === 'yes' ? ids[0] : ids[1];
const price = data ? (side === 'yes' ? data.yes_price : data.no_price) : 0;
const outcome = side === 'yes' ? 'Yes' : 'No';
const amountNum = parseFloat(amount) || 0;
const shares = price > 0 ? amountNum / price : 0;
const insufficient = pusdBalance >= 0 && amountNum > pusdBalance;
const canSubmit = authenticated && amountNum >= 1 && price > 0 && price < 1 && !!tokenId && !submitting && !insufficient;
const submit = async () => {
if (!canSubmit || !tokenId) return;
setSubmitting(true);
try {
const token = await getAccessToken();
const res = await fetch('/api/copy/manual-order', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({
market_id: marketId,
token_id: tokenId,
outcome,
amount_usd: amountNum,
limit_price: price,
}),
});
const d = await res.json();
if (d.success) {
setToast({ msg: d.noop ? t('flow.alreadyQueued') : t('flow.queued'), ok: true });
setTimeout(onClose, 1600);
} else {
setToast({ msg: d.error || t('flow.orderFailed'), ok: false });
}
} catch {
setToast({ msg: t('flow.orderFailed'), ok: false });
} finally {
setSubmitting(false);
}
};
return (
<div className="flow-modal-overlay" onClick={onClose}>
<div className="flow-modal" onClick={e => e.stopPropagation()}>
<div className="flow-modal-head">
<span className="flow-modal-q">{question}</span>
<button className="flow-modal-close" onClick={onClose} aria-label="close">✕</button>
</div>
{loading && <div className="flow-modal-body">{t('flow.loading')}</div>}
{!loading && (loadErr || !data || !tokenId) && (
<div className="flow-modal-body">{t('flow.tradeUnavailable')}</div>
)}
{!loading && data && tokenId && !authenticated && (
<div className="flow-modal-body">{t('flow.needLogin')}</div>
)}
{!loading && data && tokenId && authenticated && (
<div className="flow-buy">
<div className="flow-buy-side">
<span className={`wf-bet ${side === 'yes' ? 'wf-bet-yes' : 'wf-bet-no'}`}>{outcome}</span>
<span className="flow-buy-price">{Math.round(price * 100)}¢</span>
</div>
<div className="order-field">
<label className="order-label">{t('flow.amount')}</label>
<div className="order-input-wrap">
<span className="order-input-prefix">$</span>
<input type="number" className="order-input" placeholder="0.00" min="1" step="1"
value={amount} onChange={e => setAmount(e.target.value)} />
</div>
<div className="order-presets">
{PRESETS.map(p => (
<button key={p} className="preset-btn" onClick={() => setAmount(String(p))}>${p}</button>
))}
{pusdBalance >= 0 && (
<span className="preset-balance">${pusdBalance.toFixed(2)}</span>
)}
</div>
{insufficient && (
<div className="flow-buy-est" style={{ color: 'var(--loss)' }}>{t('flow.insufficient')}</div>
)}
</div>
{amountNum > 0 && price > 0 && (
<div className="flow-buy-est">{t('flow.estShares', { n: shares.toFixed(1) })}</div>
)}
<button className="flow-enter-btn" onClick={submit} disabled={!canSubmit}>
{submitting ? t('flow.submitting') : t('flow.buyConfirm', { x: amountNum.toFixed(2) })}
</button>
<p className="order-disclaimer">{t('flow.custodyNote')}</p>
</div>
)}
{toast && (
<div className={`order-toast ${toast.ok ? 'order-toast-success' : 'order-toast-error'}`}>{toast.msg}</div>
)}
</div>
</div>
);
}
📜 Git History
b9c19bfchore(poli): reconcile local Flow/Insider/manual-trade work with deployed state3 days ago
cecbd3afeat(poli): Flow «Войти» custody modal (chunk 3) — manual buy from deposit, no wallet sign4 days ago
eb6fff8feat(poli): Flow chunk 3 — green «Войти» button + trade modal4 days ago
Show last diff
Loading...