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:
+24
-1
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+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} />;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
+328
-12
@@ -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,12 +442,43 @@ 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 }) {
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user