Add Cashu escrow settings + a "require escrow" listing flag
- useEscrowPrefs (device-local): default lock window, preferred escrow mint,
require-escrow default for new listings.
- EscrowSettingsCard on the Wallet (native-only, beside EscrowKeyCard).
- EscrowRequestDialog now defaults its lock window + funding mint from the prefs.
- Listings gain an optional ['escrow','required'] tag: a toggle on
CreateListingPage (defaulted from the pref) and a 🔒 badge on the card.
This commit is contained in:
parent
d82ecf4adf
commit
fb42f4f0d1
9 changed files with 171 additions and 3 deletions
|
|
@ -10,9 +10,10 @@ import { Button } from '@/components/ui/button';
|
|||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow';
|
||||
import { useEscrowPrefs } from '@/hooks/wallet/useEscrowPrefs';
|
||||
import { useTradeAttestationRequest } from '@/hooks/useTradeAttestations';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { ESCROW_DEFAULT_LOCK_DAYS, ESCROW_MIN_LOCK_DAYS } from '@/lib/cashuEscrow';
|
||||
import { ESCROW_MIN_LOCK_DAYS } from '@/lib/cashuEscrow';
|
||||
import { Loader2, Lock } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
|
|
@ -39,11 +40,12 @@ export function EscrowRequestDialog({
|
|||
}) {
|
||||
const { t } = useTranslation();
|
||||
const escrow = useCashuEscrow();
|
||||
const { prefs } = useEscrowPrefs();
|
||||
const { publishRequest, isPending: publishing } = useTradeAttestationRequest();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [amount, setAmount] = useState('');
|
||||
const [days, setDays] = useState(String(ESCROW_DEFAULT_LOCK_DAYS));
|
||||
const [days, setDays] = useState(String(prefs.defaultLockDays));
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
|
|
@ -61,7 +63,7 @@ export function EscrowRequestDialog({
|
|||
|
||||
setBusy(true);
|
||||
try {
|
||||
const { token } = await escrow.createEscrow({ amountSats, sellerPubkey, lockDays });
|
||||
const { token } = await escrow.createEscrow({ amountSats, sellerPubkey, lockDays, mintUrl: prefs.preferredMintUrl || undefined });
|
||||
await publishRequest({
|
||||
listing,
|
||||
attestor: sellerPubkey,
|
||||
|
|
|
|||
|
|
@ -363,6 +363,11 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
: `📍📦 ${t('marketUi.gearSwap.pickupOrShip')}`}
|
||||
</Badge>
|
||||
)}
|
||||
{item.escrow === 'required' && (
|
||||
<Badge variant="outline" className="text-xs border-[#ff6600]/50 bg-[#ff6600]/10 text-[#ff6600]">
|
||||
🔒 {t('marketUi.create.requireEscrow')}
|
||||
</Badge>
|
||||
)}
|
||||
{distanceKm != null && (
|
||||
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
|
||||
{distanceKm < 1
|
||||
|
|
|
|||
87
src/components/wallet/EscrowSettingsCard.tsx
Normal file
87
src/components/wallet/EscrowSettingsCard.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { useEscrowPrefs } from '@/hooks/wallet/useEscrowPrefs';
|
||||
import { useCashu } from '@/hooks/wallet/useCashu';
|
||||
import { isNativePlatform } from '@/lib/platform';
|
||||
import { ESCROW_MIN_LOCK_DAYS } from '@/lib/cashuEscrow';
|
||||
|
||||
/**
|
||||
* Cashu escrow preferences (device-local): the default lock window offered to
|
||||
* buyers, the mint funds come from, and whether new listings default to
|
||||
* requiring escrow. Native-only, like the rest of the Cashu wallet.
|
||||
*/
|
||||
export function EscrowSettingsCard() {
|
||||
const { t } = useTranslation();
|
||||
const { prefs, setPrefs } = useEscrowPrefs();
|
||||
const { mints, isInitialized } = useCashu();
|
||||
|
||||
if (!isNativePlatform() || !isInitialized) return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-black/90 border-2 border-[#ff6600]/25">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-[#ff6600]">
|
||||
<Settings2 className="h-5 w-5" />
|
||||
{t('walletUi.escrowSettings.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('walletUi.escrowSettings.desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Default lock window */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="escrow-lock-days">{t('walletUi.escrowSettings.lockWindow')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="escrow-lock-days"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={ESCROW_MIN_LOCK_DAYS}
|
||||
value={String(prefs.defaultLockDays)}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
setPrefs({ ...prefs, defaultLockDays: Number.isFinite(n) ? Math.max(ESCROW_MIN_LOCK_DAYS, n) : ESCROW_MIN_LOCK_DAYS });
|
||||
}}
|
||||
className="max-w-24 bg-white/5 border-white/10"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t('walletUi.escrowSettings.days')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('walletUi.escrowSettings.lockWindowHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Preferred mint */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('walletUi.escrowSettings.preferredMint')}</Label>
|
||||
<Select
|
||||
value={prefs.preferredMintUrl || '__active__'}
|
||||
onValueChange={(v) => setPrefs({ ...prefs, preferredMintUrl: v === '__active__' ? '' : v })}
|
||||
>
|
||||
<SelectTrigger className="bg-white/5 border-white/10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__active__">{t('walletUi.escrowSettings.activeMint')}</SelectItem>
|
||||
{mints.map((m) => (
|
||||
<SelectItem key={m.url} value={m.url}>{m.url.replace(/^https?:\/\//, '')}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{t('walletUi.escrowSettings.preferredMintHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Require escrow on new listings */}
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-white/10 p-3">
|
||||
<div className="min-w-0">
|
||||
<Label className="text-sm">{t('walletUi.escrowSettings.requireListings')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('walletUi.escrowSettings.requireListingsHint')}</p>
|
||||
</div>
|
||||
<Switch checked={prefs.requireEscrow} onCheckedChange={(v) => setPrefs({ ...prefs, requireEscrow: v })} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
25
src/hooks/wallet/useEscrowPrefs.ts
Normal file
25
src/hooks/wallet/useEscrowPrefs.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { ESCROW_DEFAULT_LOCK_DAYS } from '@/lib/cashuEscrow';
|
||||
|
||||
export interface EscrowPrefs {
|
||||
/** Default lock window (days) offered when a buyer opens the escrow dialog. */
|
||||
defaultLockDays: number;
|
||||
/** Preferred mint URL for funding escrow ('' = the active/first mint). */
|
||||
preferredMintUrl: string;
|
||||
/** Default the "require escrow" flag on new listings. */
|
||||
requireEscrow: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PREFS: EscrowPrefs = {
|
||||
defaultLockDays: ESCROW_DEFAULT_LOCK_DAYS,
|
||||
preferredMintUrl: '',
|
||||
requireEscrow: false,
|
||||
};
|
||||
|
||||
const KEY = 'skatespots:escrow:prefs';
|
||||
|
||||
/** Device-local Cashu escrow preferences (see ESCROW.md). */
|
||||
export function useEscrowPrefs() {
|
||||
const [prefs, setPrefs] = useLocalStorage<EscrowPrefs>(KEY, DEFAULT_PREFS);
|
||||
return { prefs, setPrefs };
|
||||
}
|
||||
|
|
@ -1510,6 +1510,8 @@ marketUi: {
|
|||
bothHint: 'Either',
|
||||
addressNote: 'We never collect your address — for shipping, share it privately in the trade chat.',
|
||||
postageLabel: 'Who pays postage?',
|
||||
requireEscrow: 'Require escrow',
|
||||
requireEscrowHint: 'Ask the buyer to settle through Cashu escrow.',
|
||||
postageBuyer: 'Buyer pays',
|
||||
postageSeller: 'I pay',
|
||||
postageSplit: 'Split',
|
||||
|
|
@ -5759,6 +5761,18 @@ walletUi: {
|
|||
publishedDesc: 'Buyers can now lock funds to you for a trade.',
|
||||
failed: 'Could not turn on escrow',
|
||||
},
|
||||
escrowSettings: {
|
||||
title: 'Escrow settings',
|
||||
desc: 'Defaults for Cashu escrow trades. Device-local.',
|
||||
lockWindow: 'Default lock window',
|
||||
lockWindowHint: 'How long funds stay locked before the buyer can reclaim them. Longer is safer for the seller.',
|
||||
days: 'days',
|
||||
preferredMint: 'Mint for escrow',
|
||||
activeMint: 'Active mint',
|
||||
preferredMintHint: 'Which mint funds escrow. Use a trustworthy one.',
|
||||
requireListings: 'Require escrow on new listings',
|
||||
requireListingsHint: 'Pre-selects “Require escrow” when you list an item.',
|
||||
},
|
||||
privacy: {
|
||||
title: 'Privacy Tips',
|
||||
subtitle: 'Learn how to protect your financial privacy',
|
||||
|
|
|
|||
|
|
@ -1510,6 +1510,8 @@ marketUi: {
|
|||
bothHint: 'Cualquiera',
|
||||
addressNote: 'Nunca recogemos tu dirección — para envíos, compártela en privado en el chat del trato.',
|
||||
postageLabel: '¿Quién paga el envío?',
|
||||
requireEscrow: 'Exigir depósito',
|
||||
requireEscrowHint: 'Pide al comprador liquidar mediante depósito Cashu.',
|
||||
postageBuyer: 'Paga el comprador',
|
||||
postageSeller: 'Pago yo',
|
||||
postageSplit: 'A medias',
|
||||
|
|
@ -5759,6 +5761,18 @@ walletUi: {
|
|||
publishedDesc: 'Los compradores ya pueden bloquear fondos para ti en un trato.',
|
||||
failed: 'No se pudo activar el depósito',
|
||||
},
|
||||
escrowSettings: {
|
||||
title: 'Ajustes del depósito',
|
||||
desc: 'Valores por defecto para tratos con depósito Cashu. Local del dispositivo.',
|
||||
lockWindow: 'Plazo de bloqueo por defecto',
|
||||
lockWindowHint: 'Cuánto tiempo quedan bloqueados los fondos antes de que el comprador pueda recuperarlos. Más largo es más seguro para el vendedor.',
|
||||
days: 'días',
|
||||
preferredMint: 'Mint para el depósito',
|
||||
activeMint: 'Mint activo',
|
||||
preferredMintHint: 'Qué mint financia el depósito. Usa uno de confianza.',
|
||||
requireListings: 'Exigir depósito en anuncios nuevos',
|
||||
requireListingsHint: 'Preselecciona «Exigir depósito» al publicar un artículo.',
|
||||
},
|
||||
privacy: {
|
||||
title: 'Consejos de privacidad',
|
||||
subtitle: 'Aprende a proteger tu privacidad financiera',
|
||||
|
|
|
|||
|
|
@ -3702,6 +3702,8 @@ export interface MarketplaceItem {
|
|||
fulfilment?: 'pickup' | 'ships' | 'both';
|
||||
/** Who pays postage, when the deal ships. */
|
||||
shipping?: 'buyer' | 'seller' | 'split';
|
||||
/** Seller asks the buyer to settle via Cashu escrow. */
|
||||
escrow?: 'required';
|
||||
/** NIP-13 proof-of-work difficulty (leading zero bits) on the event id. */
|
||||
powDifficulty?: number;
|
||||
expiration?: number; // Unix timestamp
|
||||
|
|
@ -3785,6 +3787,7 @@ export function parseMarketplaceItem(event: NostrEvent): MarketplaceItem | null
|
|||
const listingType = event.tags.find(([name]) => name === 'listing_type')?.[1] as MarketplaceItem['listingType'];
|
||||
const fulfilment = event.tags.find(([name]) => name === 'fulfilment')?.[1] as MarketplaceItem['fulfilment'];
|
||||
const shipping = event.tags.find(([name]) => name === 'shipping')?.[1] as MarketplaceItem['shipping'];
|
||||
const escrow = event.tags.find(([name]) => name === 'escrow')?.[1] as MarketplaceItem['escrow'];
|
||||
const powDifficulty = event.tags.some(([name]) => name === 'nonce')
|
||||
? countLeadingZeroBits(event.id)
|
||||
: undefined;
|
||||
|
|
@ -3823,6 +3826,7 @@ export function parseMarketplaceItem(event: NostrEvent): MarketplaceItem | null
|
|||
listingType,
|
||||
fulfilment,
|
||||
shipping,
|
||||
escrow,
|
||||
powDifficulty,
|
||||
size,
|
||||
expiration,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useEscrowPrefs } from '@/hooks/wallet/useEscrowPrefs';
|
||||
import { useSkateSpot, useSkateSpots } from '@/hooks/useSkateSpots';
|
||||
import { useListingRestrictions, recordListingCreation } from '@/hooks/useListingRestrictions';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
|
|
@ -65,6 +67,8 @@ export default function CreateListingPage() {
|
|||
const [listingType, setListingType] = useState<'selling' | 'wtb'>('selling');
|
||||
const [fulfilment, setFulfilment] = useState<'pickup' | 'ships' | 'both'>('pickup');
|
||||
const [shippingWho, setShippingWho] = useState<'buyer' | 'seller' | 'split'>('buyer');
|
||||
const { prefs: escrowPrefs } = useEscrowPrefs();
|
||||
const [requireEscrow, setRequireEscrow] = useState(escrowPrefs.requireEscrow);
|
||||
const [category, setCategory] = useState(searchParams.get('category') || '');
|
||||
const [title, setTitle] = useState(searchParams.get('title') || '');
|
||||
const [description, setDescription] = useState(searchParams.get('description') || '');
|
||||
|
|
@ -282,6 +286,7 @@ export default function CreateListingPage() {
|
|||
if (listingType === 'selling') {
|
||||
tags.push(['fulfilment', fulfilment]);
|
||||
if (fulfilment !== 'pickup') tags.push(['shipping', shippingWho]);
|
||||
if (requireEscrow) tags.push(['escrow', 'required']);
|
||||
}
|
||||
|
||||
// Size with optional modifiers
|
||||
|
|
@ -755,6 +760,16 @@ export default function CreateListingPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{listingType === 'selling' && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-gray-700 p-3">
|
||||
<div className="min-w-0">
|
||||
<Label className="text-sm">{t('marketUi.create.requireEscrow')}</Label>
|
||||
<p className="text-xs text-gray-500">{t('marketUi.create.requireEscrowHint')}</p>
|
||||
</div>
|
||||
<Switch checked={requireEscrow} onCheckedChange={setRequireEscrow} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Size */}
|
||||
{currentSizeOptions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useTranslation } from '@/contexts/language';
|
|||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { CashuWalletCard } from '@/components/wallet/CashuWalletCard';
|
||||
import { EscrowKeyCard } from '@/components/wallet/EscrowKeyCard';
|
||||
import { EscrowSettingsCard } from '@/components/wallet/EscrowSettingsCard';
|
||||
import { NwcWalletCard } from '@/components/wallet/NwcWalletCard';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
|
|
@ -263,6 +264,7 @@ export default function WalletPage() {
|
|||
{/* Cashu wallet — native app, self-custodial ecash */}
|
||||
<CashuWalletCard />
|
||||
<EscrowKeyCard />
|
||||
<EscrowSettingsCard />
|
||||
|
||||
<Link
|
||||
to="/mints"
|
||||
|
|
|
|||
Loading…
Reference in a new issue