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
+24 -1
View File
@@ -13,6 +13,11 @@ CREATE TABLE IF NOT EXISTS users (
comments_disabled BOOLEAN DEFAULT FALSE,
first_name VARCHAR(100),
last_name VARCHAR(100),
email VARCHAR(255),
phone VARCHAR(50),
country VARCHAR(100),
preferred_language VARCHAR(10) DEFAULT 'es',
terms_accepted_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
@@ -117,7 +122,7 @@ CREATE TABLE IF NOT EXISTS slides (
title VARCHAR(255),
subtitle TEXT,
image_id INTEGER REFERENCES images(id) ON DELETE SET NULL,
image_url VARCHAR(500),
image_url TEXT,
active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
@@ -170,6 +175,16 @@ CREATE TABLE IF NOT EXISTS calendar_events (
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS terms_of_service (
id SERIAL PRIMARY KEY,
language VARCHAR(10) NOT NULL UNIQUE,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
version VARCHAR(20) DEFAULT '1.0',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
@@ -192,6 +207,14 @@ ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
ALTER TABLE users ADD COLUMN IF NOT EXISTS comments_disabled BOOLEAN DEFAULT FALSE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name VARCHAR(100);
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
ALTER TABLE users ADD COLUMN IF NOT EXISTS country VARCHAR(100);
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_language VARCHAR(10) DEFAULT 'es';
ALTER TABLE users ADD COLUMN IF NOT EXISTS terms_accepted_at TIMESTAMP;
-- Fix slides.image_url to TEXT (was VARCHAR(500), too short for base64)
ALTER TABLE slides ALTER COLUMN image_url TYPE TEXT;
-- Seed initial data
INSERT INTO users (username, password_hash, role) VALUES
+267
View File
@@ -0,0 +1,267 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
type TermsData = {
id?: number;
language: string;
title: string;
content: string;
version: string;
};
export default function AcceptTermsPage() {
const [preferredLanguage, setPreferredLanguage] = useState('es');
const [terms, setTerms] = useState<TermsData | null>(null);
const [acceptTerms, setAcceptTerms] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [checking, setChecking] = useState(true);
const router = useRouter();
// Check if logged in and needs terms acceptance
useEffect(() => {
fetch('/api/auth/me', { credentials: 'include' })
.then(r => r.json())
.then(data => {
if (!data.authenticated) {
router.push('/login');
} else if (data.terms_accepted_at) {
router.push(data.role === 'admin' ? '/admin' : '/user');
} else {
setChecking(false);
}
})
.catch(() => {
router.push('/login');
});
}, [router]);
// Fetch terms when language changes
useEffect(() => {
fetch(`/api/terms?language=${preferredLanguage}`)
.then(r => r.json())
.then(data => {
if (data.data) setTerms(data.data);
})
.catch(console.error);
}, [preferredLanguage]);
const handleAccept = async () => {
if (!acceptTerms) {
setError(preferredLanguage === 'es'
? 'Debes aceptar los términos de servicio'
: 'You must accept the terms of service');
return;
}
setLoading(true);
setError(null);
try {
const res = await fetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
preferred_language: preferredLanguage,
accept_terms: true,
}),
credentials: 'same-origin',
});
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Failed to accept terms');
setLoading(false);
return;
}
// Fetch user role to redirect appropriately
const meRes = await fetch('/api/auth/me', { credentials: 'include' });
const meData = await meRes.json();
router.push(meData.role === 'admin' ? '/admin' : '/user');
} catch (err) {
setError('An error occurred. Please try again.');
setLoading(false);
}
};
if (checking) {
return (
<main style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#FAF7F0' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '32px', marginBottom: '16px' }}></div>
<div style={{ fontSize: '16px', color: '#555' }}>Loading...</div>
</div>
</main>
);
}
const accent = '#E8A849';
const navy = '#010D1E';
const cream = '#f5f0e8';
return (
<main
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
background: '#FAF7F0',
}}
>
<div
style={{
width: '100%',
maxWidth: '40rem',
display: 'flex',
flexDirection: 'column',
gap: '1.5rem',
padding: '2.5rem',
background: cream,
borderRadius: '16px',
boxShadow: '0 6px 18px rgba(14,47,71,0.12)',
}}
>
{/* Language toggle */}
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<button
type="button"
onClick={() => setPreferredLanguage('es')}
style={{
padding: '0.5rem 1rem',
background: preferredLanguage === 'es' ? accent : 'transparent',
border: `1px solid ${preferredLanguage === 'es' ? accent : '#ccc'}`,
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: preferredLanguage === 'es' ? 600 : 400,
}}
>
🇪🇨 Español
</button>
<button
type="button"
onClick={() => setPreferredLanguage('en')}
style={{
padding: '0.5rem 1rem',
background: preferredLanguage === 'en' ? accent : 'transparent',
border: `1px solid ${preferredLanguage === 'en' ? accent : '#ccc'}`,
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: preferredLanguage === 'en' ? 600 : 400,
}}
>
🏴󠁧󠁢󠁥󠁮󠁧󠁿 English
</button>
</div>
<h1
style={{
margin: '0',
fontSize: 'clamp(24px, 4vw, 36px)',
fontWeight: 400,
fontStyle: 'italic',
color: navy,
textAlign: 'center',
}}
>
{preferredLanguage === 'es' ? 'Términos de Servicio' : 'Terms of Service'}
</h1>
<p style={{ margin: '0 0 1rem', color: '#555', textAlign: 'center' }}>
{preferredLanguage === 'es'
? 'Por favor, revisa y acepta nuestros términos de servicio para continuar.'
: 'Please review and accept our terms of service to continue.'}
</p>
{/* Terms content */}
<div
style={{
background: '#fff',
borderRadius: '12px',
padding: '1.5rem',
border: '1px solid rgba(1,13,30,0.18)',
maxHeight: '300px',
overflow: 'auto',
}}
>
{terms ? (
<>
<h3 style={{ margin: '0 0 1rem', fontSize: '18px', color: navy }}>{terms.title}</h3>
<p style={{ whiteSpace: 'pre-wrap', margin: 0, fontSize: '14px', lineHeight: 1.6, color: '#555' }}>
{terms.content}
</p>
</>
) : (
<p style={{ margin: 0, color: '#777', textAlign: 'center' }}>
{preferredLanguage === 'es' ? 'Cargando términos...' : 'Loading terms...'}
</p>
)}
</div>
{/* Accept checkbox */}
<label
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '0.75rem',
cursor: 'pointer',
fontSize: '15px',
color: '#333',
}}
>
<input
type="checkbox"
checked={acceptTerms}
onChange={(e) => setAcceptTerms(e.target.checked)}
style={{ marginTop: '0.25rem' }}
/>
<span>
{preferredLanguage === 'es'
? 'He leído y acepto los términos de servicio y la política de privacidad de Hostería La Huasca.'
: 'I have read and accept the terms of service and privacy policy of Hostería La Huasca.'}
</span>
</label>
{error && <p style={{ color: '#c23b22', margin: 0, fontSize: '14px' }}>{error}</p>}
<button
onClick={handleAccept}
disabled={!acceptTerms || loading}
style={{
padding: '1rem',
background: acceptTerms && !loading ? accent : '#ccc',
color: navy,
border: 'none',
borderRadius: '999px',
cursor: acceptTerms && !loading ? 'pointer' : 'not-allowed',
fontSize: '16px',
fontWeight: 600,
transition: 'transform .12s, box-shadow .15s',
opacity: loading ? 0.7 : 1,
}}
onMouseEnter={(e) => {
if (acceptTerms && !loading) {
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 6px 18px rgba(232,168,73,0.35)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
}}
>
{loading
? (preferredLanguage === 'es' ? 'Guardando...' : 'Saving...')
: (preferredLanguage === 'es' ? 'Continuar' : 'Continue')}
</button>
</div>
</main>
);
}
+194 -26
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 (err) {
console.error('Slide save exception:', err);
onToast('Error saving slide', 'error');
}
} catch { 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 }}>
<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 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>
<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} />;
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
export async function GET() {
try {
const { rows } = await db.query(
'SELECT id, language, title, version, updated_at FROM terms_content ORDER BY language, updated_at DESC'
);
return NextResponse.json({ data: rows });
} catch (error) {
console.error('Error fetching all terms:', error);
return NextResponse.json({ error: 'Failed to fetch terms' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { language, title, content, version = '1.0' } = body;
if (!language || !title || !content) {
return NextResponse.json({ error: 'Language, title, and content are required' }, { status: 400 });
}
const { rows } = await db.query(
'INSERT INTO terms_content (language, title, content, version) VALUES ($1, $2, $3, $4) RETURNING id, language, title, version, updated_at',
[language, title, content, version]
);
return NextResponse.json({ data: rows[0] });
} catch (error: any) {
console.error('Error creating terms:', error);
if (error.code === '23505') {
return NextResponse.json({ error: 'Terms with this language and version already exists' }, { status: 409 });
}
return NextResponse.json({ error: error.message || 'Failed to create terms' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { id, title, content } = body;
if (!id) {
return NextResponse.json({ error: 'Terms ID is required' }, { status: 400 });
}
const { rows } = await db.query(
'UPDATE terms_content SET title = $1, content = $2, updated_at = NOW() WHERE id = $3 RETURNING id, language, title, version, updated_at',
[title, content, id]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Terms not found' }, { status: 404 });
}
return NextResponse.json({ data: rows[0] });
} catch (error: any) {
console.error('Error updating terms:', error);
return NextResponse.json({ error: error.message || 'Failed to update terms' }, { status: 500 });
}
}
+51 -7
View File
@@ -5,7 +5,7 @@ import { db } from '@/lib/db';
export async function GET() {
try {
await requireRole('admin');
const { rows } = await db.query('SELECT id, username, role, comments_disabled, first_name, last_name, created_at FROM users ORDER BY created_at DESC');
const { rows } = await db.query('SELECT id, username, role, comments_disabled, first_name, last_name, preferred_language, terms_accepted_at, email, phone, country, created_at FROM users ORDER BY created_at DESC');
return NextResponse.json({ data: rows });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to fetch users' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { username, password, role = 'user' } = body;
const { username, password, role = 'user', first_name, last_name, email, phone, country, preferred_language = 'es' } = body;
if (!username || !password) {
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 });
@@ -26,8 +26,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Role must be admin or user' }, { status: 400 });
}
const user = await createUser(username, password, role as 'admin' | 'user');
return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role } });
const user = await createUser(username, password, role as 'admin' | 'user', { first_name, last_name, email, phone, country, preferred_language });
return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role, first_name: user.first_name, last_name: user.last_name } });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to create user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
}
@@ -37,11 +37,55 @@ export async function PUT(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { id, first_name, last_name, comments_disabled } = body;
const { id, first_name, last_name, comments_disabled, preferred_language, email, phone, country, password } = body;
// Build dynamic update query
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (first_name !== undefined) {
updates.push(`first_name = $${paramIndex++}`);
values.push(first_name || null);
}
if (last_name !== undefined) {
updates.push(`last_name = $${paramIndex++}`);
values.push(last_name || null);
}
if (comments_disabled !== undefined) {
updates.push(`comments_disabled = $${paramIndex++}`);
values.push(comments_disabled === true);
}
if (preferred_language !== undefined) {
updates.push(`preferred_language = $${paramIndex++}`);
values.push(preferred_language || 'es');
}
if (email !== undefined) {
updates.push(`email = $${paramIndex++}`);
values.push(email || null);
}
if (phone !== undefined) {
updates.push(`phone = $${paramIndex++}`);
values.push(phone || null);
}
if (country !== undefined) {
updates.push(`country = $${paramIndex++}`);
values.push(country || null);
}
if (password !== undefined && password) {
const { hashPassword } = await import('@/lib/auth');
updates.push(`password_hash = $${paramIndex++}`);
values.push(await hashPassword(password));
}
if (updates.length === 0) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
}
values.push(id);
const { rows } = await db.query(
'UPDATE users SET first_name = $1, last_name = $2, comments_disabled = $3 WHERE id = $4 RETURNING id, username, role, first_name, last_name, comments_disabled, created_at',
[first_name || null, last_name || null, comments_disabled === true, id]
`UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING id, username, role, first_name, last_name, comments_disabled, preferred_language, email, phone, country, created_at`,
values
);
if (rows.length === 0) {
+8 -1
View File
@@ -17,7 +17,14 @@ export async function POST(request: NextRequest) {
await createSession(user);
return NextResponse.json({ role: user.role, username: user.username });
return NextResponse.json({
role: user.role,
username: user.username,
preferred_language: user.preferred_language,
terms_accepted_at: user.terms_accepted_at,
first_name: user.first_name,
last_name: user.last_name
});
} catch (error) {
console.error('Login error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { createUser } from '@/lib/auth';
import { db } from '@/lib/db';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { username, password, preferred_language, first_name, last_name, email, phone, country, accept_terms } = body;
if (!username || !password) {
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 });
}
if (!accept_terms) {
return NextResponse.json({ error: 'You must accept the terms of service' }, { status: 400 });
}
// Check if username already exists
const { rows: existing } = await db.query('SELECT id FROM users WHERE username = $1', [username]);
if (existing.length > 0) {
return NextResponse.json({ error: 'Username already exists' }, { status: 409 });
}
// Create user with role 'user' and terms_accepted_at
const user = await createUser(username, password, 'user');
// Update with additional profile fields
await db.query(
'UPDATE users SET preferred_language = $1, first_name = $2, last_name = $3, email = $4, phone = $5, country = $6, terms_accepted_at = NOW() WHERE id = $7',
[preferred_language || 'es', first_name || null, last_name || null, email || null, phone || null, country || null, user.id]
);
const { rows } = await db.query(
'SELECT id, username, role, preferred_language, first_name, last_name, email, phone, country, terms_accepted_at FROM users WHERE id = $1',
[user.id]
);
return NextResponse.json({ ok: true, user: rows[0] });
} catch (error: any) {
console.error('Registration error:', error);
return NextResponse.json({ error: error.message || 'Failed to register user' }, { status: 500 });
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const language = searchParams.get('language') || 'es';
// Get the latest terms for the requested language
const { rows } = await db.query(
'SELECT id, language, title, content, version, updated_at FROM terms_content WHERE language = $1 ORDER BY updated_at DESC LIMIT 1',
[language]
);
if (rows.length === 0) {
// Return default English terms if no terms found for requested language
const { rows: enRows } = await db.query(
'SELECT id, language, title, content, version, updated_at FROM terms_content WHERE language = $1 ORDER BY updated_at DESC LIMIT 1',
['en']
);
if (enRows.length === 0) {
// Return empty terms if nothing exists yet
return NextResponse.json({ data: { title: '', content: '', version: '1.0', language } });
}
return NextResponse.json({ data: enRows[0] });
}
return NextResponse.json({ data: rows[0] });
} catch (error) {
console.error('Error fetching terms:', error);
return NextResponse.json({ error: 'Failed to fetch terms' }, { status: 500 });
}
}
+84
View File
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, hashPassword } from '@/lib/auth';
import { db } from '@/lib/db';
export async function GET() {
try {
const session = await requireAuth();
const { rows } = await db.query(
'SELECT id, username, role, first_name, last_name, preferred_language, terms_accepted_at, email, phone, country, created_at FROM users WHERE id = $1',
[session.id]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json({ data: rows[0] });
} catch (error: any) {
return NextResponse.json({ error: error.message || 'Failed to fetch profile' }, { status: error.message === 'Unauthorized' ? 401 : 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const session = await requireAuth();
const body = await request.json();
const { first_name, last_name, preferred_language, email, phone, country, password, accept_terms } = body;
// Build dynamic update query
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (first_name !== undefined) {
updates.push(`first_name = $${paramIndex++}`);
values.push(first_name || null);
}
if (last_name !== undefined) {
updates.push(`last_name = $${paramIndex++}`);
values.push(last_name || null);
}
if (preferred_language !== undefined) {
updates.push(`preferred_language = $${paramIndex++}`);
values.push(preferred_language || 'es');
}
if (email !== undefined) {
updates.push(`email = $${paramIndex++}`);
values.push(email || null);
}
if (phone !== undefined) {
updates.push(`phone = $${paramIndex++}`);
values.push(phone || null);
}
if (country !== undefined) {
updates.push(`country = $${paramIndex++}`);
values.push(country || null);
}
if (password !== undefined && password) {
updates.push(`password_hash = $${paramIndex++}`);
values.push(await hashPassword(password));
}
if (accept_terms === true) {
updates.push(`terms_accepted_at = NOW()`);
}
if (updates.length === 0) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
}
values.push(session.id);
const { rows } = await db.query(
`UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING id, username, role, first_name, last_name, preferred_language, terms_accepted_at, email, phone, country, created_at`,
values
);
if (rows.length === 0) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json({ data: rows[0] });
} catch (error: any) {
return NextResponse.json({ error: error.message || 'Failed to update profile' }, { status: error.message === 'Unauthorized' ? 401 : 500 });
}
}
+327 -11
View File
@@ -3,9 +3,28 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
type TermsData = {
id?: number;
language: string;
title: string;
content: string;
version: string;
};
export default function LoginPage() {
const [mode, setMode] = useState<'login' | 'register'>('login');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [country, setCountry] = useState('');
const [preferredLanguage, setPreferredLanguage] = useState('es');
const [terms, setTerms] = useState<TermsData | null>(null);
const [acceptTerms, setAcceptTerms] = useState(false);
const [showTerms, setShowTerms] = useState(false);
const [error, setError] = useState<string | null>(null);
const [checking, setChecking] = useState(true);
const router = useRouter();
@@ -24,7 +43,17 @@ export default function LoginPage() {
.catch(() => setChecking(false));
}, [router]);
const handleSubmit = async (e: React.FormEvent) => {
// Fetch terms when language changes
useEffect(() => {
fetch(`/api/terms?language=${preferredLanguage}`)
.then(r => r.json())
.then(data => {
if (data.data) setTerms(data.data);
})
.catch(console.error);
}, [preferredLanguage]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
@@ -42,9 +71,71 @@ export default function LoginPage() {
}
const data = await res.json();
// Check if user needs to accept terms
if (!data.terms_accepted_at) {
// Store user data and redirect to terms acceptance
localStorage.setItem('pendingUser', JSON.stringify(data));
router.push('/accept-terms');
return;
}
router.push(data.role === 'admin' ? '/admin' : '/user');
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (!acceptTerms) {
setError('You must accept the terms of service');
return;
}
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
password,
preferred_language: preferredLanguage,
first_name: firstName,
last_name: lastName,
email,
phone,
country,
accept_terms: true,
}),
credentials: 'same-origin',
});
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Registration failed');
return;
}
// Auto-login after registration
const loginRes = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
credentials: 'same-origin',
});
if (loginRes.ok) {
router.push('/user');
} else {
setMode('login');
setError('Registration successful! Please log in.');
}
};
if (checking) {
return (
<main style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#FAF7F0' }}>
@@ -72,34 +163,74 @@ export default function LoginPage() {
}}
>
<form
onSubmit={handleSubmit}
onSubmit={mode === 'login' ? handleLogin : handleRegister}
style={{
width: '100%',
maxWidth: '26rem',
maxWidth: '28rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
gap: '1rem',
padding: '2.5rem',
background: cream,
borderRadius: '16px',
boxShadow: '0 6px 18px rgba(14,47,71,0.12)',
}}
>
{/* Language toggle */}
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end', marginBottom: '0.5rem' }}>
<button
type="button"
onClick={() => setPreferredLanguage('es')}
style={{
padding: '0.25rem 0.75rem',
background: preferredLanguage === 'es' ? accent : 'transparent',
border: `1px solid ${preferredLanguage === 'es' ? accent : '#ccc'}`,
borderRadius: '6px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: preferredLanguage === 'es' ? 600 : 400,
}}
>
🇪🇨 Español
</button>
<button
type="button"
onClick={() => setPreferredLanguage('en')}
style={{
padding: '0.25rem 0.75rem',
background: preferredLanguage === 'en' ? accent : 'transparent',
border: `1px solid ${preferredLanguage === 'en' ? accent : '#ccc'}`,
borderRadius: '6px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: preferredLanguage === 'en' ? 600 : 400,
}}
>
🏴󠁧󠁢󠁥󠁮󠁧󠁿 English
</button>
</div>
<h1
style={{
margin: '0 0 0.25rem',
margin: '0',
fontSize: 'clamp(28px, 4vw, 40px)',
fontWeight: 400,
fontStyle: 'italic',
color: navy,
}}
>
Welcome back
{mode === 'login'
? (preferredLanguage === 'es' ? 'Bienvenido de nuevo' : 'Welcome back')
: (preferredLanguage === 'es' ? 'Crear cuenta' : 'Create account')}
</h1>
<p style={{ margin: '0 0 1rem', color: '#555' }}>Log in to your portal.</p>
<p style={{ margin: '0 0 0.5rem', color: '#555' }}>
{mode === 'login'
? (preferredLanguage === 'es' ? 'Inicia sesión en tu portal.' : 'Log in to your portal.')
: (preferredLanguage === 'es' ? 'Regístrate como huésped.' : 'Register as a guest.')}
</p>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
Username
{preferredLanguage === 'es' ? 'Usuario' : 'Username'}
<input
type="text"
value={username}
@@ -117,7 +248,7 @@ export default function LoginPage() {
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
Password
{preferredLanguage === 'es' ? 'Contraseña' : 'Password'}
<input
type="password"
value={password}
@@ -134,7 +265,161 @@ export default function LoginPage() {
required
/>
</label>
{mode === 'register' && (
<>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
{preferredLanguage === 'es' ? 'Confirmar contraseña' : 'Confirm password'}
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
style={{
padding: '0.75rem 0.9rem',
borderRadius: '12px',
border: '1px solid rgba(1,13,30,0.18)',
background: '#fff',
fontSize: '15px',
color: navy,
outline: 'none',
}}
required
/>
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
{preferredLanguage === 'es' ? 'Nombre' : 'First name'}
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
style={{
padding: '0.75rem 0.9rem',
borderRadius: '12px',
border: '1px solid rgba(1,13,30,0.18)',
background: '#fff',
fontSize: '15px',
color: navy,
outline: 'none',
}}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
{preferredLanguage === 'es' ? 'Apellido' : 'Last name'}
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
style={{
padding: '0.75rem 0.9rem',
borderRadius: '12px',
border: '1px solid rgba(1,13,30,0.18)',
background: '#fff',
fontSize: '15px',
color: navy,
outline: 'none',
}}
/>
</label>
</div>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
{preferredLanguage === 'es' ? 'Correo electrónico' : 'Email'}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{
padding: '0.75rem 0.9rem',
borderRadius: '12px',
border: '1px solid rgba(1,13,30,0.18)',
background: '#fff',
fontSize: '15px',
color: navy,
outline: 'none',
}}
/>
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
{preferredLanguage === 'es' ? 'Teléfono' : 'Phone'}
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
style={{
padding: '0.75rem 0.9rem',
borderRadius: '12px',
border: '1px solid rgba(1,13,30,0.18)',
background: '#fff',
fontSize: '15px',
color: navy,
outline: 'none',
}}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '12px', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#555' }}>
{preferredLanguage === 'es' ? 'País' : 'Country'}
<input
type="text"
value={country}
onChange={(e) => setCountry(e.target.value)}
style={{
padding: '0.75rem 0.9rem',
borderRadius: '12px',
border: '1px solid rgba(1,13,30,0.18)',
background: '#fff',
fontSize: '15px',
color: navy,
outline: 'none',
}}
/>
</label>
</div>
{/* Terms of Service */}
<div style={{ background: '#fff', borderRadius: '12px', padding: '1rem', border: '1px solid rgba(1,13,30,0.18)' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.75rem' }}>
<input
type="checkbox"
id="accept-terms"
checked={acceptTerms}
onChange={(e) => setAcceptTerms(e.target.checked)}
style={{ marginTop: '0.25rem' }}
/>
<label htmlFor="accept-terms" style={{ fontSize: '14px', color: '#555', cursor: 'pointer' }}>
{preferredLanguage === 'es'
? 'Acepto los términos de servicio y la política de privacidad'
: 'I accept the terms of service and privacy policy'}
</label>
</div>
<button
type="button"
onClick={() => setShowTerms(!showTerms)}
style={{
marginTop: '0.75rem',
background: 'transparent',
border: 'none',
color: accent,
cursor: 'pointer',
fontSize: '13px',
textDecoration: 'underline',
padding: 0,
}}
>
{preferredLanguage === 'es' ? 'Leer términos completos' : 'Read full terms'}
</button>
{showTerms && terms && (
<div style={{ marginTop: '1rem', padding: '1rem', background: '#f5f5f5', borderRadius: '8px', maxHeight: '200px', overflow: 'auto', fontSize: '13px' }}>
<h3 style={{ margin: '0 0 0.5rem', fontSize: '16px' }}>{terms.title}</h3>
<p style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{terms.content}</p>
</div>
)}
</div>
</>
)}
{error && <p style={{ color: '#c23b22', margin: 0, fontSize: '14px' }}>{error}</p>}
<button
type="submit"
style={{
@@ -157,11 +442,42 @@ export default function LoginPage() {
e.currentTarget.style.boxShadow = 'none';
}}
>
Log in
{mode === 'login'
? (preferredLanguage === 'es' ? 'Iniciar sesión' : 'Log in')
: (preferredLanguage === 'es' ? 'Registrarse' : 'Register')}
</button>
<p style={{ margin: '0.5rem 0 0', fontSize: '13px', color: '#777', textAlign: 'center' }}>
Contact reception for login credentials.
{mode === 'login' ? (
<>
{preferredLanguage === 'es' ? '¿No tienes cuenta? ' : "Don't have an account? "}
<button
type="button"
onClick={() => setMode('register')}
style={{ background: 'none', border: 'none', color: accent, cursor: 'pointer', textDecoration: 'underline', fontSize: '13px' }}
>
{preferredLanguage === 'es' ? 'Regístrate' : 'Register'}
</button>
</>
) : (
<>
{preferredLanguage === 'es' ? '¿Ya tienes cuenta? ' : 'Already have an account? '}
<button
type="button"
onClick={() => setMode('login')}
style={{ background: 'none', border: 'none', color: accent, cursor: 'pointer', textDecoration: 'underline', fontSize: '13px' }}
>
{preferredLanguage === 'es' ? 'Iniciar sesión' : 'Log in'}
</button>
</>
)}
</p>
{mode === 'login' && (
<p style={{ margin: '0.25rem 0 0', fontSize: '12px', color: '#999', textAlign: 'center' }}>
{preferredLanguage === 'es' ? 'Contacta a recepción para obtener credenciales.' : 'Contact reception for login credentials.'}
</p>
)}
</form>
</main>
);
+12 -4
View File
@@ -50,17 +50,25 @@ const typeEmoji: Record<CalendarEvent['type'], string> = {
event: '📌',
};
function parseDateLocal(dateStr: string): Date {
// Parse date as local time to avoid timezone shifts
// Input format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS
const parts = dateStr.split('T')[0].split('-');
const year = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10) - 1; // JS months are 0-indexed
const day = parseInt(parts[2], 10);
return new Date(year, month, day);
}
function formatMonthDay(dateStr: string) {
// Handle both ISO timestamp and date-only formats
const d = dateStr.includes('T') ? new Date(dateStr) : new Date(dateStr + 'T00:00:00');
const d = parseDateLocal(dateStr);
return { month: d.toLocaleString('en-US', { month: 'short' }), day: d.getDate() };
}
function daysUntil(dateStr: string) {
const today = new Date();
today.setHours(0, 0, 0, 0);
// Handle both ISO timestamp and date-only formats
const d = dateStr.includes('T') ? new Date(dateStr) : new Date(dateStr + 'T00:00:00');
const d = parseDateLocal(dateStr);
d.setHours(0, 0, 0, 0);
const diff = Math.ceil((d.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (isNaN(diff)) return 'Unknown';
+21 -5
View File
@@ -17,22 +17,38 @@ export async function verifyPassword(password: string, hash: string) {
return bcrypt.compareSync(password, hash);
}
export async function createUser(username: string, password: string, role: 'admin' | 'user') {
export async function createUser(
username: string,
password: string,
role: 'admin' | 'user',
profile?: { first_name?: string; last_name?: string; email?: string; phone?: string; country?: string; preferred_language?: string }
) {
const hash = await hashPassword(password);
const { rows } = await db.query(
'INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3) ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash, role = EXCLUDED.role RETURNING id, username, role',
[username, hash, role]
`INSERT INTO users (username, password_hash, role, first_name, last_name, email, phone, country, preferred_language)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash, role = EXCLUDED.role
RETURNING id, username, role, first_name, last_name, email, phone, country, preferred_language`,
[username, hash, role, profile?.first_name || null, profile?.last_name || null, profile?.email || null, profile?.phone || null, profile?.country || null, profile?.preferred_language || 'es']
);
return rows[0];
}
export async function authenticateUser(username: string, password: string) {
const { rows } = await db.query('SELECT id, username, password_hash, role FROM users WHERE username = $1', [username]);
const { rows } = await db.query('SELECT id, username, password_hash, role, preferred_language, terms_accepted_at, first_name, last_name FROM users WHERE username = $1', [username]);
if (rows.length === 0) return null;
const user = rows[0];
const valid = await verifyPassword(password, user.password_hash);
if (!valid) return null;
return { id: user.id, username: user.username, role: user.role };
return {
id: user.id,
username: user.username,
role: user.role,
preferred_language: user.preferred_language || 'es',
terms_accepted_at: user.terms_accepted_at,
first_name: user.first_name,
last_name: user.last_name
};
}
export async function createSession(user: { id: number; username: string; role: string }) {
+34
View File
@@ -222,6 +222,38 @@ export async function initSchema() {
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_language VARCHAR(10) DEFAULT 'es';
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS terms_accepted_at TIMESTAMP;
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS country VARCHAR(100);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS terms_content (
id SERIAL PRIMARY KEY,
language VARCHAR(10) NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
version VARCHAR(20) NOT NULL DEFAULT '1.0',
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(language, version)
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS availability (
id SERIAL PRIMARY KEY,
@@ -397,6 +429,8 @@ export async function seed() {
['Heating', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z"/></svg>', 9],
['Balcony', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><line x1="3" y1="17" x2="21" y2="17"/><line x1="7" y1="11" x2="7" y2="21"/><line x1="12" y1="11" x2="12" y2="21"/><line x1="17" y1="11" x2="17" y2="21"/></svg>', 10],
['King Bed', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 18v-4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v4"/><path d="M2 14v-2a2 2 0 0 1 2-2h1"/><path d="M22 14v-2a2 2 0 0 0-2-2h-1"/><path d="M5 4h14a2 2 0 0 1 2 2v2H3V6a2 2 0 0 1 2-2z"/><rect x="3" y="10" width="18" height="4" rx="1"/></svg>', 11],
['Queen Bed', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 18v-3a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v3"/><path d="M2 13v-2a2 2 0 0 1 2-2h1"/><path d="M18 13v-2a2 2 0 0 0-2-2h-1"/><path d="M4 5h10a2 2 0 0 1 2 2v1H2V7a2 2 0 0 1 2-2z"/><rect x="2" y="9" width="14" height="3" rx="1"/></svg>', 13],
['2 Queen Beds', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 15v-2a1.5 1.5 0 0 1 1.5-1.5H10a1.5 1.5 0 0 1 1.5 1.5v2"/><path d="M12.5 15v-2a1.5 1.5 0 0 1 1.5-1.5H21.5a1.5 1.5 0 0 1 1.5 1.5v2"/><path d="M1 11V9.5A1.5 1.5 0 0 1 2.5 8h8A1.5 1.5 0 0 1 12 9.5V11"/><path d="M12 11V9.5A1.5 1.5 0 0 1 13.5 8h8A1.5 1.5 0 0 1 23 9.5V11"/><rect x="1" y="8" width="11" height="2.5" rx="0.75"/><rect x="12" y="8" width="11" height="2.5" rx="0.75"/></svg>', 14],
['Mountain View', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 3 4 8 5-5 5 15H2L8 3z"/></svg>', 12],
];