/* ========================================================= Our Journey - Wedding Planner Application Full React 18 SPA with PHP API backend ========================================================= */ const { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } = React; // ---- API Helper ---- const API_BASE = '/api'; async function api(path, options = {}) { const url = API_BASE + path; const config = { credentials: 'include', headers: {}, ...options, }; if (options.body && !(options.body instanceof FormData)) { config.headers['Content-Type'] = 'application/json'; config.body = JSON.stringify(options.body); } const res = await fetch(url, config); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(data.error || `HTTP ${res.status}`); } return data; } // ---- Toast Context ---- const ToastContext = createContext(); function ToastProvider({ children }) { const [toasts, setToasts] = useState([]); const idRef = useRef(0); const addToast = useCallback((message, type = 'success') => { const id = ++idRef.current; setToasts(prev => [...prev, { id, message, type }]); setTimeout(() => { setToasts(prev => prev.filter(t => t.id !== id)); }, 3000); }, []); return React.createElement(ToastContext.Provider, { value: addToast }, children, React.createElement('div', { className: 'toast-container' }, toasts.map(t => React.createElement('div', { key: t.id, className: `toast ${t.type}` }, t.message) ) ) ); } function useToast() { return useContext(ToastContext); } // ---- Wedding Quotes ---- const QUOTES = [ "\"And among His signs is that He created for you mates from among yourselves, that you may dwell in tranquility with them.\" - Quran 30:21", "\"A great marriage is not when the perfect couple comes together, but when an imperfect couple learns to enjoy their differences.\" - Dave Meurer", "\"The best thing to hold onto in life is each other.\" - Audrey Hepburn", "\"In all the world, there is no heart for me like yours. In all the world, there is no love for you like mine.\" - Maya Angelou", "\"A successful marriage requires falling in love many times, always with the same person.\" - Mignon McLaughlin", "\"Whatever our souls are made of, his and hers are the same.\" - Emily Bronte", "\"You don't marry someone you can live with — you marry someone you cannot live without.\"", "\"A happy marriage is a long conversation which always seems too short.\" - Andre Maurois", "\"To love is nothing. To be loved is something. But to love and be loved — that's everything.\" - T. Tolis", "\"The real act of marriage takes place in the heart, not in the ballroom or church.\" - Barbara de Angelis", "\"Grow old along with me; the best is yet to be.\" - Robert Browning", "\"I choose you. And I'll choose you over and over, without pause, without a doubt, in a heartbeat.\"", "\"Marriage is not a noun; it's a verb. It isn't something you get, it's something you do.\" - Barbara De Angelis", "\"Two are better than one, because they have a good return for their labour.\" - Ecclesiastes 4:9", "\"He who finds a wife finds a good thing, and obtains favour from the Lord.\" - Proverbs 18:22", "\"The most important thing in the world is family and love.\" - John Wooden", "\"There is no more lovely, friendly or charming relationship than a good marriage.\" - Martin Luther", "\"Being deeply loved by someone gives you strength, while loving someone deeply gives you courage.\" - Lao Tzu", "\"Come what may — and love it.\" - Joseph B. Wirthlin", "\"Where there is love there is life.\" - Mahatma Gandhi" ]; function getRandomQuote() { return QUOTES[Math.floor(Math.random() * QUOTES.length)]; } // ---- Geometric Background ---- function GeoBg() { return React.createElement('div', { className: 'geo-bg' }, React.createElement('svg', { viewBox: '0 0 800 600', preserveAspectRatio: 'xMidYMid slice' }, React.createElement('defs', null, React.createElement('pattern', { id: 'geo', x: 0, y: 0, width: 100, height: 100, patternUnits: 'userSpaceOnUse' }, React.createElement('polygon', { points: '50,5 95,27.5 95,72.5 50,95 5,72.5 5,27.5', fill: 'none', stroke: '#D4AF37', strokeWidth: '0.5' }), React.createElement('circle', { cx: 50, cy: 50, r: 20, fill: 'none', stroke: '#2E8B57', strokeWidth: '0.3' }), React.createElement('line', { x1: 0, y1: 50, x2: 100, y2: 50, stroke: '#D4AF37', strokeWidth: '0.2' }), React.createElement('line', { x1: 50, y1: 0, x2: 50, y2: 100, stroke: '#D4AF37', strokeWidth: '0.2' }) ) ), React.createElement('rect', { width: '100%', height: '100%', fill: 'url(#geo)' }) ) ); } // ---- SVG Icons (inline) ---- function CheckIcon({ size = 12 }) { return React.createElement('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 3, strokeLinecap: 'round', strokeLinejoin: 'round' }, React.createElement('polyline', { points: '20 6 9 17 4 12' }) ); } function ChevronIcon({ direction = 'right', size = 16 }) { const rotation = { right: 0, down: 90, left: 180, up: 270 }; return React.createElement('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', style: { transform: `rotate(${rotation[direction]}deg)`, transition: 'transform 0.2s' } }, React.createElement('polyline', { points: '9 18 15 12 9 6' }) ); } // ---- Format Currency ---- function formatCurrency(amount) { return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'EUR' }).format(amount || 0); } // ---- Color presets ---- const COLOR_PRESETS = ['#D4AF37', '#2E8B57', '#E74C3C', '#3498DB', '#9B59B6', '#E67E22', '#1ABC9C', '#F39C12', '#C0392B', '#8E44AD']; // ================================================================ // LOGIN SCREEN // ================================================================ function LoginScreen({ onLogin, joinToken }) { const [mode, setMode] = useState(joinToken ? 'join' : 'login'); const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const [quote] = useState(getRandomQuote); async function handleSubmit(e) { e.preventDefault(); setError(''); setLoading(true); try { let result; if (mode === 'login') { result = await api('/auth/login', { method: 'POST', body: { email, password } }); } else if (mode === 'register') { result = await api('/auth/register', { method: 'POST', body: { name, email, password } }); } else if (mode === 'join') { result = await api('/couple/join?token=' + joinToken, { method: 'POST', body: { name, email, password } }); } onLogin(result); } catch (err) { setError(err.message); } finally { setLoading(false); } } const titles = { login: 'Log in', register: 'Create account', join: 'Join your partner' }; return React.createElement(React.Fragment, null, React.createElement(GeoBg), React.createElement('div', { className: 'auth-screen' }, React.createElement('form', { className: 'auth-card', onSubmit: handleSubmit }, React.createElement('h1', { className: 'font-display' }, "Our Journey \u0645\u0639\u0627\u064B"), React.createElement('div', { className: 'subtitle' }, titles[mode]), React.createElement('div', { className: 'quote' }, quote), error && React.createElement('div', { className: 'auth-error' }, error), (mode === 'register' || mode === 'join') && React.createElement('div', { className: 'form-group' }, React.createElement('label', null, 'First name'), React.createElement('input', { type: 'text', className: 'form-input', placeholder: 'Your first name', value: name, onChange: e => setName(e.target.value), required: true, autoComplete: 'given-name' }) ), React.createElement('div', { className: 'form-group' }, React.createElement('label', null, 'Email'), React.createElement('input', { type: 'email', className: 'form-input', placeholder: 'your@email.com', value: email, onChange: e => setEmail(e.target.value), required: true, autoComplete: 'email' }) ), React.createElement('div', { className: 'form-group' }, React.createElement('label', null, 'Password'), React.createElement('input', { type: 'password', className: 'form-input', placeholder: 'Minimum 6 characters', value: password, onChange: e => setPassword(e.target.value), required: true, minLength: 6 }) ), React.createElement('button', { type: 'submit', className: 'btn btn-primary', disabled: loading }, loading ? 'Loading...' : (mode === 'login' ? 'Log in' : (mode === 'join' ? 'Join' : 'Sign up'))), mode !== 'join' && React.createElement('div', { className: 'auth-toggle' }, mode === 'login' ? React.createElement(React.Fragment, null, "Don't have an account? ", React.createElement('a', { onClick: () => { setMode('register'); setError(''); } }, 'Sign up')) : React.createElement(React.Fragment, null, "Already have an account? ", React.createElement('a', { onClick: () => { setMode('login'); setError(''); } }, 'Log in')) ) ) ) ); } // ================================================================ // ONBOARDING SCREENS // ================================================================ function OnboardingScreen({ user, couple, onComplete }) { const [step, setStep] = useState(0); const [name, setName] = useState(user.name || ''); const [color, setColor] = useState(user.color || '#D4AF37'); const [avatarPreview, setAvatarPreview] = useState(user.photo_path ? '/' + user.photo_path : null); const [avatarFile, setAvatarFile] = useState(null); const [inviteUrl, setInviteUrl] = useState(''); const [copied, setCopied] = useState(false); const [saving, setSaving] = useState(false); const toast = useToast(); const fileInputRef = useRef(null); useEffect(() => { if (couple && couple.invite_token) { setInviteUrl('https://wedding.boubacarbarry.fr/?join=' + couple.invite_token); } }, [couple]); function handleAvatarChange(e) { const file = e.target.files[0]; if (!file) return; if (file.size > 5 * 1024 * 1024) { toast('Image too large (max 5MB)', 'error'); return; } setAvatarFile(file); const reader = new FileReader(); reader.onload = () => setAvatarPreview(reader.result); reader.readAsDataURL(file); } async function saveProfile() { setSaving(true); try { await api('/users/profile', { method: 'PUT', body: { name, color } }); if (avatarFile) { const formData = new FormData(); formData.append('file', avatarFile); await api('/users/avatar', { method: 'POST', body: formData }); } setStep(2); } catch (err) { toast(err.message, 'error'); } finally { setSaving(false); } } function copyInvite() { navigator.clipboard.writeText(inviteUrl).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); } function shareWhatsApp() { const text = encodeURIComponent("Join me to plan our wedding! " + inviteUrl); window.open('https://wa.me/?text=' + text, '_blank'); } // Step 0: Welcome if (step === 0) { return React.createElement(React.Fragment, null, React.createElement(GeoBg), React.createElement('div', { className: 'onboarding' }, React.createElement('div', { className: 'onboarding-card' }, React.createElement('div', { style: { fontSize: 48, marginBottom: 16 } }, String.fromCodePoint(0x1F48D)), React.createElement('h2', null, 'Welcome to Our Journey'), React.createElement('p', null, 'Plan your wedding together, step by step. Organise your tasks, manage your budget and share everything with your partner.'), React.createElement('button', { className: 'btn btn-primary', onClick: () => setStep(1) }, 'Get started'), React.createElement('div', { className: 'onboarding-dots' }, [0, 1, 2].map(i => React.createElement('div', { key: i, className: `onboarding-dot ${i === 0 ? 'active' : ''}` }) ) ) ) ) ); } // Step 1: Profile setup if (step === 1) { return React.createElement(React.Fragment, null, React.createElement(GeoBg), React.createElement('div', { className: 'onboarding' }, React.createElement('div', { className: 'onboarding-card' }, React.createElement('h2', null, 'Your profile'), React.createElement('p', null, 'Customise your profile so you can be identified as a couple.'), React.createElement('div', { className: 'avatar-upload', onClick: () => fileInputRef.current.click() }, React.createElement('div', { className: 'avatar-circle', style: { borderColor: color } }, avatarPreview ? React.createElement('img', { src: avatarPreview, alt: 'Avatar' }) : name.charAt(0).toUpperCase() || '?' ), React.createElement('div', { className: 'avatar-badge' }, '+'), React.createElement('input', { ref: fileInputRef, type: 'file', accept: 'image/*', style: { display: 'none' }, onChange: handleAvatarChange }) ), React.createElement('div', { className: 'form-group' }, React.createElement('label', null, 'Your first name'), React.createElement('input', { type: 'text', className: 'form-input', value: name, onChange: e => setName(e.target.value), style: { textAlign: 'center' } }) ), React.createElement('label', { style: { fontSize: 13, color: 'var(--text-secondary)' } }, 'Your colour'), React.createElement('div', { className: 'color-picker-row' }, COLOR_PRESETS.map(c => React.createElement('div', { key: c, className: `color-swatch ${color === c ? 'active' : ''}`, style: { backgroundColor: c }, onClick: () => setColor(c) }) ) ), React.createElement('button', { className: 'btn btn-primary', onClick: saveProfile, disabled: saving || !name.trim() }, saving ? 'Saving...' : 'Continue'), React.createElement('div', { className: 'onboarding-dots' }, [0, 1, 2].map(i => React.createElement('div', { key: i, className: `onboarding-dot ${i === 1 ? 'active' : ''}` }) ) ) ) ) ); } // Step 2: Invite partner return React.createElement(React.Fragment, null, React.createElement(GeoBg), React.createElement('div', { className: 'onboarding' }, React.createElement('div', { className: 'onboarding-card' }, React.createElement('div', { style: { fontSize: 48, marginBottom: 16 } }, String.fromCodePoint(0x1F495)), React.createElement('h2', null, 'Invite your partner'), React.createElement('p', null, 'Share this link so your partner can join you and collaborate on the planning.'), React.createElement('div', { className: 'invite-section' }, React.createElement('div', { className: 'invite-link-box' }, React.createElement('input', { type: 'text', readOnly: true, value: inviteUrl }), React.createElement('button', { onClick: copyInvite }, copied ? 'Copied!' : 'Copy') ), React.createElement('button', { className: 'btn btn-secondary', onClick: shareWhatsApp }, 'Share via WhatsApp' ) ), React.createElement('button', { className: 'btn btn-ghost', style: { marginTop: 16 }, onClick: onComplete }, 'Skip for now'), React.createElement('div', { className: 'onboarding-dots' }, [0, 1, 2].map(i => React.createElement('div', { key: i, className: `onboarding-dot ${i === 2 ? 'active' : ''}` }) ) ) ) ) ); } // ================================================================ // STEP MODAL (Create / Edit) // ================================================================ function StepModal({ step, onSave, onClose }) { const isEdit = !!step; const [emoji, setEmoji] = useState(step?.emoji || ''); const [title, setTitle] = useState(step?.title || ''); const [subtitle, setSubtitle] = useState(step?.subtitle || ''); const [color, setColor] = useState(step?.color || '#D4AF37'); const [deadline, setDeadline] = useState(step?.deadline || ''); const [saving, setSaving] = useState(false); const toast = useToast(); const EMOJI_SUGGESTIONS = ['', '', '', '', '', '', '', '', '', '', '', '', '', '', '']; async function handleSave() { if (!title.trim()) return; setSaving(true); try { const body = { emoji, title: title.trim(), subtitle: subtitle.trim(), color, deadline: deadline || null }; if (isEdit) { const updated = await api('/steps/' + step.id, { method: 'PUT', body }); onSave(updated, 'update'); } else { const created = await api('/steps/', { method: 'POST', body }); onSave(created, 'create'); } } catch (err) { toast(err.message, 'error'); } finally { setSaving(false); } } return React.createElement('div', { className: 'modal-overlay', onClick: e => e.target === e.currentTarget && onClose() }, React.createElement('div', { className: 'modal-content' }, React.createElement('div', { className: 'modal-header' }, React.createElement('h3', null, isEdit ? 'Edit step' : 'New step'), React.createElement('button', { className: 'modal-close', onClick: onClose }, String.fromCharCode(10005)) ), React.createElement('div', { style: { marginBottom: 16 } }, React.createElement('label', { style: { fontSize: 13, color: 'var(--text-secondary)', marginBottom: 6, display: 'block' } }, 'Icon'), React.createElement('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } }, EMOJI_SUGGESTIONS.map(em => React.createElement('button', { key: em, type: 'button', style: { fontSize: 24, background: emoji === em ? 'var(--bg-secondary)' : 'transparent', border: emoji === em ? '2px solid var(--gold)' : '2px solid transparent', borderRadius: 8, padding: '4px 8px', cursor: 'pointer' }, onClick: () => setEmoji(em) }, em) ) ) ), React.createElement('div', { className: 'form-group' }, React.createElement('label', null, 'Title *'), React.createElement('input', { className: 'form-input', value: title, onChange: e => setTitle(e.target.value), placeholder: 'E.g. Wedding venue', required: true }) ), React.createElement('div', { className: 'form-group' }, React.createElement('label', null, 'Subtitle'), React.createElement('input', { className: 'form-input', value: subtitle, onChange: e => setSubtitle(e.target.value), placeholder: 'Optional description' }) ), React.createElement('div', { style: { display: 'flex', gap: 16 } }, React.createElement('div', { className: 'form-group', style: { flex: 1 } }, React.createElement('label', null, 'Colour'), React.createElement('div', { className: 'color-picker-row', style: { justifyContent: 'flex-start', margin: '8px 0 0' } }, COLOR_PRESETS.map(c => React.createElement('div', { key: c, className: `color-swatch ${color === c ? 'active' : ''}`, style: { backgroundColor: c, width: 28, height: 28 }, onClick: () => setColor(c) }) ) ) ), React.createElement('div', { className: 'form-group', style: { flex: 1 } }, React.createElement('label', null, 'Deadline'), React.createElement('input', { type: 'date', className: 'form-input', value: deadline, onChange: e => setDeadline(e.target.value) }) ) ), React.createElement('div', { className: 'modal-actions' }, React.createElement('button', { className: 'btn btn-secondary', onClick: onClose }, 'Cancel'), React.createElement('button', { className: 'btn btn-primary', style: { width: 'auto' }, onClick: handleSave, disabled: saving || !title.trim() }, saving ? 'Saving...' : (isEdit ? 'Update' : 'Create')) ) ) ); } // ================================================================ // EXPENSE MODAL // ================================================================ function ExpenseModal({ expense, stepId, userRole, onSave, onClose }) { const isEdit = !!expense; const [form, setForm] = useState({ name: expense?.name || '', category: expense?.category || 'Other', vendor: expense?.vendor || '', notes: expense?.notes || '', link: expense?.link || '', estimated: expense?.estimated || 0, quoted: expense?.quoted || 0, paid: expense?.paid || 0, status: expense?.status || 'estimating', visibility: expense?.visibility || 'both', payer: expense?.payer || 'shared' }); const [saving, setSaving] = useState(false); const toast = useToast(); function updateField(field, value) { setForm(prev => ({ ...prev, [field]: value })); } async function handleSave() { if (!form.name.trim()) return; setSaving(true); try { if (isEdit) { const updated = await api('/expenses/' + expense.id, { method: 'PUT', body: form }); onSave(updated, 'update'); } else { const created = await api('/expenses/', { method: 'POST', body: { ...form, step_id: stepId } }); onSave(created, 'create'); } } catch (err) { toast(err.message, 'error'); } finally { setSaving(false); } } const categories = ['Venue','Catering','Photography','Attire','Jewellery','Ceremony','Music','Flowers','Travel','Home','Admin','Other']; const statuses = ['estimating','quoted','deposit','paid']; const visibilities = ['both','user1','user2']; const payers = ['user1','user2','user1-family','user2-family','shared']; const statusLabels = { estimating: 'Estimating', quoted: 'Quote received', deposit: 'Deposit paid', paid: 'Fully paid' }; const visibilityLabels = { both: 'Visible to both', user1: 'Partner 1 only', user2: 'Partner 2 only' }; const payerLabels = { user1: 'Partner 1', user2: 'Partner 2', 'user1-family': 'Partner 1 family', 'user2-family': 'Partner 2 family', shared: 'Shared' }; return React.createElement('div', { className: 'modal-overlay', onClick: e => e.target === e.currentTarget && onClose() }, React.createElement('div', { className: 'modal-content' }, React.createElement('div', { className: 'modal-header' }, React.createElement('h3', null, isEdit ? 'Edit expense' : 'New expense'), React.createElement('button', { className: 'modal-close', onClick: onClose }, String.fromCharCode(10005)) ), React.createElement('div', { className: 'expense-form-grid' }, React.createElement('div', { className: 'form-group full-width' }, React.createElement('label', null, 'Name *'), React.createElement('input', { className: 'form-input', value: form.name, onChange: e => updateField('name', e.target.value), placeholder: 'E.g. Wedding venue' }) ), React.createElement('div', { className: 'form-group' }, React.createElement('label', null, 'Category'), React.createElement('select', { className: 'form-select', value: form.category, onChange: e => updateField('category', e.target.value) }, categories.map(c => React.createElement('option', { key: c, value: c }, c)) ) ), React.createElement('div', { className: 'form-group' }, React.createElement('label', null, 'Vendor'), React.createElement('input', { className: 'form-input', value: form.vendor, onChange: e => updateField('vendor', e.target.value), placeholder: 'Vendor name' }) ), React.createElement('div', { className: 'form-group' }, React.createElement('label', null, 'Estimated'), React.createElement('input', { type: 'number', className: 'form-input', value: form.estimated, onChange: e => updateField('estimated', parseFloat(e.target.value) || 0), min: 0, step: '0.01' }) ), React.createElement('div', { className: 'form-group' }, React.createElement('label', null, 'Quoted'), React.createElement('input', { type: 'number', className: 'form-input', value: form.quoted, onChange: e => updateField('quoted', parseFloat(e.target.value) || 0), min: 0, step: '0.01' }) ), React.createElement('div', { className: 'form-group' }, React.createElement('label', null, 'Paid'), React.createElement('input', { type: 'number', className: 'form-input', value: form.paid, onChange: e => updateField('paid', parseFloat(e.target.value) || 0), min: 0, step: '0.01' }) ), React.createElement('div', { className: 'form-group' }, React.createElement('label', null, 'Status'), React.createElement('select', { className: 'form-select', value: form.status, onChange: e => updateField('status', e.target.value) }, statuses.map(s => React.createElement('option', { key: s, value: s }, statusLabels[s])) ) ), React.createElement('div', { className: 'form-group' }, React.createElement('label', null, 'Visibility'), React.createElement('select', { className: 'form-select', value: form.visibility, onChange: e => updateField('visibility', e.target.value) }, visibilities.map(v => React.createElement('option', { key: v, value: v }, visibilityLabels[v])) ) ), React.createElement('div', { className: 'form-group' }, React.createElement('label', null, 'Payer'), React.createElement('select', { className: 'form-select', value: form.payer, onChange: e => updateField('payer', e.target.value) }, payers.map(p => React.createElement('option', { key: p, value: p }, payerLabels[p])) ) ), React.createElement('div', { className: 'form-group full-width' }, React.createElement('label', null, 'Link'), React.createElement('input', { className: 'form-input', value: form.link, onChange: e => updateField('link', e.target.value), placeholder: 'https://...' }) ), React.createElement('div', { className: 'form-group full-width' }, React.createElement('label', null, 'Notes'), React.createElement('textarea', { className: 'form-textarea', value: form.notes, onChange: e => updateField('notes', e.target.value), placeholder: 'Additional notes...' }) ) ), React.createElement('div', { className: 'modal-actions' }, React.createElement('button', { className: 'btn btn-secondary', onClick: onClose }, 'Cancel'), React.createElement('button', { className: 'btn btn-primary', style: { width: 'auto' }, onClick: handleSave, disabled: saving || !form.name.trim() }, saving ? 'Saving...' : (isEdit ? 'Update' : 'Add')) ) ) ); } // ================================================================ // IMAGE STRIP + UPLOAD // ================================================================ function ExpenseImageStrip({ expense, onUpdate }) { const [lightboxIdx, setLightboxIdx] = useState(null); const [uploading, setUploading] = useState(false); const fileRef = useRef(null); const toast = useToast(); const images = expense.images || []; async function handleUpload(e) { const file = e.target.files[0]; if (!file) return; if (file.size > 5 * 1024 * 1024) { toast('Max 5MB', 'error'); return; } setUploading(true); try { const formData = new FormData(); formData.append('file', file); formData.append('expense_id', expense.id); const img = await api('/images/upload', { method: 'POST', body: formData }); onUpdate({ ...expense, images: [...images, img] }); toast('Image added'); } catch (err) { toast(err.message, 'error'); } finally { setUploading(false); if (fileRef.current) fileRef.current.value = ''; } } async function handleDelete(imgId) { try { await api('/images/delete?id=' + imgId, { method: 'DELETE' }); onUpdate({ ...expense, images: images.filter(i => i.id !== imgId) }); toast('Image deleted'); } catch (err) { toast(err.message, 'error'); } } return React.createElement(React.Fragment, null, React.createElement('div', { className: 'image-strip' }, images.map((img, idx) => React.createElement('div', { key: img.id, className: 'image-strip-item' }, React.createElement('img', { src: '/' + img.file_path, alt: img.original_name, onClick: () => setLightboxIdx(idx) }), React.createElement('button', { className: 'delete-img', onClick: e => { e.stopPropagation(); handleDelete(img.id); } }, String.fromCharCode(10005)) ) ), React.createElement('div', { className: 'image-upload-btn', onClick: () => fileRef.current?.click() }, uploading ? '...' : '+', React.createElement('input', { ref: fileRef, type: 'file', accept: 'image/jpeg,image/png,image/webp', style: { display: 'none' }, onChange: handleUpload }) ) ), lightboxIdx !== null && React.createElement('div', { className: 'lightbox', onClick: () => setLightboxIdx(null) }, React.createElement('img', { src: '/' + images[lightboxIdx].file_path, alt: '' }), React.createElement('button', { className: 'lightbox-close', onClick: () => setLightboxIdx(null) }, String.fromCharCode(10005)), lightboxIdx > 0 && React.createElement('button', { className: 'lightbox-nav prev', onClick: e => { e.stopPropagation(); setLightboxIdx(lightboxIdx - 1); } }, String.fromCharCode(8592)), lightboxIdx < images.length - 1 && React.createElement('button', { className: 'lightbox-nav next', onClick: e => { e.stopPropagation(); setLightboxIdx(lightboxIdx + 1); } }, String.fromCharCode(8594)) ) ); } // ================================================================ // TASK ROW // ================================================================ function TaskRow({ task, onUpdate, onDelete }) { const [expanded, setExpanded] = useState(task.expanded || false); const [newSubtask, setNewSubtask] = useState(''); const toast = useToast(); const subtasks = task.subtasks || []; async function toggleDone() { const newDone = !task.done; onUpdate({ ...task, done: newDone }); try { await api('/tasks/' + task.id, { method: 'PUT', body: { done: newDone } }); } catch (err) { onUpdate({ ...task, done: !newDone }); toast(err.message, 'error'); } } async function toggleExpanded() { const newExp = !expanded; setExpanded(newExp); try { await api('/tasks/' + task.id, { method: 'PUT', body: { expanded: newExp } }); } catch (err) { /* non-critical */ } } async function addSubtask(e) { e.preventDefault(); if (!newSubtask.trim()) return; try { const st = await api('/subtasks/', { method: 'POST', body: { task_id: task.id, text: newSubtask.trim() } }); onUpdate({ ...task, subtasks: [...subtasks, st] }); setNewSubtask(''); } catch (err) { toast(err.message, 'error'); } } async function toggleSubtask(st) { const newDone = !st.done; const newSubs = subtasks.map(s => s.id === st.id ? { ...s, done: newDone } : s); onUpdate({ ...task, subtasks: newSubs }); try { await api('/subtasks/' + st.id, { method: 'PUT', body: { done: newDone } }); } catch (err) { toast(err.message, 'error'); } } async function deleteSubtask(stId) { const newSubs = subtasks.filter(s => s.id !== stId); onUpdate({ ...task, subtasks: newSubs }); try { await api('/subtasks/' + stId, { method: 'DELETE' }); } catch (err) { toast(err.message, 'error'); } } async function handleDelete() { try { await api('/tasks/' + task.id, { method: 'DELETE' }); onDelete(task.id); } catch (err) { toast(err.message, 'error'); } } return React.createElement('div', { className: 'task-row' }, React.createElement('div', { className: 'task-main' }, React.createElement('div', { className: 'task-drag', title: 'Drag to reorder' }, String.fromCharCode(9776)), React.createElement('div', { className: `task-checkbox ${task.done ? 'checked' : ''}`, onClick: toggleDone }, task.done && React.createElement(CheckIcon)), React.createElement('span', { className: `task-text ${task.done ? 'done' : ''}` }, task.text), subtasks.length > 0 || true ? React.createElement('button', { className: 'task-expand-btn', onClick: toggleExpanded }, React.createElement(ChevronIcon, { direction: expanded ? 'down' : 'right', size: 14 })) : null, React.createElement('button', { className: 'task-delete-btn', onClick: handleDelete }, String.fromCharCode(10005)) ), expanded && React.createElement('div', { className: 'subtasks-container' }, subtasks.map(st => React.createElement('div', { key: st.id, className: 'subtask-row' }, React.createElement('div', { className: `subtask-checkbox ${st.done ? 'checked' : ''}`, onClick: () => toggleSubtask(st) }, st.done && React.createElement(CheckIcon, { size: 10 })), React.createElement('span', { className: `subtask-text ${st.done ? 'done' : ''}` }, st.text), React.createElement('button', { className: 'subtask-delete', onClick: () => deleteSubtask(st.id) }, String.fromCharCode(10005)) ) ), React.createElement('form', { className: 'add-subtask-input', onSubmit: addSubtask }, React.createElement('input', { type: 'text', placeholder: 'Add a subtask...', value: newSubtask, onChange: e => setNewSubtask(e.target.value) }) ) ) ); } // ================================================================ // BUDGET PANEL (per step) // ================================================================ function BudgetPanel({ stepId, userRole }) { const [expenses, setExpenses] = useState([]); const [showModal, setShowModal] = useState(false); const [editExpense, setEditExpense] = useState(null); const [loading, setLoading] = useState(true); const toast = useToast(); useEffect(() => { loadExpenses(); }, [stepId]); async function loadExpenses() { try { const data = await api('/expenses/?step_id=' + stepId); setExpenses(data); } catch (err) { toast(err.message, 'error'); } finally { setLoading(false); } } function handleSave(exp, action) { if (action === 'create') { setExpenses(prev => [exp, ...prev]); } else { setExpenses(prev => prev.map(e => e.id === exp.id ? exp : e)); } setShowModal(false); setEditExpense(null); } async function handleDelete(expId) { if (!confirm('Delete this expense?')) return; try { await api('/expenses/' + expId, { method: 'DELETE' }); setExpenses(prev => prev.filter(e => e.id !== expId)); toast('Expense deleted'); } catch (err) { toast(err.message, 'error'); } } function handleExpenseUpdate(updatedExpense) { setExpenses(prev => prev.map(e => e.id === updatedExpense.id ? updatedExpense : e)); } const totals = useMemo(() => { return expenses.reduce((acc, e) => ({ estimated: acc.estimated + (e.estimated || 0), quoted: acc.quoted + (e.quoted || 0), paid: acc.paid + (e.paid || 0) }), { estimated: 0, quoted: 0, paid: 0 }); }, [expenses]); const statusLabels = { estimating: 'Estimating', quoted: 'Quoted', deposit: 'Deposit', paid: 'Paid' }; if (loading) { return React.createElement('div', { style: { textAlign: 'center', padding: 40, color: 'var(--text-muted)' } }, 'Loading budget...'); } return React.createElement(React.Fragment, null, React.createElement('div', { className: 'budget-summary' }, React.createElement('h4', null, 'Budget summary'), React.createElement('div', { className: 'budget-grid' }, React.createElement('div', { className: 'budget-stat' }, React.createElement('div', { className: 'label' }, 'Estimated'), React.createElement('div', { className: 'value estimated' }, formatCurrency(totals.estimated)) ), React.createElement('div', { className: 'budget-stat' }, React.createElement('div', { className: 'label' }, 'Quoted'), React.createElement('div', { className: 'value quoted' }, formatCurrency(totals.quoted)) ), React.createElement('div', { className: 'budget-stat' }, React.createElement('div', { className: 'label' }, 'Paid'), React.createElement('div', { className: 'value paid' }, formatCurrency(totals.paid)) ) ) ), React.createElement('div', { className: 'expense-list' }, expenses.map(exp => React.createElement('div', { key: exp.id, className: 'expense-row' }, React.createElement('div', { className: 'expense-row-header' }, React.createElement('span', { className: 'expense-name', onClick: () => { setEditExpense(exp); setShowModal(true); } }, exp.name), React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } }, React.createElement('span', { className: 'expense-amount' }, formatCurrency(exp.quoted || exp.estimated)), React.createElement('button', { className: 'btn-ghost', style: { padding: 4, fontSize: 12, color: 'var(--text-muted)' }, onClick: () => handleDelete(exp.id) }, String.fromCharCode(128465)) ) ), React.createElement('div', { className: 'expense-row-meta' }, React.createElement('span', { className: 'expense-tag' }, exp.category), React.createElement('span', { className: `expense-tag status-${exp.status}` }, statusLabels[exp.status]), exp.vendor && React.createElement('span', { className: 'expense-tag' }, exp.vendor) ), React.createElement(ExpenseImageStrip, { expense: exp, onUpdate: handleExpenseUpdate }) ) ), React.createElement('button', { className: 'add-expense-btn', onClick: () => { setEditExpense(null); setShowModal(true); } }, '+ Add expense' ) ), showModal && React.createElement(ExpenseModal, { expense: editExpense, stepId: stepId, userRole: userRole, onSave: handleSave, onClose: () => { setShowModal(false); setEditExpense(null); } }) ); } // ================================================================ // STEP DETAIL VIEW // ================================================================ function StepDetailView({ step, userRole, onBack, onUpdate, onDelete }) { const [activeTab, setActiveTab] = useState('tasks'); const [tasks, setTasks] = useState([]); const [newTaskText, setNewTaskText] = useState(''); const [loading, setLoading] = useState(true); const [showEditModal, setShowEditModal] = useState(false); const toast = useToast(); useEffect(() => { loadTasks(); }, [step.id]); async function loadTasks() { try { const data = await api('/tasks/?step_id=' + step.id); setTasks(data); } catch (err) { toast(err.message, 'error'); } finally { setLoading(false); } } async function addTask(e) { e.preventDefault(); if (!newTaskText.trim()) return; try { const task = await api('/tasks/', { method: 'POST', body: { step_id: step.id, text: newTaskText.trim() } }); setTasks(prev => [...prev, task]); setNewTaskText(''); } catch (err) { toast(err.message, 'error'); } } function handleTaskUpdate(updatedTask) { setTasks(prev => prev.map(t => t.id === updatedTask.id ? updatedTask : t)); } function handleTaskDelete(taskId) { setTasks(prev => prev.filter(t => t.id !== taskId)); } function handleStepUpdate(updatedStep) { onUpdate(updatedStep); setShowEditModal(false); } async function handleStepDelete() { if (!confirm('Delete this step and all its tasks/expenses?')) return; try { await api('/steps/' + step.id, { method: 'DELETE' }); onDelete(step.id); } catch (err) { toast(err.message, 'error'); } } const doneCount = tasks.filter(t => t.done).length; const totalCount = tasks.length; const pct = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0; return React.createElement('div', { className: 'step-detail' }, React.createElement('div', { className: 'step-detail-header' }, React.createElement('button', { className: 'back-btn', onClick: onBack }, React.createElement(ChevronIcon, { direction: 'left' })), React.createElement('div', { className: 'step-detail-title' }, React.createElement('h2', null, step.emoji, ' ', step.title), step.subtitle && React.createElement('div', { className: 'sub' }, step.subtitle) ), React.createElement('div', { className: 'step-actions' }, React.createElement('button', { className: 'btn-icon', onClick: () => setShowEditModal(true), title: 'Edit' }, String.fromCharCode(9998)), React.createElement('button', { className: 'btn-icon', onClick: handleStepDelete, title: 'Delete', style: { color: 'var(--danger)' } }, String.fromCharCode(128465)) ) ), totalCount > 0 && React.createElement('div', { style: { marginBottom: 16 } }, React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', marginBottom: 4, fontSize: 12, color: 'var(--text-muted)' } }, React.createElement('span', null, `${doneCount}/${totalCount} tasks`), React.createElement('span', null, `${pct}%`) ), React.createElement('div', { className: 'progress-bar' }, React.createElement('div', { className: 'progress-fill', style: { width: pct + '%', backgroundColor: step.color || 'var(--gold)' } }) ) ), step.deadline && React.createElement('div', { className: 'step-deadline', style: { marginBottom: 16 } }, 'Deadline: ', new Date(step.deadline).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }) ), React.createElement('div', { className: 'tab-bar' }, React.createElement('button', { className: `tab-item ${activeTab === 'tasks' ? 'active' : ''}`, onClick: () => setActiveTab('tasks') }, 'Tasks'), React.createElement('button', { className: `tab-item ${activeTab === 'budget' ? 'active' : ''}`, onClick: () => setActiveTab('budget') }, 'Budget') ), activeTab === 'tasks' && React.createElement('div', { className: 'tasks-section' }, loading ? React.createElement('div', { style: { textAlign: 'center', padding: 40, color: 'var(--text-muted)' } }, 'Loading...') : React.createElement(React.Fragment, null, tasks.map(task => React.createElement(TaskRow, { key: task.id, task: task, onUpdate: handleTaskUpdate, onDelete: handleTaskDelete }) ), tasks.length === 0 && React.createElement('div', { style: { textAlign: 'center', padding: 30, color: 'var(--text-muted)', fontSize: 14 } }, 'No tasks yet. Add your first task below.' ), React.createElement('form', { className: 'add-task-input', onSubmit: addTask }, React.createElement('input', { type: 'text', placeholder: 'Add a task...', value: newTaskText, onChange: e => setNewTaskText(e.target.value) }), React.createElement('button', { type: 'submit', disabled: !newTaskText.trim() }, 'Add') ) ) ), activeTab === 'budget' && React.createElement(BudgetPanel, { stepId: step.id, userRole: userRole }), showEditModal && React.createElement(StepModal, { step: step, onSave: (updated) => handleStepUpdate(updated), onClose: () => setShowEditModal(false) }) ); } // ================================================================ // GLOBAL BUDGET VIEW // ================================================================ function GlobalBudgetView({ userRole }) { const [expenses, setExpenses] = useState([]); const [loading, setLoading] = useState(true); const toast = useToast(); useEffect(() => { loadAllExpenses(); }, []); async function loadAllExpenses() { try { const data = await api('/expenses/'); setExpenses(data); } catch (err) { toast(err.message, 'error'); } finally { setLoading(false); } } const totals = useMemo(() => { return expenses.reduce((acc, e) => ({ estimated: acc.estimated + (e.estimated || 0), quoted: acc.quoted + (e.quoted || 0), paid: acc.paid + (e.paid || 0) }), { estimated: 0, quoted: 0, paid: 0 }); }, [expenses]); const byCategory = useMemo(() => { const map = {}; expenses.forEach(e => { if (!map[e.category]) map[e.category] = { estimated: 0, quoted: 0, paid: 0 }; map[e.category].estimated += e.estimated || 0; map[e.category].quoted += e.quoted || 0; map[e.category].paid += e.paid || 0; }); return Object.entries(map).sort((a, b) => (b[1].quoted || b[1].estimated) - (a[1].quoted || a[1].estimated)); }, [expenses]); const byPayer = useMemo(() => { const map = {}; expenses.forEach(e => { if (!map[e.payer]) map[e.payer] = 0; map[e.payer] += e.quoted || e.estimated || 0; }); return Object.entries(map).sort((a, b) => b[1] - a[1]); }, [expenses]); const payerLabels = { user1: 'Partner 1', user2: 'Partner 2', 'user1-family': 'Partner 1 family', 'user2-family': 'Partner 2 family', shared: 'Shared' }; if (loading) { return React.createElement('div', { style: { textAlign: 'center', padding: 60, color: 'var(--text-muted)' } }, 'Loading global budget...'); } return React.createElement('div', { className: 'global-budget' }, React.createElement('div', { className: 'global-budget-header' }, React.createElement('h2', null, 'Global Budget') ), React.createElement('div', { className: 'budget-total-card' }, React.createElement('div', { className: 'total-label' }, 'Total Estimated Budget'), React.createElement('div', { className: 'total-value' }, formatCurrency(totals.quoted || totals.estimated)), React.createElement('div', { className: 'total-sub' }, React.createElement('div', { className: 'stat' }, React.createElement('div', { className: 'val', style: { color: 'var(--text-secondary)' } }, formatCurrency(totals.estimated)), React.createElement('div', { className: 'lbl' }, 'Estimated') ), React.createElement('div', { className: 'stat' }, React.createElement('div', { className: 'val', style: { color: 'var(--warning)' } }, formatCurrency(totals.quoted)), React.createElement('div', { className: 'lbl' }, 'Quoted') ), React.createElement('div', { className: 'stat' }, React.createElement('div', { className: 'val', style: { color: 'var(--success)' } }, formatCurrency(totals.paid)), React.createElement('div', { className: 'lbl' }, 'Paid') ) ) ), expenses.length === 0 && React.createElement('div', { className: 'empty-state' }, React.createElement('div', { className: 'icon' }, String.fromCodePoint(0x1F4B0)), React.createElement('h3', null, 'No expenses'), React.createElement('p', null, 'Add expenses to your steps to see the global budget here.') ), byCategory.length > 0 && React.createElement(React.Fragment, null, React.createElement('h4', { style: { fontFamily: "'Playfair Display', serif", color: 'var(--text-secondary)', fontSize: 15, marginBottom: 12 } }, 'By category'), React.createElement('div', { className: 'budget-category-list' }, byCategory.map(([cat, vals]) => React.createElement('div', { key: cat, className: 'budget-category-row' }, React.createElement('span', { className: 'budget-category-name' }, cat), React.createElement('span', { className: 'budget-category-amount' }, formatCurrency(vals.quoted || vals.estimated)) ) ) ) ), byPayer.length > 0 && React.createElement('div', { className: 'budget-payer-breakdown' }, React.createElement('h4', null, 'By payer'), React.createElement('div', { className: 'budget-category-list' }, byPayer.map(([payer, amount]) => React.createElement('div', { key: payer, className: 'budget-category-row' }, React.createElement('span', { className: 'budget-category-name' }, payerLabels[payer] || payer), React.createElement('span', { className: 'budget-category-amount' }, formatCurrency(amount)) ) ) ) ) ); } // ================================================================ // SETTINGS VIEW // ================================================================ function SettingsView({ user, couple, userRole, onLogout, onUserUpdate }) { const [name, setName] = useState(user.name); const [color, setColor] = useState(user.color || '#D4AF37'); const [saving, setSaving] = useState(false); const [inviteUrl, setInviteUrl] = useState(''); const [copied, setCopied] = useState(false); const toast = useToast(); const fileRef = useRef(null); useEffect(() => { if (couple && couple.invite_token) { setInviteUrl('https://wedding.boubacarbarry.fr/?join=' + couple.invite_token); } }, [couple]); async function saveProfile() { setSaving(true); try { const updated = await api('/users/profile', { method: 'PUT', body: { name, color } }); onUserUpdate(updated); toast('Profile updated'); } catch (err) { toast(err.message, 'error'); } finally { setSaving(false); } } async function uploadAvatar(e) { const file = e.target.files[0]; if (!file) return; const formData = new FormData(); formData.append('file', file); try { const updated = await api('/users/avatar', { method: 'POST', body: formData }); onUserUpdate(updated); toast('Avatar updated'); } catch (err) { toast(err.message, 'error'); } } function copyInvite() { navigator.clipboard.writeText(inviteUrl).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); } return React.createElement('div', { className: 'settings-panel' }, React.createElement('h2', null, 'Settings'), React.createElement('div', { className: 'settings-section' }, React.createElement('h4', null, 'Profile'), React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 } }, React.createElement('div', { className: 'avatar-upload', style: { width: 64, height: 64, margin: 0 }, onClick: () => fileRef.current?.click() }, React.createElement('div', { className: 'avatar-circle', style: { borderColor: color } }, user.photo_path ? React.createElement('img', { src: '/' + user.photo_path, alt: user.name }) : user.name.charAt(0).toUpperCase() ), React.createElement('input', { ref: fileRef, type: 'file', accept: 'image/*', style: { display: 'none' }, onChange: uploadAvatar }) ), React.createElement('div', null, React.createElement('div', { style: { fontWeight: 600 } }, user.name), React.createElement('div', { style: { fontSize: 13, color: 'var(--text-muted)' } }, user.email) ) ), React.createElement('div', { className: 'form-group' }, React.createElement('label', null, 'Name'), React.createElement('input', { className: 'form-input', value: name, onChange: e => setName(e.target.value) }) ), React.createElement('label', { style: { fontSize: 13, color: 'var(--text-secondary)' } }, 'Colour'), React.createElement('div', { className: 'color-picker-row', style: { justifyContent: 'flex-start', margin: '8px 0 16px' } }, COLOR_PRESETS.map(c => React.createElement('div', { key: c, className: `color-swatch ${color === c ? 'active' : ''}`, style: { backgroundColor: c, width: 28, height: 28 }, onClick: () => setColor(c) }) ) ), React.createElement('button', { className: 'btn btn-primary btn-small', onClick: saveProfile, disabled: saving }, saving ? 'Saving...' : 'Save') ), React.createElement('div', { className: 'settings-section' }, React.createElement('h4', null, 'Couple'), couple && couple.partner ? React.createElement('div', { className: 'settings-row' }, React.createElement('span', { className: 'label' }, 'Partner'), React.createElement('span', { className: 'value' }, couple.partner.name) ) : React.createElement(React.Fragment, null, React.createElement('p', { style: { fontSize: 13, color: 'var(--text-muted)', marginBottom: 12 } }, 'Your partner hasn\'t joined yet. Share this link:'), React.createElement('div', { className: 'invite-link-box' }, React.createElement('input', { type: 'text', readOnly: true, value: inviteUrl }), React.createElement('button', { onClick: copyInvite }, copied ? 'Copied!' : 'Copy') ) ), React.createElement('div', { className: 'settings-row' }, React.createElement('span', { className: 'label' }, 'Your role'), React.createElement('span', { className: 'value' }, userRole === 'user1' ? 'Partner 1' : 'Partner 2') ) ), React.createElement('button', { className: 'btn btn-danger', style: { width: '100%', marginTop: 8 }, onClick: onLogout }, 'Log out') ); } // ================================================================ // MAIN APP // ================================================================ function App() { const [authState, setAuthState] = useState('loading'); // 'loading' | 'unauthenticated' | 'onboarding' | 'authenticated' const [user, setUser] = useState(null); const [couple, setCouple] = useState(null); const [userRole, setUserRole] = useState(null); const [joinToken, setJoinToken] = useState(null); // App state const [steps, setSteps] = useState([]); const [activeStep, setActiveStep] = useState(null); const [activeView, setActiveView] = useState('steps'); // 'steps' | 'budget' | 'settings' const [showStepModal, setShowStepModal] = useState(false); const [stepsLoading, setStepsLoading] = useState(false); // Check for join token in URL useEffect(() => { const params = new URLSearchParams(window.location.search); const token = params.get('join'); if (token) { setJoinToken(token); } }, []); // Check auth on mount useEffect(() => { checkAuth(); }, []); async function checkAuth() { try { const data = await api('/auth/me'); setUser(data.user); setCouple(data.couple); setUserRole(data.user_role); // Check if onboarding needed (new user with no steps) // For simplicity, go straight to authenticated setAuthState('authenticated'); loadSteps(); } catch (err) { setAuthState('unauthenticated'); } } async function loadSteps() { setStepsLoading(true); try { const data = await api('/steps/'); setSteps(data); } catch (err) { // may fail if not yet in couple } finally { setStepsLoading(false); } } function handleLogin(result) { setUser(result.user); setCouple(result.couple); setUserRole(result.user_role); // Clean URL if (joinToken) { window.history.replaceState({}, '', '/'); setJoinToken(null); } // If new registration, show onboarding if (!result.couple?.partner && result.user_role === 'user1') { setAuthState('onboarding'); } else { setAuthState('authenticated'); loadSteps(); } } function handleOnboardingComplete() { setAuthState('authenticated'); loadSteps(); } async function handleLogout() { try { await api('/auth/logout', { method: 'POST' }); } catch (err) { /* ignore */ } setUser(null); setCouple(null); setUserRole(null); setSteps([]); setActiveStep(null); setAuthState('unauthenticated'); } function handleStepSave(step, action) { if (action === 'create') { setSteps(prev => [...prev, { ...step, task_count: 0, done_count: 0 }]); } else { setSteps(prev => prev.map(s => s.id === step.id ? { ...s, ...step } : s)); if (activeStep && activeStep.id === step.id) { setActiveStep(prev => ({ ...prev, ...step })); } } setShowStepModal(false); } function handleStepDelete(stepId) { setSteps(prev => prev.filter(s => s.id !== stepId)); setActiveStep(null); } function handleStepUpdate(updatedStep) { setSteps(prev => prev.map(s => s.id === updatedStep.id ? { ...s, ...updatedStep } : s)); setActiveStep(prev => prev ? { ...prev, ...updatedStep } : null); } function handleUserUpdate(updatedUser) { setUser(prev => ({ ...prev, ...updatedUser })); } // ---- Render ---- if (authState === 'loading') { return React.createElement('div', { className: 'app-loader' }, React.createElement('div', { className: 'spinner' }), React.createElement('div', { className: 'text' }, 'Loading...') ); } if (authState === 'unauthenticated') { return React.createElement(LoginScreen, { onLogin: handleLogin, joinToken: joinToken }); } if (authState === 'onboarding') { return React.createElement(OnboardingScreen, { user: user, couple: couple, onComplete: handleOnboardingComplete }); } // ---- Authenticated App ---- // Step detail view if (activeStep && activeView === 'steps') { return React.createElement(React.Fragment, null, React.createElement(GeoBg), React.createElement('div', { className: 'app-container' }, React.createElement('div', { className: 'app-header' }, React.createElement('div', { className: 'header-left' }, React.createElement('span', { className: 'header-logo' }, "Our Journey \u0645\u0639\u0627\u064B") ), React.createElement('div', { className: 'header-right' }, couple && couple.partner && React.createElement('div', { className: 'header-avatar', style: { borderColor: couple.partner.color || '#D4AF37' } }, couple.partner.photo_path ? React.createElement('img', { src: '/' + couple.partner.photo_path, alt: '' }) : (couple.partner.name || '?').charAt(0).toUpperCase() ), React.createElement('div', { className: 'header-avatar', style: { borderColor: user.color || '#D4AF37' }, onClick: () => { setActiveStep(null); setActiveView('settings'); } }, user.photo_path ? React.createElement('img', { src: '/' + user.photo_path, alt: '' }) : (user.name || '?').charAt(0).toUpperCase() ) ) ), React.createElement('div', { className: 'app-main' }, React.createElement(StepDetailView, { step: activeStep, userRole: userRole, onBack: () => setActiveStep(null), onUpdate: handleStepUpdate, onDelete: handleStepDelete }) ), React.createElement('div', { className: 'bottom-nav' }, React.createElement('button', { className: `bottom-nav-item active`, onClick: () => setActiveStep(null) }, React.createElement('span', { className: 'nav-icon' }, String.fromCodePoint(0x1F4CB)), React.createElement('span', null, 'Steps') ), React.createElement('button', { className: 'bottom-nav-item', onClick: () => { setActiveStep(null); setActiveView('budget'); } }, React.createElement('span', { className: 'nav-icon' }, String.fromCodePoint(0x1F4B0)), React.createElement('span', null, 'Budget') ), React.createElement('button', { className: 'bottom-nav-item', onClick: () => { setActiveStep(null); setActiveView('settings'); } }, React.createElement('span', { className: 'nav-icon' }, String.fromCodePoint(0x2699)), React.createElement('span', null, 'Settings') ) ) ) ); } // Main views return React.createElement(React.Fragment, null, React.createElement(GeoBg), React.createElement('div', { className: 'app-container' }, React.createElement('div', { className: 'app-header' }, React.createElement('div', { className: 'header-left' }, React.createElement('span', { className: 'header-logo' }, "Our Journey \u0645\u0639\u0627\u064B") ), React.createElement('div', { className: 'header-right' }, couple && couple.partner && React.createElement('div', { className: 'header-avatar', style: { borderColor: couple.partner.color || '#D4AF37' } }, couple.partner.photo_path ? React.createElement('img', { src: '/' + couple.partner.photo_path, alt: '' }) : (couple.partner.name || '?').charAt(0).toUpperCase() ), React.createElement('div', { className: 'header-avatar', style: { borderColor: user.color || '#D4AF37' }, onClick: () => setActiveView('settings') }, user.photo_path ? React.createElement('img', { src: '/' + user.photo_path, alt: '' }) : (user.name || '?').charAt(0).toUpperCase() ) ) ), React.createElement('div', { className: 'app-main' }, // Steps list view activeView === 'steps' && React.createElement('div', { className: 'steps-list' }, stepsLoading ? React.createElement('div', { style: { textAlign: 'center', padding: 40, color: 'var(--text-muted)' } }, 'Loading steps...') : React.createElement(React.Fragment, null, steps.length === 0 && React.createElement('div', { className: 'empty-state' }, React.createElement('div', { className: 'icon' }, String.fromCodePoint(0x1F48D)), React.createElement('h3', null, 'Welcome!'), React.createElement('p', null, 'Start by creating your first planning step. Each step contains tasks and a budget.') ), steps.map(s => React.createElement('div', { key: s.id, className: 'step-card', onClick: () => setActiveStep(s), style: { '--step-color': s.color || 'var(--gold)' } }, React.createElement('div', null, React.createElement('div', { style: { position: 'absolute', left: 0, top: 0, bottom: 0, width: 4, background: s.color || 'var(--gold)', borderRadius: '4px 0 0 4px' } }) ), React.createElement('div', { className: 'step-card-header' }, React.createElement('span', { className: 'step-emoji' }, s.emoji || String.fromCodePoint(0x2728)), React.createElement('div', { className: 'step-info' }, React.createElement('div', { className: 'step-title' }, s.title), s.subtitle && React.createElement('div', { className: 'step-subtitle' }, s.subtitle) ) ), (s.task_count > 0) && React.createElement('div', { className: 'step-meta' }, React.createElement('div', { className: 'step-progress' }, React.createElement('div', { className: 'progress-bar' }, React.createElement('div', { className: 'progress-fill', style: { width: (s.task_count > 0 ? Math.round((s.done_count / s.task_count) * 100) : 0) + '%', backgroundColor: s.color || 'var(--gold)' } }) ) ), React.createElement('span', { className: 'step-count' }, `${s.done_count}/${s.task_count}`) ), s.deadline && React.createElement('div', { className: 'step-deadline' }, new Date(s.deadline).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) ) ) ), React.createElement('button', { className: 'add-step-btn', onClick: () => setShowStepModal(true) }, '+ Add a step' ) ) ), // Global budget view activeView === 'budget' && React.createElement(GlobalBudgetView, { userRole: userRole }), // Settings view activeView === 'settings' && React.createElement(SettingsView, { user: user, couple: couple, userRole: userRole, onLogout: handleLogout, onUserUpdate: handleUserUpdate }) ), // Bottom nav React.createElement('div', { className: 'bottom-nav' }, React.createElement('button', { className: `bottom-nav-item ${activeView === 'steps' ? 'active' : ''}`, onClick: () => setActiveView('steps') }, React.createElement('span', { className: 'nav-icon' }, String.fromCodePoint(0x1F4CB)), React.createElement('span', null, 'Steps') ), React.createElement('button', { className: `bottom-nav-item ${activeView === 'budget' ? 'active' : ''}`, onClick: () => setActiveView('budget') }, React.createElement('span', { className: 'nav-icon' }, String.fromCodePoint(0x1F4B0)), React.createElement('span', null, 'Budget') ), React.createElement('button', { className: `bottom-nav-item ${activeView === 'settings' ? 'active' : ''}`, onClick: () => setActiveView('settings') }, React.createElement('span', { className: 'nav-icon' }, String.fromCodePoint(0x2699)), React.createElement('span', null, 'Settings') ) ) ), // Step creation modal showStepModal && React.createElement(StepModal, { step: null, onSave: handleStepSave, onClose: () => setShowStepModal(false) }) ); } // ================================================================ // RENDER // ================================================================ const rootEl = document.getElementById('root'); const root = ReactDOM.createRoot(rootEl); root.render( React.createElement(ToastProvider, null, React.createElement(App) ) );