/* Me2You - Marketplace screens: Home, Browse, PDP, Cart, Checkout, Success, Orders, Dispute */
/* global React, Icon, StatusTag, Field, OrderStepper, Price, CondTag, PageTransition, EmptyState,
btnPrimary, btnGhost, btnDanger, btnSmall, panelStyle, PRODUCTS, CATEGORIES, SAMPLE_ORDERS */
const { useState, useEffect, useRef, useMemo } = React;
/* --- Hero --- */
function MarketplaceHero({ onBrowse, onSell }) {
return (
Made in SA - POPIA-safe
Sell to your neighbours, safely.
We hold your buyer's payment until they confirm receipt. No fake proof of payment, no cash meet-ups, no fuss.
List your first item
Browse marketplace
);
}
/* --- Category Rail --- */
function CategoryRail({ active, onPick }) {
return (
{CATEGORIES.map(c => (
onPick(c.slug)} style={{
padding:'10px 18px',borderRadius:'var(--radius-pill)',
fontFamily:'var(--font-body)',fontWeight:600,fontSize:14,cursor:'pointer',
background: active === c.slug ? 'var(--ink-900)' : 'var(--paper)',
color: active === c.slug ? 'white' : 'var(--ink-900)',
border: '1.5px solid ' + (active === c.slug ? 'var(--ink-900)' : 'var(--ink-200)'),
transition:'all .15s ease'
}}>{c.name}
))}
);
}
/* --- Product Card --- */
function ProductCard({ product, onClick, onAddToCart }) {
const [hovered, setHovered] = useState(false);
const [liked, setLiked] = useState(false);
return (
setHovered(true)} onMouseLeave={() => setHovered(false)}
style={{
background:'var(--paper)',borderRadius:'var(--radius-lg)',
border:'1px solid var(--ink-200)',
boxShadow: hovered ? 'var(--shadow-md)' : 'var(--shadow-sm)',
padding:10,cursor:'pointer',
transition:'all .2s cubic-bezier(.2,.7,.3,1)',
transform: hovered ? 'translateY(-3px)' : 'none',
display:'flex',flexDirection:'column',position:'relative'
}}>
{ e.stopPropagation(); setLiked(!liked); }}
style={{width:32,height:32,borderRadius:'50%',border:'none',cursor:'pointer',
background:'rgba(255,255,255,.85)',backdropFilter:'blur(8px)',
display:'flex',alignItems:'center',justifyContent:'center',
color: liked ? 'var(--danger)' : 'var(--ink-400)',transition:'color .15s'}}>
{product.emoji}
{product.title.split(' ').slice(0,2).join(' ')}
{product.title}
{product.location} - {product.seller}
);
}
/* --- Product Grid --- */
function ProductGrid({ products, onSelect, onAddToCart }) {
return (
{products.map(p =>
onSelect(p)} onAddToCart={onAddToCart}/>)}
);
}
/* --- Search bar --- */
function SearchBar({ value, onChange, onClear }) {
return (
onChange(e.target.value)}
placeholder="Search listings, e.g. 'Hisense TV'"
style={{width:'100%',padding:'12px 40px 12px 42px',border:'1.5px solid var(--ink-200)',
borderRadius:'var(--radius-pill)',background:'var(--ink-50)',
fontFamily:'var(--font-body)',fontSize:14,boxSizing:'border-box',
transition:'border-color .15s'}}/>
{value && (
)}
);
}
/* --- Lay-bye panel: reserve with deposit --- */
function LaybyePanel({ product, onReserve }) {
const [agreed, setAgreed] = useState(false);
const [reserved, setReserved] = useState(false);
const [ackAt, setAckAt] = useState(null);
// Split the price into a deposit plus 3 weekly instalments (4 payments total)
const instalments = 4;
const each = Math.floor((product.price / instalments) * 100) / 100;
const last = Math.round((product.price - each * (instalments - 1)) * 100) / 100;
const schedule = [
{ label:'Deposit today', amount: each },
{ label:'Instalment 2 - in 1 week', amount: each },
{ label:'Instalment 3 - in 2 weeks', amount: each },
{ label:'Instalment 4 - in 3 weeks', amount: last },
];
const toggleAck = () => {
setAgreed(prev => {
const next = !prev;
setAckAt(next ? new Date().toLocaleString('en-ZA', { dateStyle:'medium', timeStyle:'short' }) : null);
return next;
});
};
if (reserved) {
return (
Item reserved on lay-bye
Deposit of R {each.toLocaleString('en-ZA')} taken. We hold the item for you. It ships once paid in full.
);
}
return (
Reserve on lay-bye
Pay a deposit today, then weekly instalments. The item ships once it is paid in full.
{schedule.map((s,i) => (
{s.label}
R {s.amount.toLocaleString('en-ZA')}
))}
I understand that if I miss instalments my deposit may be forfeited under the lay-bye terms, and the item is only released once paid in full.
{ackAt && Acknowledged {ackAt} }
{ setReserved(true); if (onReserve) onReserve(); }} disabled={!agreed}
style={{...btnPrimary,width:'100%',justifyContent:'center',padding:'14px',
opacity: agreed ? 1 : 0.5,cursor: agreed ? 'pointer' : 'not-allowed'}}>
Start lay-bye - pay deposit R {each.toLocaleString('en-ZA')}
);
}
/* --- Auction / Bid panel: countdown, current bid, place bid --- */
function AuctionPanel({ product }) {
const a = product.auction;
const [remaining, setRemaining] = useState(a ? a.endsInMs : 0);
const [currentBid, setCurrentBid] = useState(a ? a.currentBid : 0);
const [bids, setBids] = useState(a ? a.bids : 0);
const [won, setWon] = useState(false);
const minNext = currentBid + (a ? a.minIncrement : 50);
const [bidValue, setBidValue] = useState(minNext);
const [error, setError] = useState('');
useEffect(() => {
if (won || remaining <= 0) return;
const t = setInterval(() => setRemaining(r => Math.max(0, r - 1000)), 1000);
return () => clearInterval(t);
}, [won, remaining]);
const ended = remaining <= 0;
const fmt = (ms) => {
const total = Math.floor(ms / 1000);
const h = String(Math.floor(total / 3600)).padStart(2,'0');
const m = String(Math.floor((total % 3600) / 60)).padStart(2,'0');
const s = String(total % 60).padStart(2,'0');
return `${h}:${m}:${s}`;
};
const placeBid = () => {
const v = Number(bidValue);
if (!v || v < minNext) { setError(`Your bid must be at least R ${minNext.toLocaleString('en-ZA')}.`); return; }
setError('');
setCurrentBid(v);
setBids(b => b + 1);
setBidValue(v + (a ? a.minIncrement : 50));
// Simulate the auction closing in your favour shortly after you take the lead
setRemaining(4000);
setTimeout(() => setWon(true), 4200);
};
if (won) {
return (
You won the auction
Winning bid R {currentBid.toLocaleString('en-ZA')}. This pays through escrow. Funds release on confirmed receipt.
);
}
return (
Live auction
{ended ? 'Ended' : fmt(remaining)}
Current bid
R {currentBid.toLocaleString('en-ZA')}
{bids} bids - top bidder {a ? a.topBidder : ''}
{ended ? (
This auction has closed. The highest bidder pays through escrow.
) : (
<>
Your bid (R) - minimum R {minNext.toLocaleString('en-ZA')}
setBidValue(e.target.value)}
style={{flex:1,padding:'13px 14px',border:'1.5px solid var(--ink-200)',borderRadius:'var(--radius-sm)',
fontFamily:'var(--font-body)',fontSize:15,boxSizing:'border-box',background:'var(--paper)'}}/>
Place bid
{error &&
{error}
}
>
)}
Winning bid pays through escrow. Funds released on confirmed receipt.
);
}
/* --- PDP --- */
function ProductDetail({ product, onBack, onAddToCart, onSwap, onWatch, watched }) {
if (!product) return null;
const [activeImg, setActiveImg] = useState(0);
const [showShare, setShowShare] = useState(false);
const [payMode, setPayMode] = useState('buy');
const hasTabs = product.laybye || product.auction;
// Simulate multiple images by using same emoji with different bg tints
const images = [
product.bg,
`linear-gradient(135deg, var(--ink-100), var(--brand-orange-50))`,
`linear-gradient(135deg, var(--brand-orange-100), var(--ink-50))`,
product.bg,
];
const reviews = [
{ buyer:'Thandi K.', stars:5, comment:'Exactly as described. Fast delivery!', date:'2 days ago' },
{ buyer:'James R.', stars:5, comment:'Great seller, very responsive on WhatsApp.', date:'1 week ago' },
{ buyer:'Siya N.', stars:4, comment:'Good condition, minor scratch not in photos but ok.', date:'2 weeks ago' },
];
const moreFromSeller = PRODUCTS.filter(p => p.seller === product.seller && p.id !== product.id).slice(0,3);
return (
Back to listings
{product.sellerOnVacation && (
Shop on a short break
{product.seller} is away until {product.vacationUntil} . You can save this listing and watch it for updates. Buy now is paused.
)}
{/* Image gallery */}
{product.emoji} {product.title.split(' ').slice(0,3).join(' ')}
setActiveImg(i => (i - 1 + images.length) % images.length)}
style={{position:'absolute',left:12,top:'50%',transform:'translateY(-50%)',
width:40,height:40,borderRadius:999,background:'rgba(255,255,255,.9)',backdropFilter:'blur(8px)',
border:'none',cursor:'pointer',display:'flex',alignItems:'center',justifyContent:'center'}}>
setActiveImg(i => (i + 1) % images.length)}
style={{position:'absolute',right:12,top:'50%',transform:'translateY(-50%)',
width:40,height:40,borderRadius:999,background:'rgba(255,255,255,.9)',backdropFilter:'blur(8px)',
border:'none',cursor:'pointer',display:'flex',alignItems:'center',justifyContent:'center'}}>
{activeImg+1} / {images.length}
{images.map((bg,i) => (
setActiveImg(i)}
style={{width:64,height:64,borderRadius:10,backgroundImage:'url(assets/me2you.jpg)',backgroundSize:'cover',backgroundPosition:'center',
border: activeImg === i ? '2px solid var(--brand-orange)' : '1px solid var(--ink-200)',
cursor:'pointer',transition:'all .15s',flexShrink:0}}/>
))}
{product.title}
{product.location}
-
{product.rating} ({product.reviews} reviews)
R {product.price.toLocaleString('en-ZA')}
Paid through Me2You - your money is safe until you confirm receipt.
Excellent condition, used for about a year. Original packaging included. Pickup or courier - your choice.
{/* Pay-mode tabs: Buy / Lay-bye / Bid (only for eligible listings) */}
{hasTabs && (
{[
{ key:'buy', label:'Buy now' },
product.laybye && { key:'laybye', label:'Lay-bye' },
product.auction && { key:'bid', label:'Bid' },
].filter(Boolean).map(t => (
setPayMode(t.key)} style={{
padding:'8px 18px',borderRadius:999,border:'none',cursor:'pointer',minHeight:36,
fontFamily:'var(--font-body)',fontWeight:600,fontSize:14,
background: payMode === t.key ? 'var(--paper)' : 'transparent',
color: payMode === t.key ? 'var(--ink-900)' : 'var(--ink-500)',
boxShadow: payMode === t.key ? 'var(--shadow-xs)' : 'none',transition:'all .15s'
}}>{t.label}
))}
)}
{hasTabs && payMode === 'laybye' && }
{hasTabs && payMode === 'bid' && }
{(!hasTabs || payMode === 'buy') && (
onAddToCart(product)} disabled={product.sellerOnVacation}
style={{...btnPrimary,opacity: product.sellerOnVacation ? 0.55 : 1,cursor: product.sellerOnVacation ? 'not-allowed' : 'pointer'}}>
{product.sellerOnVacation ? 'Buy now - paused' : 'Add to cart'}
onWatch && onWatch(product.id)} style={{...btnGhost,color: watched ? 'var(--brand-orange-700)' : 'var(--ink-900)',
borderColor: watched ? 'var(--brand-orange)' : 'var(--ink-300)'}}>
{watched ? 'Watching' : 'Watch'}
onSwap && onSwap(product)} style={btnGhost}>
Propose a swap
setShowShare(!showShare)} style={{...btnGhost,position:'relative'}}>
Share
{showShare && (
{[
{ label:'WhatsApp', icon:'message-circle', color:'#25D366' },
{ label:'Copy link', icon:'tag', color:'var(--ink-700)' },
{ label:'SMS', icon:'phone', color:'var(--info)' },
].map(opt => (
e.currentTarget.style.background='var(--ink-50)'}
onMouseLeave={e => e.currentTarget.style.background='none'}>
{opt.label}
))}
)}
)}
{/* Seller card */}
{product.seller.charAt(0)}
{product.seller}
★ {product.rating} - Joined Mar 2025
View profile
{/* Reviews */}
Reviews ({product.reviews})
★ {product.rating} average
{reviews.map((r,i) => (
{r.buyer}
{[1,2,3,4,5].map(n => )}
{r.comment}
{r.date}
))}
{/* More from this seller */}
{moreFromSeller.length > 0 && (
More from {product.seller}
{moreFromSeller.map(p =>
{}}/>)}
)}
);
}
/* --- Cart --- */
function CartView({ cart, onUpdateQty, onRemove, onCheckout, onBrowse }) {
if (cart.length === 0) return (
);
const subtotal = cart.reduce((s,i) => s + i.product.price * i.qty, 0);
const delivery = 60;
return (
Your cart
{cart.map(i => (
{i.product.title}
From {i.product.seller}, {i.product.location}
onUpdateQty(i.product.id, -1)} style={{width:28,height:28,borderRadius:8,border:'1px solid var(--ink-200)',background:'var(--paper)',cursor:'pointer',display:'flex',alignItems:'center',justifyContent:'center'}}>
{i.qty}
onUpdateQty(i.product.id, 1)} style={{width:28,height:28,borderRadius:8,border:'1px solid var(--ink-200)',background:'var(--paper)',cursor:'pointer',display:'flex',alignItems:'center',justifyContent:'center'}}>
onRemove(i.product.id)} style={{display:'block',marginTop:4,background:'none',border:'none',cursor:'pointer',color:'var(--danger)',fontSize:12,fontWeight:600,fontFamily:'var(--font-body)'}}>Remove
))}
Total R {(subtotal + delivery).toLocaleString('en-ZA')}
Proceed to safe checkout
);
}
function SummaryRow({ label, value }) {
return (
{label} {value}
);
}
/* --- Checkout --- */
function CheckoutView({ cart, onSuccess }) {
const [delivery, setDelivery] = useState('pickup');
const [tip, setTip] = useState(10);
const [radius, setRadius] = useState(8);
const subtotal = cart.reduce((s,i) => s + i.product.price * i.qty, 0);
// Distance-based pricing for door-to-door driver
const calcDoorPrice = (km) => {
const base = 25;
const perKm = 5;
const surge = km > 20 ? Math.ceil((km - 20) / 5) * 10 : 0;
return base + Math.round(km * perKm) + surge;
};
const doorPrice = calcDoorPrice(radius);
const pickupPrice = 30;
const deliveryCost = delivery === 'door' ? doorPrice : delivery === 'pickup' ? pickupPrice : 0;
const total = subtotal + deliveryCost + tip;
return (
Checkout
Shipping, delivery and payment
How do you want it?
{[
{ key:'pickup', icon:'map-pin', title:'Pickup point', desc:`Collect from your nearest PEP / Pargo / Paxi. R ${pickupPrice}.`, price: pickupPrice },
{ key:'door', icon:'truck', title:'Driver to your door', desc:`Distance-based. Currently R ${doorPrice} for ${radius} km.`, price: doorPrice },
{ key:'self', icon:'user-round', title:'Self-collect with OTP', desc:'Meet seller, inspect, release with 6-digit code. Free.', price: 0 },
].map(opt => (
setDelivery(opt.key)} style={{
display:'flex',gap:12,padding:14,borderRadius:'var(--radius-md)',cursor:'pointer',
border: '1.5px solid ' + (delivery === opt.key ? 'var(--brand-orange)' : 'var(--ink-200)'),
background: delivery === opt.key ? 'var(--brand-orange-50)' : 'transparent',marginBottom:8,
transition:'all .15s ease'
}}>
{opt.price === 0 ? 'Free' : `R ${opt.price}`}
))}
{delivery === 'door' && (
Distance from seller
{radius} km
setRadius(+e.target.value)}
style={{width:'100%',accentColor:'var(--brand-orange)'}}/>
2 km - R {calcDoorPrice(2)}
20 km - R {calcDoorPrice(20)}
40 km - R {calcDoorPrice(40)}
R 25 base + R 5/km {radius > 20 ? + R {Math.ceil((radius - 20)/5)*10} long-distance surcharge : ''}.
100% of the delivery fee goes to your driver.
)}
Tip your driver (optional, 100% to driver)
{[0,5,10,20].map(t => (
setTip(t)} style={{
padding:'10px 18px',borderRadius:'var(--radius-pill)',cursor:'pointer',
fontFamily:'var(--font-body)',fontWeight:600,fontSize:14,
background: tip===t ? 'var(--ink-900)' : 'var(--paper)',
color: tip===t ? 'white' : 'var(--ink-900)',
border: '1.5px solid ' + (tip===t ? 'var(--ink-900)' : 'var(--ink-200)'),
}}>{t === 0 ? 'No tip' : `R ${t}`}
))}
Pay safely with PayFast
EFT, card, or mobile. We hold your money until you confirm receipt.
Subtotal R {subtotal.toLocaleString('en-ZA')}
Delivery ({delivery === 'door' ? `${radius} km` : delivery}) R {deliveryCost}
{tip > 0 &&
Driver tip R {tip}
}
Total R {total.toLocaleString('en-ZA')}
onSuccess(delivery)} style={{...btnPrimary,width:'100%',padding:'16px',fontSize:16,justifyContent:'center'}}>
Pay R {total.toLocaleString('en-ZA')} safely
);
}
/* --- Order Success --- */
function OrderSuccess({ onContinue, onConfirmCollect }) {
return (
You're paid up.
We've let the seller know. They have 3 days to dispatch your order.
Order reference
M2Y-2026-00248
{onConfirmCollect && (
Confirm collection with OTP
)}
Continue shopping
);
}
/* --- Orders List --- */
function OrdersList({ orders, onTrack }) {
return (
Your orders
{orders.map(o => (
onTrack && onTrack(o)}
style={{...panelStyle,display:'grid',gridTemplateColumns:'60px 1fr auto',gap:16,alignItems:'center',cursor:'pointer',transition:'box-shadow .15s'}}
onMouseEnter={e => e.currentTarget.style.boxShadow='var(--shadow-md)'}
onMouseLeave={e => e.currentTarget.style.boxShadow='none'}>
{o.product.title}
{o.id} - {o.date}
))}
);
}
/* --- Dispute Form --- */
function DisputeForm({ onBack }) {
const [submitted, setSubmitted] = useState(false);
const [reason, setReason] = useState('');
const [chatInput, setChatInput] = useState('');
const [messages, setMessages] = useState([
{ from:'admin', name:'Marco (Admin)', text:"Hi, we've received your dispute. Can you share photos of the issue?", time:'just now' },
]);
const send = () => {
if (!chatInput.trim()) return;
setMessages(prev => [...prev, { from:'me', name:'You', text:chatInput, time:'now' }]);
setChatInput('');
// Simulate admin response
setTimeout(() => {
setMessages(prev => [...prev, { from:'admin', name:'Marco (Admin)', text:"Thanks. We'll review and respond within 24 hours.", time:'now' }]);
}, 1500);
};
if (submitted) {
return (
Back to orders
Dispute opened
Case DSP-2026-0142 - Funds frozen in escrow
What happens next
{[
{ icon:'shield-check', text:'Escrow funds are frozen until resolved', done:true },
{ icon:'message-circle', text:'Admin contacts both parties within 24 hours', done:false },
{ icon:'check-circle', text:'Resolution: refund, release, or partial', done:false },
].map((step,i) => (
))}
{/* Live admin chat */}
Me2You Support
Usually replies in 2 hours
{messages.map((m,i) => (
))}
setChatInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && send()}
placeholder="Message support..."
style={{flex:1,padding:'10px 14px',border:'1.5px solid var(--ink-200)',borderRadius:'var(--radius-pill)',
fontFamily:'var(--font-body)',fontSize:14}}/>
Need urgent help?
Call us on 0860 ME 2 YOU - WhatsApp +27 71 234 5678 - Mon-Fri 8am-6pm
);
}
return (
Back to orders
Something not right?
We've got you. Tell us what happened and our team will be in touch within 24 hours.
Order details
What went wrong?
{[
{key:'not-received', label:'Never arrived', icon:'package'},
{key:'damaged', label:'Arrived damaged', icon:'triangle-alert'},
{key:'wrong-item', label:'Wrong item', icon:'x'},
{key:'fake', label:'Not as described', icon:'eye'},
].map(r => (
setReason(r.key)} style={{
padding:'12px',borderRadius:'var(--radius-md)',cursor:'pointer',
border: reason===r.key ? '2px solid var(--danger)' : '1.5px solid var(--ink-200)',
background: reason===r.key ? 'var(--danger-100)' : 'var(--paper)',
display:'flex',alignItems:'center',gap:8,
fontFamily:'var(--font-body)',fontWeight:600,fontSize:13,
color: reason===r.key ? 'var(--danger)' : 'var(--ink-700)',textAlign:'left'
}}>
{r.label}
))}
Upload evidence
Photos or screenshots help us resolve disputes faster.
Tap to upload photos or video
JPG - PNG - MP4 - up to 5 files
Your money is safe. Filing a dispute freezes the escrow funds. They won't be released to the seller until your case is resolved - typically within 48 hours.
setSubmitted(true)} disabled={!reason}
style={{...btnDanger,width:'100%',justifyContent:'center',padding:'14px',
opacity: reason ? 1 : 0.5,cursor: reason ? 'pointer' : 'not-allowed'}}>
Open dispute
);
}
/* --- Swap offer flow: pick your item, side-by-side, message, send, seller decision --- */
function SwapOfferFlow({ product, onBack }) {
if (!product) return null;
const mine = (window.BUYER_LISTINGS || []);
const [step, setStep] = useState('compose'); // compose | sent | decision
const [offerId, setOfferId] = useState(mine[0] ? mine[0].id : null);
const [topUp, setTopUp] = useState('');
const [note, setNote] = useState('');
const offer = mine.find(m => m.id === offerId) || mine[0];
const tile = (item, label) => (
{label}
{item ? item.title : ''}
R {item ? item.price.toLocaleString('en-ZA') : 0}
);
const sideBySide = (theirs, yours, theirLabel, yourLabel) => (
{tile(theirs, theirLabel)}
{tile(yours, yourLabel)}
);
if (step === 'sent') {
return (
Back to listing
Swap offer sent
We let {product.seller} know. You will get a message when they respond.
{sideBySide(offer, product, 'You offer', 'You want')}
{Number(topUp) > 0 && (
Cash top-up (you pay, held in escrow)
R {Number(topUp).toLocaleString('en-ZA')}
)}
{note &&
Your message: {note}
}
setStep('decision')} style={{...btnGhost,width:'100%',justifyContent:'center'}}>
Preview what the seller sees
);
}
if (step === 'decision') {
return
;
}
return (
Back to listing
Propose a swap
Offer one of your own listings for {product.seller}'s item. Add cash to balance the deal if needed.
You want
{product.title}
{product.seller} - {product.location} - R {product.price.toLocaleString('en-ZA')}
From your listings, choose one to offer
{mine.map(m => (
setOfferId(m.id)} style={{
textAlign:'left',padding:8,borderRadius:'var(--radius-md)',cursor:'pointer',background:'var(--paper)',
border: offerId === m.id ? '2px solid var(--brand-orange)' : '1.5px solid var(--ink-200)'}}>
{m.title}
R {m.price.toLocaleString('en-ZA')}
{offerId === m.id &&
Selected
}
))}
Add cash to balance (optional, R)
setTopUp(e.target.value)} placeholder="e.g. 60"
style={{width:'100%',padding:'13px 14px',border:'1.5px solid var(--ink-200)',borderRadius:'var(--radius-sm)',
fontFamily:'var(--font-body)',fontSize:15,boxSizing:'border-box',background:'var(--paper)',marginBottom:16}}/>
Message to {product.seller}
);
}
/* --- Seller side: accept or decline a swap offer --- */
function SellerSwapDecision({ theirs, yours, topUp, onBack }) {
const [outcome, setOutcome] = useState(null); // accepted | declined
const tile = (item, label) => (
{label}
{item ? item.title : ''} - R {item ? item.price.toLocaleString('en-ZA') : 0}
);
return (
Back to listing
Swap offer - #SW-118
A buyer sent you a swap offer 12 minutes ago.
{tile(theirs, 'They offer')}
{tile(yours, 'Your item')}
{topUp > 0 && (
Cash top-up from buyer
R {topUp.toLocaleString('en-ZA')}
)}
{outcome === 'accepted' ? (
Swap accepted
Both items are locked{topUp > 0 ? ` and escrow is open for the R ${topUp.toLocaleString('en-ZA')} top-up` : ''}. Arrange the exchange at a pickup desk.
) : outcome === 'declined' ? (
Swap declined
We let the buyer know. Nothing was charged.
) : (
<>
Accepting locks both items{topUp > 0 ? ` and opens escrow for the R ${topUp.toLocaleString('en-ZA')} top-up` : ''}.
setOutcome('declined')} style={{...btnGhost,flex:1,justifyContent:'center'}}>
Decline
setOutcome('accepted')} style={{...btnPrimary,flex:1,justifyContent:'center'}}>
Accept swap
>
)}
);
}
/* --- OTP self-collect confirm: 6-box code entry at handover --- */
function OTPCollectionConfirm({ onBack, onConfirmed }) {
const [digits, setDigits] = useState(['','','','','','']);
const [error, setError] = useState('');
const [confirmed, setConfirmed] = useState(false);
const refs = useRef([]);
const EXPECTED = '482913';
const setDigit = (i, v) => {
const clean = v.replace(/[^0-9]/g, '').slice(-1);
setError('');
setDigits(prev => prev.map((d,idx) => idx === i ? clean : d));
if (clean && i < 5 && refs.current[i+1]) refs.current[i+1].focus();
};
const onKey = (i, e) => {
if (e.key === 'Backspace' && !digits[i] && i > 0 && refs.current[i-1]) refs.current[i-1].focus();
};
const confirm = () => {
const code = digits.join('');
if (code.length < 6) { setError('Enter all 6 digits from the buyer.'); return; }
if (code !== EXPECTED) { setError('That code does not match. Ask the buyer to read it again from their order screen.'); return; }
setConfirmed(true);
if (onConfirmed) onConfirmed();
};
if (confirmed) {
return (
Collection confirmed
The escrow funds have been released to the seller. Enjoy your item.
Done
);
}
return (
Back
Confirm collection
Inspect the item first. When you are happy, read the 6-digit code from your order screen to the seller, or enter it here to release payment.
{digits.map((d,i) => (
refs.current[i] = el} value={d} inputMode="numeric" maxLength={1}
onChange={e => setDigit(i, e.target.value)} onKeyDown={e => onKey(i, e)}
aria-label={`Digit ${i+1}`}
style={{width:44,height:54,textAlign:'center',fontFamily:'var(--font-mono)',fontWeight:700,fontSize:24,
border: error ? '1.5px solid var(--danger)' : '1.5px solid var(--ink-200)',borderRadius:'var(--radius-sm)',
background:'var(--paper)',color:'var(--ink-900)'}}/>
))}
{error &&
{error}
}
Demo code for testing: 482913
Confirm collection - release payment
);
}
Object.assign(window, {
MarketplaceHero, CategoryRail, ProductCard, ProductGrid, SearchBar,
ProductDetail, CartView, CheckoutView, OrderSuccess, OrdersList, DisputeForm,
LaybyePanel, AuctionPanel, SwapOfferFlow, SellerSwapDecision, OTPCollectionConfirm,
});