// Wander — Browse, Search, Saved, Profile, Paywall (Stripe), Completion, Offline
// ─── BROWSE ───────────────────────────────────────────────────────────────────
function BrowseScreen({ onOpenTour, onTab, onSearch, dense }) {
const { TOURS } = window.WANDER_DATA;
const [cat, setCat] = React.useState('All');
const cats = ['All','Free','Food','History','Architecture','Nature','After dark'];
const featured = TOURS[1];
const list = cat==='Free' ? TOURS.filter(t=>t.tier==='free') : TOURS;
return (
Tours · Singapore
Where to next?
Search neighbourhoods, vibes…
{cats.map(c => (
setCat(c)} style={{ padding:'10px 16px', borderRadius:999, border:'none', cursor:'pointer', background:cat===c?'var(--w-ink)':'var(--w-paper)', color:cat===c?'var(--w-yellow)':'var(--w-ink)', fontWeight:700, fontSize:13, fontFamily:'inherit', flexShrink:0, boxShadow:cat===c?'none':'inset 0 0 0 1px var(--w-line)' }}>{c}
))}
onOpenTour(featured.id)} style={{ width:'100%', border:'none', padding:0, background:'transparent', cursor:'pointer', textAlign:'left', fontFamily:'inherit', position:'relative' }}>
Editor's pick
{featured.title}
{featured.duration} · {featured.stops} stops · ★ {featured.rating}
All tours
{list.map(t => (
onOpenTour(t.id)} style={{ display:'flex', gap:12, padding:dense?8:10, background:'var(--w-paper)', borderRadius:20, border:'none', boxShadow:'var(--sh-card)', cursor:'pointer', textAlign:'left', fontFamily:'inherit', alignItems:'center' }}>
{t.tier==='pro' ? PRO : FREE }
{t.title}
{t.duration} · {t.stops} stops · ★ {t.rating}
))}
);
}
// ─── SEARCH ───────────────────────────────────────────────────────────────────
function SearchScreen({ onClose, onOpenTour }) {
const { TOURS } = window.WANDER_DATA;
const [q, setQ] = React.useState('');
const recent = ['Hawker','Sunset spots','Free tours','Architecture','Family-friendly'];
const matches = q ? TOURS.filter(t => t.title.toLowerCase().includes(q.toLowerCase()) || t.subtitle.toLowerCase().includes(q.toLowerCase())) : [];
return (
{!q ? (
Recent
{recent.map(r => (
setQ(r)} style={{ display:'flex', alignItems:'center', gap:12, padding:'14px 0', borderBottom:'1px solid var(--w-line)', cursor:'pointer' }}>
{r}
))}
Trending in Singapore
{['Chinatown food','Sunset Marina','Hidden gardens','Heritage shophouses','Peranakan Joo Chiat'].map(t => (
setQ(t)} style={{ padding:'10px 14px', borderRadius:999, border:'none', background:'var(--w-yellow-soft)', color:'var(--w-yellow-ink)', fontWeight:700, fontSize:13, fontFamily:'inherit', cursor:'pointer' }}>{t}
))}
) : (
{matches.length} {matches.length===1?'result':'results'}
{matches.map(t => (
onOpenTour(t.id)} style={{ display:'flex', gap:12, padding:'12px 0', cursor:'pointer', borderBottom:'1px solid var(--w-line)', alignItems:'center' }}>
))}
)}
);
}
// ─── SAVED ────────────────────────────────────────────────────────────────────
function SavedScreen({ onTab, onOpenTour, onOpenOffline, dense }) {
const { TOURS } = window.WANDER_DATA;
const saved = TOURS.slice(0,4);
return (
{['Wishlist','Downloaded','Completed'].map((t,i) => (
i===1&&onOpenOffline()} style={{ flex:1, height:38, borderRadius:19, border:'none', cursor:'pointer', background:i===0?'var(--w-ink)':'transparent', color:i===0?'var(--w-yellow)':'var(--w-slate)', fontWeight:700, fontSize:13, fontFamily:'inherit', boxShadow:i===0?'none':'inset 0 0 0 1px var(--w-line)' }}>{t}
))}
{saved.map(t => (
onOpenTour(t.id)} style={{ background:'var(--w-paper)', borderRadius:22, padding:12, display:'flex', gap:14, alignItems:'center', boxShadow:'var(--sh-card)', cursor:'pointer' }}>
{t.title}
{t.duration} · {t.stops} stops
{t.formats.slice(0,3).map(f=>formatIcon(f,13,'var(--w-mute)'))}
))}
);
}
// ─── PROFILE ──────────────────────────────────────────────────────────────────
function ProfileScreen({ onTab, dense, authUser, authToken, onGoAuth, onGoCreator, onLogout }) {
const [showPassForm, setShowPassForm] = React.useState(false);
const [passForm, setPassForm] = React.useState({ current:'', next:'', confirm:'' });
const [passErr, setPassErr] = React.useState('');
const [passOk, setPassOk] = React.useState('');
const [savingPass, setSavingPass] = React.useState(false);
const handleChangePass = async e => {
e.preventDefault();
if (passForm.next !== passForm.confirm) { setPassErr('Passwords do not match'); return; }
setSavingPass(true); setPassErr(''); setPassOk('');
try {
const r = await fetch('/api/auth/me', {
method:'PUT',
headers:{ 'Content-Type':'application/json', Authorization:`Bearer ${authToken}` },
body: JSON.stringify({ currentPassword: passForm.current, newPassword: passForm.next }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error);
setPassOk('Password updated ✓');
setPassForm({ current:'', next:'', confirm:'' });
setShowPassForm(false);
} catch(e) { setPassErr(e.message); }
finally { setSavingPass(false); }
};
const badges = ['🦁 Singapore explorer','🌅 Sunrise walker','🍜 Hawker hunter','📜 History buff'];
const memberSince = authUser ? new Date(authUser.created_at).toLocaleDateString('en-SG',{month:'long',year:'numeric'}) : null;
const inputSty = { width:'100%', padding:'12px 14px', borderRadius:12, border:'1.5px solid var(--w-line)', fontSize:14, fontFamily:'inherit', color:'var(--w-ink)', background:'#fff', boxSizing:'border-box', outline:'none' };
// ── GUEST VIEW ─────────────────────────────────────────────────────────────
if (!authUser) {
const perks = [
['🎙️','Upload audio narrations','Record stories and guide travelers by voice'],
['📹','Add video content','Show beautiful footage along your route'],
['💰','Earn from Pro tours','Set your own price and keep 100% of earnings'],
['📍','Build interactive routes','Add stops, facts, and rich descriptions'],
];
return (
{/* Hero band */}
🧭
Become a Creator
Share your local knowledge with travelers who want the real story.
{/* Perks */}
{perks.map(([icon,title,desc],i,a)=>(
))}
{/* CTAs */}
onGoAuth('register')}>
Create free account
onGoAuth('login')}>
Sign in
{/* Static settings */}
Settings
{['Language · English','Voice · Mei Ling','Notifications','Help & Feedback'].map((s,i,a)=>(
{s}
))}
);
}
// ── LOGGED-IN VIEW ─────────────────────────────────────────────────────────
return (
{/* Yellow header band */}
{/* User card */}
{authUser.avatar_emoji || '🧭'}
{authUser.name}
{authUser.email}
{memberSince &&
Creator since {memberSince}
}
{authUser.bio && (
{authUser.bio}
)}
{/* Creator Studio CTA */}
Creator Studio
Manage your tours
Upload audio · video · set pricing
Open →
{/* Pro upsell */}
Wander Pro
All tours, every city
$4.99/month after trial
Try free
{/* Badges */}
Badges
{badges.map(b=>{b} )}
{/* Account settings */}
Account
setShowPassForm(v=>!v)}>
Change password
{showPassForm?'▲':'▼'}
{showPassForm && (
)}
{['Language · English','Voice · Mei Ling','Notifications','Help & Feedback'].map((s,i,a)=>(
{s}
))}
{/* Sign out */}
Sign out
);
}
// ─── PAYWALL (with Stripe) ────────────────────────────────────────────────────
function PaywallScreen({ tourId, onClose, onSuccess }) {
const { TOURS } = window.WANDER_DATA;
const tour = TOURS.find(t => t.id===tourId) || TOURS[1];
const [plan, setPlan] = React.useState('pro');
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState(null);
const plans = [
{ id:'tour', label:'Just this tour', price:'$'+tour.price, sub:'one-time', highlight:false },
{ id:'pro', label:'Wander Pro', price:'$4.99', sub:'per month · 7-day free trial', highlight:true },
];
// Listen for payment success from popup window
React.useEffect(() => {
const handler = (e) => {
if (e.data?.type==='WANDER_PAYMENT_SUCCESS') onSuccess();
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, [onSuccess]);
// Check if we landed back from Stripe redirect with ?unlocked=1
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('unlocked')==='1') {
window.history.replaceState({}, '', '/');
onSuccess();
}
}, []);
const handlePay = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tourId, plan }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Payment failed');
// Open Stripe Checkout in a small popup (stays inside the prototype view)
const popup = window.open(data.url, 'stripe_checkout', 'width=480,height=700,left=200,top=100');
if (!popup) {
// Fallback: redirect current tab
window.location.href = data.url;
}
} catch (e) {
if (e.message.includes('not configured')) {
// Demo mode — simulate success
setTimeout(() => onSuccess(), 1200);
} else {
setError(e.message);
}
} finally {
setLoading(false);
}
};
return (
{tour.emoji}
Unlock pro tour
{tour.title}
{tour.blurb}
{plans.map(p => (
setPlan(p.id)} style={{ width:'100%', padding:18, borderRadius:20, background:plan===p.id?'var(--w-yellow)':'rgba(255,255,255,0.06)', color:plan===p.id?'var(--w-yellow-ink)':'#fff', border:'none', cursor:'pointer', textAlign:'left', display:'flex', alignItems:'center', gap:14, fontFamily:'inherit', boxShadow:plan===p.id?'0 8px 24px rgba(255,201,60,0.35)':'inset 0 0 0 1px rgba(255,255,255,0.1)', position:'relative' }}>
{p.price}
{p.highlight && plan!==p.id && BEST VALUE
}
))}
{['Unlimited tours in 47 cities','Offline downloads & maps','AR overlays + bonus stops','Cancel anytime'].map(t => (
{t}
))}
{error &&
{error}
}
{loading ? 'Opening payment…' : plan==='pro' ? 'Start free trial' : 'Unlock for $'+tour.price}
Charged after 7 days. Cancel anytime in Settings.
);
}
// ─── COMPLETION ───────────────────────────────────────────────────────────────
function CompletionScreen({ tourId, onDone }) {
const { TOURS, stopsForTour } = window.WANDER_DATA;
const tour = TOURS.find(t=>t.id===tourId) || TOURS[0];
const STOPS = stopsForTour(tour.id);
const [stars, setStars] = React.useState(5);
return (
{Array.from({length:20}).map((_,i) => {
const colors=['#FFC93C','#FF6B5A','#2DD4A7','#9B7EDC','#4FB7E8'];
return
;
})}
🏆
Tour complete
You wandered like a local.
{tour.title} · all {STOPS.length} stops · {tour.distance} on foot
{[{l:'Time',v:'47 min'},{l:'Steps',v:'2,840'},{l:'Discoveries',v:'7'}].map(s => (
))}
🦁
Badge unlocked
Marina Bay Master
Rate this tour
{[1,2,3,4,5].map(n => setStars(n)} style={{ background:'none', border:'none', cursor:'pointer', padding:4 }}> )}
Done · find next tour
Share my walk
);
}
// ─── OFFLINE ──────────────────────────────────────────────────────────────────
function OfflineScreen({ onClose }) {
const { TOURS } = window.WANDER_DATA;
const off = window.WanderOffline;
const [ids, setIds] = React.useState(() => (off && off.supported()) ? off.list() : []);
const [used, setUsed] = React.useState(null);
const refresh = React.useCallback(() => {
if (!off || !off.supported()) return;
setIds(off.list());
off.estimate().then(setUsed);
}, [off]);
React.useEffect(() => { refresh(); }, [refresh]);
const removeTour = async (id) => { await off.remove(id); refresh(); };
const downloads = ids
.map(id => TOURS.find(t => t.id === id))
.filter(Boolean)
.map(tour => ({ tour }));
const usedMB = used ? Math.round(used.usageMB) : 0;
const quotaMB = used ? Math.round(used.quotaMB) : 0;
const usedPct = used ? Math.min(100, Math.max(2, used.pct * 100)) : 0;
return (
Device storage used
{used
? <>{usedMB} MB / {quotaMB >= 1024 ? (quotaMB/1024).toFixed(1)+' GB' : quotaMB+' MB'} >
: Not available
}
{downloads.length === 0 && (
📥
No offline tours yet
Open any tour and tap Download for offline to save it here.
)}
{downloads.map(d => (
{d.tour.title}
Available offline · narrated on-device
removeTour(d.tour.id)} title="Remove download" style={{ width:36, height:36, borderRadius:18, border:'none', background:'var(--w-cream)', cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center' }}>
))}
🎙️
Tip — download over Wi-Fi before you go.
Narration is read aloud by your phone's built-in voice, so it works fully offline. One catch: if your device is set to a
cloud-based voice, it may still need signal — pick an on-device voice in your system settings for guaranteed offline playback.
);
}
Object.assign(window, { BrowseScreen, SearchScreen, SavedScreen, ProfileScreen, PaywallScreen, CompletionScreen, OfflineScreen });