// Wander — Creator Studio (Dashboard + Tour Editor)
const HERO_COLORS = ['#FFC93C','#FF8A4C','#FF6B5A','#2DD4A7','#4FB7E8','#9B7EDC','#E89B6C','#1A1812'];
const ALL_FORMATS = ['audio','video','text','ar','quiz'];
// ─── CREATOR DASHBOARD ────────────────────────────────────────────────────────
function CreatorDashboardScreen({ authUser, authToken, onBack, onCreateTour, onEditTour }) {
const [tours, setTours] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const wrapRef = React.useRef(null);
const loadTours = () => {
fetch('/api/my/tours', { headers:{ Authorization:`Bearer ${authToken}` } })
.then(r => r.json())
.then(d => { setTours(Array.isArray(d)?d:[]); setLoading(false); })
.catch(() => setLoading(false));
};
React.useEffect(() => {
loadTours();
if (window.anime && wrapRef.current) {
anime({ targets: wrapRef.current, opacity:[0,1], translateY:[20,0], duration:400, easing:'easeOutQuart' });
}
}, []);
const deleteTour = async id => {
if (!window.confirm('Delete this tour? This cannot be undone.')) return;
await fetch(`/api/my/tours/${id}`, { method:'DELETE', headers:{ Authorization:`Bearer ${authToken}` } });
setTours(t => t.filter(x => x.id !== id));
if (window.anime) {
anime({ targets:`[data-tour-id="${id}"]`, opacity:[1,0], height:[80,0], marginBottom:[10,0], duration:280, easing:'easeInQuart' });
}
};
const totalAudio = tours.filter(t => t.audio_url).length;
const totalVideo = tours.filter(t => t.video_url).length;
return (
{/* Header */}
Creator Studio
{authUser?.avatar_emoji || '🧭'} {authUser?.name || 'My Tours'}
{/* Stats */}
{[['Tours',tours.length,'🗺️'],['🎙️ Audio',totalAudio,null],['📹 Video',totalVideo,null]].map(([label,v]) => (
))}
{/* Tour list */}
{loading ? (
Loading your tours…
) : tours.length === 0 ? (
🗺️
No tours yet
Create your first tour and share your local stories with travelers from around the world.
) : (
{tours.map(t => (
{t.title}
{t.duration} · {t.stops} stops
{t.tier==='pro'
? PRO ${t.price}
: FREE}
{t.audio_url ? '🎙️ audio' : '⚠️ no audio'}
{t.video_url && 📹 video}
))}
)}
);
}
// ─── TOUR EDITOR ──────────────────────────────────────────────────────────────
function TourEditorScreen({ authToken, existingTour, onBack }) {
const isNew = !existingTour;
const [tab, setTab] = React.useState('details');
const [savedTour, setSavedTour] = React.useState(existingTour || null);
const [saving, setSaving] = React.useState(false);
const [saveErr, setSaveErr] = React.useState('');
const [saveOk, setSaveOk] = React.useState('');
const [form, setForm] = React.useState({
title: existingTour?.title || '',
subtitle: existingTour?.subtitle || '',
blurb: existingTour?.blurb || '',
duration: existingTour?.duration || '30 min',
distance: existingTour?.distance || '1.0 km',
stops: existingTour?.stops || 3,
tier: existingTour?.tier || 'free',
price: existingTour?.price || 0,
emoji: existingTour?.emoji || '📍',
formats: existingTour?.formats || ['audio'],
hero: existingTour?.hero || ['#FFC93C','#FF8A4C'],
});
// Media upload state
const [audioProgress, setAudioProgress] = React.useState(null);
const [audioError, setAudioError] = React.useState('');
const [audioDone, setAudioDone] = React.useState(!!existingTour?.audio_url);
const [videoProgress, setVideoProgress] = React.useState(null);
const [videoError, setVideoError] = React.useState('');
const [videoDone, setVideoDone] = React.useState(!!existingTour?.video_url);
const audioInputRef = React.useRef();
const videoInputRef = React.useRef();
const currentId = savedTour?.id;
const setF = k => e => setForm(f => ({ ...f, [k]: e.target.value }));
const setFN = k => e => setForm(f => ({ ...f, [k]: parseFloat(e.target.value)||0 }));
const toggleFormat = fmt => setForm(f => ({
...f, formats: f.formats.includes(fmt) ? f.formats.filter(x=>x!==fmt) : [...f.formats, fmt],
}));
const handleSave = async e => {
e.preventDefault();
if (!form.title.trim()) { setSaveErr('Tour title is required'); return; }
setSaving(true); setSaveErr(''); setSaveOk('');
try {
const method = (isNew && !savedTour) ? 'POST' : 'PUT';
const url = (isNew && !savedTour) ? '/api/my/tours' : `/api/my/tours/${currentId}`;
const r = await fetch(url, {
method, headers:{ 'Content-Type':'application/json', Authorization:`Bearer ${authToken}` },
body: JSON.stringify({ ...form, stops: parseInt(form.stops)||0 }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error);
setSavedTour(d);
setSaveOk(isNew && !savedTour ? 'Tour created! Now upload your audio & video below.' : 'Changes saved ✓');
if (isNew && !savedTour) {
setTimeout(() => setTab('media'), 800);
}
} catch(e) {
setSaveErr(e.message);
} finally { setSaving(false); }
};
const uploadFile = (type, file) => {
if (!currentId) return;
const setProgress = type==='audio' ? setAudioProgress : setVideoProgress;
const setError = type==='audio' ? setAudioError : setVideoError;
const setDone = type==='audio' ? setAudioDone : setVideoDone;
setProgress(0); setError('');
const fd = new FormData();
fd.append('file', file);
const xhr = new XMLHttpRequest();
xhr.open('POST', `/api/my/tours/${currentId}/${type}`);
xhr.setRequestHeader('Authorization', `Bearer ${authToken}`);
xhr.upload.onprogress = ev => {
if (ev.lengthComputable) setProgress(Math.round(ev.loaded/ev.total*100));
};
xhr.onload = () => {
if (xhr.status===200) { setDone(true); setProgress(null); }
else { setError('Upload failed — please try again'); setProgress(null); }
};
xhr.onerror = () => { setError('Network error — check your connection'); setProgress(null); };
xhr.send(fd);
};
const deleteMedia = async type => {
if (!currentId) return;
await fetch(`/api/my/tours/${currentId}/${type}`, { method:'DELETE', headers:{ Authorization:`Bearer ${authToken}` } });
if (type==='audio') setAudioDone(false);
else setVideoDone(false);
};
// Styles
const S = {
label: { fontSize:11, fontWeight:700, color:'var(--w-mute)', textTransform:'uppercase', letterSpacing:0.5, display:'block', marginBottom:5 },
input: { width:'100%', padding:'12px 14px', borderRadius:13, border:'1.5px solid var(--w-line)', fontSize:14, fontFamily:'inherit', color:'var(--w-ink)', background:'#fff', boxSizing:'border-box', outline:'none' },
section: { marginBottom:18 },
chip: active => ({ padding:'8px 14px', borderRadius:999, border:'none', cursor:'pointer', fontFamily:'inherit', fontWeight:600, fontSize:12, background: active?'var(--w-ink)':'var(--w-paper)', color: active?'var(--w-yellow)':'var(--w-mute)', boxShadow: active?'none':'inset 0 0 0 1px var(--w-line)' }),
};
return (
{/* Header */}
{isNew && !savedTour ? 'New Tour' : form.title || 'Edit Tour'}
{form.emoji &&
{form.emoji}}
{/* Tab bar */}
{[['details','📝 Details'],['media','🎙️ Media']].map(([id,label]) => (
))}
{/* Scrollable content */}
{/* ── DETAILS TAB ── */}
{tab==='details' && (
)}
{/* ── MEDIA TAB ── */}
{tab==='media' && (
{!currentId ? (
💾
Save your tour details first, then come back here to upload audio and video.
) : (
{/* Audio card */}
🎙️
Audio Narration
MP3, AAC, M4A, WAV · up to 200 MB
{audioDone &&
✅}
{audioProgress !== null ? (
Uploading…
{audioProgress}%
) : (
{ if(e.target.files[0]) uploadFile('audio',e.target.files[0]); e.target.value=''; }}/>
{audioDone && (
)}
)}
{audioError &&
{audioError}
}
{/* Video card */}
📹
Video Content
MP4, MOV, MKV · up to 2 GB
{videoDone &&
✅}
{videoProgress !== null ? (
Uploading…
{videoProgress}%
) : (
{ if(e.target.files[0]) uploadFile('video',e.target.files[0]); e.target.value=''; }}/>
{videoDone && (
)}
)}
{videoError &&
{videoError}
}
{/* Tips */}
💡 Recording tips
- Record in a quiet room — ambient noise kills immersion
- MP3 at 128 kbps or higher sounds great in the player
- Aim for 1–3 min per stop · 720p video is fine
- Files are streamed, so size doesn't affect the listener's experience
)}
)}
);
}