fix: slide image upload - clear image_url when image_id set, fix slides.image_url column type to TEXT, add bed amenities (Queen, 2 Queen)

This commit is contained in:
2026-06-29 21:38:27 -05:00
parent 91fce014fd
commit 2dc949553e
13 changed files with 1169 additions and 58 deletions
+196 -28
View File
@@ -35,6 +35,7 @@ const NAV = [
{ id: 'places', label: 'Places', icon: '🗺️' },
{ id: 'reviews', label: 'Reviews', icon: '⭐' },
{ id: 'users', label: 'Users', icon: '👥' },
{ id: 'terms', label: 'Terms', icon: '📜' },
{ id: 'weather', label: 'Weather', icon: '🌤️' },
];
@@ -483,19 +484,31 @@ function SlidesSection({ slides, setSlides, images, onToast, onRefreshImages }:
const saveSlide = async (slide: Slide) => {
try {
const url = slide.id ? `/api/slides/${slide.id}` : '/api/slides';
// If image_id is set, clear image_url (image is in images table)
const payload = {
...slide,
image_url: slide.image_id ? null : slide.image_url
};
const url = payload.id ? `/api/slides/${payload.id}` : '/api/slides';
const res = await fetch(url, {
method: slide.id ? 'PUT' : 'POST',
method: payload.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(slide),
body: JSON.stringify(payload),
});
if (res.ok) {
const data = await res.json();
setEditing(null);
setSlides((s: any) => slide.id ? s.map((x: Slide) => x.id === slide.id ? data.data : x) : [...s, data.data]);
setSlides((s: any) => payload.id ? s.map((x: Slide) => x.id === payload.id ? data.data : x) : [...s, data.data]);
onToast('Slide saved!', 'success');
} else {
const err = await res.json();
console.error('Slide save error:', err);
onToast(err.error || 'Error saving slide', 'error');
}
} catch { onToast('Error saving slide', 'error'); }
} catch (err) {
console.error('Slide save exception:', err);
onToast('Error saving slide', 'error');
}
};
const delSlide = async (id: number) => {
@@ -537,7 +550,6 @@ function SlideEditor({ slide, setSlide, images, onRefreshImages, onSave, onCance
const [showPicker, setShowPicker] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const s = { ...slide };
const handleUpload = async (files: FileList) => {
setUploading(true);
@@ -551,17 +563,23 @@ function SlideEditor({ slide, setSlide, images, onRefreshImages, onSave, onCance
});
if (res.ok) {
const data = await res.json();
onRefreshImages?.();
setSlide({ ...s, image_id: data.data.id, image_url: data.data.data });
await onRefreshImages?.();
setSlide((prev: any) => ({ ...prev, image_id: data.data.id, image_url: data.data.data }));
} else {
const err = await res.json();
console.error('Image upload error:', err);
alert('Upload failed: ' + (err.error || 'Unknown error'));
}
}
} catch (err) {
console.error('Upload failed:', err);
alert('Upload failed');
} finally {
setUploading(false);
}
};
const s = slide;
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9998 }}>
<div style={{ background: '#FFFFFF', borderRadius: 16, padding: '24px 28px', maxWidth: 500, width: '90%', maxHeight: '90vh', overflowY: 'auto' }}>
@@ -1826,7 +1844,7 @@ function UsersSection({ onToast }: { onToast: (msg: string, type: string) => voi
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<any | null>(null);
const [newUser, setNewUser] = useState({ username: '', password: '', role: 'user' });
const [newUser, setNewUser] = useState({ username: '', password: '', role: 'user', first_name: '', last_name: '', email: '', phone: '', country: '', preferred_language: 'es' });
const [showAdd, setShowAdd] = useState(false);
useEffect(() => {
@@ -1860,7 +1878,7 @@ function UsersSection({ onToast }: { onToast: (msg: string, type: string) => voi
});
if (res.ok) {
onToast('User created!', 'success');
setNewUser({ username: '', password: '', role: 'user' });
setNewUser({ username: '', password: '', role: 'user', first_name: '', last_name: '', email: '', phone: '', country: '', preferred_language: 'es' });
setShowAdd(false);
loadUsers();
} else {
@@ -1893,6 +1911,7 @@ function UsersSection({ onToast }: { onToast: (msg: string, type: string) => voi
};
const deleteUser = async (id: number) => {
if (!confirm('Delete this user?')) return;
try {
const res = await fetch(`/api/admin/users?id=${id}`, { method: 'DELETE' });
if (res.ok) {
@@ -1910,21 +1929,19 @@ function UsersSection({ onToast }: { onToast: (msg: string, type: string) => voi
return (
<div>
<SecHead title="👥 Users" count={users.length}
action={<Btn onClick={() => setShowAdd(!showAdd)} size="sm">+ Add User</Btn>} />
<SecHead title="👥 Users" count={users.length} action={<Btn onClick={() => setShowAdd(!showAdd)} size="sm">+ Add User</Btn>} />
{showAdd && (
<div style={{ background: C.card, borderRadius: 12, padding: 16, marginBottom: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
<I label="Username" value={newUser.username} onChange={(v: string) => setNewUser({ ...newUser, username: v })} />
<I label="Password" type="password" value={newUser.password} onChange={(v: string) => setNewUser({ ...newUser, password: v })} />
<div>
<label style={{ fontSize: 13, fontWeight: 600, color: C.text, display: 'block', marginBottom: 6 }}>Role</label>
<select value={newUser.role} onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
style={{ width: '100%', padding: '10px 12px', borderRadius: 8, border: `1px solid ${C.border}`, fontSize: 14 }}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<S label="Role" value={newUser.role} onChange={(v: string) => setNewUser({ ...newUser, role: v })} options={[{ value: 'user', label: 'User' }, { value: 'admin', label: 'Admin' }]} />
<I label="First Name" value={newUser.first_name} onChange={(v: string) => setNewUser({ ...newUser, first_name: v })} />
<I label="Last Name" value={newUser.last_name} onChange={(v: string) => setNewUser({ ...newUser, last_name: v })} />
<I label="Email" type="email" value={newUser.email} onChange={(v: string) => setNewUser({ ...newUser, email: v })} />
<I label="Phone" value={newUser.phone} onChange={(v: string) => setNewUser({ ...newUser, phone: v })} />
<I label="Country" value={newUser.country} onChange={(v: string) => setNewUser({ ...newUser, country: v })} />
<S label="Language" value={newUser.preferred_language} onChange={(v: string) => setNewUser({ ...newUser, preferred_language: v })} options={[{ value: 'es', label: '🇪🇨 Español' }, { value: 'en', label: '🏴󠁧󠁢󠁥󠁮󠁧󠁿 English' }]} />
</div>
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<Btn onClick={createUser} size="sm">Create User</Btn>
@@ -1936,26 +1953,45 @@ function UsersSection({ onToast }: { onToast: (msg: string, type: string) => voi
{users.map((u: any) => (
<div key={u.id} style={{ background: C.card, borderRadius: 12, padding: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 600, color: C.text }}>{u.username} <span style={{ fontSize: 12, background: u.role === 'admin' ? '#dc2626' : '#059669', color: '#fff', padding: '2px 8px', borderRadius: 4, marginLeft: 8 }}>{u.role}</span></div>
<div style={{ fontSize: 12, color: C.textLight }}>{u.first_name || ''} {u.last_name || ''} {u.comments_disabled ? '• Comments disabled' : ''}</div>
<div style={{ fontWeight: 600, color: C.text }}>
{u.username} <span style={{ fontSize: 12, background: u.role === 'admin' ? '#dc2626' : '#059669', color: '#fff', padding: '2px 8px', borderRadius: 4, marginLeft: 8 }}>{u.role}</span>
{u.preferred_language && <span style={{ fontSize: 11, background: '#6366f1', color: '#fff', padding: '2px 6px', borderRadius: 4, marginLeft: 8 }}>{u.preferred_language === 'es' ? '🇪🇨 ES' : 'EN'}</span>}
</div>
<div style={{ fontSize: 12, color: C.textLight }}>
{u.first_name || ''} {u.last_name || ''} {u.email ? `${u.email}` : ''} {u.country ? `${u.country}` : ''}
{u.terms_accepted_at ? `• Terms: ${new Date(u.terms_accepted_at).toLocaleDateString()}` : '• Terms not accepted'}
{u.comments_disabled ? '• Comments disabled' : ''}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Btn size="sm" variant="secondary" onClick={() => setEditing(u)}>Edit</Btn>
{u.role !== 'admin' && <Btn size="sm" variant="danger" onClick={() => deleteUser(u.id)}>Delete</Btn>}
</div>
</div>
))}
</div>
{editing && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9998 }}>
<div style={{ background: '#fff', borderRadius: 16, padding: 24, maxWidth: 400, width: '90%' }}>
<div style={{ background: '#fff', borderRadius: 16, padding: 24, maxWidth: 500, width: '90%', maxHeight: '90vh', overflow: 'auto' }}>
<h3 style={{ margin: '0 0 20px', fontSize: 18, fontWeight: 700 }}>Edit User</h3>
<div style={{ display: 'grid', gap: 12 }}>
<I label="First Name" value={editing.first_name || ''} onChange={(v: string) => setEditing({ ...editing, first_name: v })} />
<I label="Last Name" value={editing.last_name || ''} onChange={(v: string) => setEditing({ ...editing, last_name: v })} />
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={editing.comments_disabled || false} onChange={(e) => setEditing({ ...editing, comments_disabled: e.target.checked })} />
<label style={{ fontSize: 13, color: C.text }}>Disable comments</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<I label="First Name" value={editing.first_name || ''} onChange={(v: string) => setEditing({ ...editing, first_name: v })} />
<I label="Last Name" value={editing.last_name || ''} onChange={(v: string) => setEditing({ ...editing, last_name: v })} />
</div>
<I label="Email" type="email" value={editing.email || ''} onChange={(v: string) => setEditing({ ...editing, email: v })} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<I label="Phone" value={editing.phone || ''} onChange={(v: string) => setEditing({ ...editing, phone: v })} />
<I label="Country" value={editing.country || ''} onChange={(v: string) => setEditing({ ...editing, country: v })} />
</div>
<S label="Language" value={editing.preferred_language || 'es'} onChange={(v: string) => setEditing({ ...editing, preferred_language: v })} options={[{ value: 'es', label: '🇪🇨 Español' }, { value: 'en', label: '🏴󠁧󠁢󠁥󠁮󠁧󠁿 English' }]} />
<I label="New Password (leave blank to keep)" type="password" value={editing.password || ''} onChange={(v: string) => setEditing({ ...editing, password: v })} />
<Tgl label="Disable comments" checked={editing.comments_disabled || false} onChange={(v: boolean) => setEditing({ ...editing, comments_disabled: v })} />
{editing.terms_accepted_at && (
<div style={{ fontSize: 12, color: C.textLight }}>
Terms accepted: {new Date(editing.terms_accepted_at).toLocaleString()}
</div>
)}
</div>
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
<Btn onClick={() => updateUser(editing)} size="sm">Save</Btn>
@@ -1968,6 +2004,137 @@ function UsersSection({ onToast }: { onToast: (msg: string, type: string) => voi
);
}
/* ─── Terms Section ─── */
function TermsSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
const [terms, setTerms] = useState<{ es: any; en: any }>({ es: null, en: null });
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<any | null>(null);
useEffect(() => {
loadTerms();
}, []);
const loadTerms = async () => {
try {
const [esRes, enRes] = await Promise.all([
fetch('/api/terms?language=es'),
fetch('/api/terms?language=en'),
]);
const esData = await esRes.json();
const enData = await enRes.json();
setTerms({
es: esData.data || null,
en: enData.data || null,
});
} catch (err) {
console.error('Failed to load terms:', err);
} finally {
setLoading(false);
}
};
const saveTerm = async (term: any) => {
try {
const res = await fetch('/api/admin/terms', {
method: term.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(term),
});
if (res.ok) {
onToast('Terms saved!', 'success');
setEditing(null);
loadTerms();
} else {
const err = await res.json();
onToast(err.error || 'Failed to save terms', 'error');
}
} catch {
onToast('Error saving terms', 'error');
}
};
if (loading) {
return <div style={{ textAlign: 'center', padding: '40px' }}>Loading...</div>;
}
return (
<div>
<SecHead title="📜 Terms of Service" count={0} />
<p style={{ fontSize: 13, color: C.textLight, marginBottom: 16 }}>
Manage terms of service and disclaimers shown to users. One version per language.
</p>
<div style={{ display: 'grid', gap: 16 }}>
{/* Spanish Terms */}
<div style={{ background: C.card, borderRadius: 12, padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 style={{ margin: 0, fontSize: 16 }}>🇪🇨 Español</h3>
{terms.es && (
<span style={{ fontSize: 12, color: C.textLight }}>
Version: {terms.es.version}
</span>
)}
</div>
{terms.es ? (
<div>
<div style={{ fontWeight: 600, marginBottom: 8 }}>{terms.es.title}</div>
<div style={{ fontSize: 13, color: C.textLight, maxHeight: 120, overflow: 'auto', whiteSpace: 'pre-wrap' }}>
{terms.es.content?.substring(0, 300)}...
</div>
<Btn size="sm" variant="secondary" onClick={() => setEditing({ ...terms.es })} style={{ marginTop: 12 }}>Edit</Btn>
</div>
) : (
<Btn size="sm" onClick={() => setEditing({ language: 'es', title: '', content: '', version: '1.0' })}>Create Spanish Terms</Btn>
)}
</div>
{/* English Terms */}
<div style={{ background: C.card, borderRadius: 12, padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 style={{ margin: 0, fontSize: 16 }}>🏴󠁧󠁢󠁥󠁮󠁧󠁿 English</h3>
{terms.en && (
<span style={{ fontSize: 12, color: C.textLight }}>
Version: {terms.en.version}
</span>
)}
</div>
{terms.en ? (
<div>
<div style={{ fontWeight: 600, marginBottom: 8 }}>{terms.en.title}</div>
<div style={{ fontSize: 13, color: C.textLight, maxHeight: 120, overflow: 'auto', whiteSpace: 'pre-wrap' }}>
{terms.en.content?.substring(0, 300)}...
</div>
<Btn size="sm" variant="secondary" onClick={() => setEditing({ ...terms.en })} style={{ marginTop: 12 }}>Edit</Btn>
</div>
) : (
<Btn size="sm" onClick={() => setEditing({ language: 'en', title: '', content: '', version: '1.0' })}>Create English Terms</Btn>
)}
</div>
</div>
{/* Edit Modal */}
{editing && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9998 }}>
<div style={{ background: '#fff', borderRadius: 16, padding: 24, maxWidth: 600, width: '90%', maxHeight: '90vh', overflow: 'auto' }}>
<h3 style={{ margin: '0 0 20px', fontSize: 18, fontWeight: 700 }}>
{editing.language === 'es' ? '🇪🇨 ' : '🏴󠁧󠁢󠁥󠁮󠁧󠁿 '}Edit Terms ({editing.language.toUpperCase()})
</h3>
<div style={{ display: 'grid', gap: 12 }}>
<I label="Title" value={editing.title || ''} onChange={(v: string) => setEditing({ ...editing, title: v })} placeholder="Terms of Service and Privacy Policy" />
<TA label="Content" value={editing.content || ''} onChange={(v: string) => setEditing({ ...editing, content: v })} rows={10} placeholder="Enter the full terms of service text..." />
<I label="Version" value={editing.version || '1.0'} onChange={(v: string) => setEditing({ ...editing, version: v })} />
</div>
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
<Btn onClick={() => saveTerm(editing)} size="sm">Save</Btn>
<Btn variant="secondary" onClick={() => setEditing(null)} size="sm">Cancel</Btn>
</div>
</div>
</div>
)}
</div>
);
}
/* ─── Weather Section ─── */
function WeatherSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
const [enabled, setEnabled] = useState(true);
@@ -2232,6 +2399,7 @@ export default function AdminPage() {
case 'places': return <PlacesSection places={places} setPlaces={setPlaces} images={images} onToast={showToast} />;
case 'reviews': return <ReviewsSection reviews={reviews} setReviews={setReviews} onToast={showToast} />;
case 'users': return <UsersSection onToast={showToast} />;
case 'terms': return <TermsSection onToast={showToast} />;
case 'weather': return <WeatherSection onToast={showToast} />;
case 'trips': return <TripsSection onToast={showToast} />;
case 'messages': return <MessagesSection onToast={showToast} />;