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:
+196
-28
@@ -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} />;
|
||||
|
||||
Reference in New Issue
Block a user