diff --git a/init-db-local.sh b/init-db-local.sh index a48cdbe..08de435 100755 --- a/init-db-local.sh +++ b/init-db-local.sh @@ -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 diff --git a/src/app/accept-terms/page.tsx b/src/app/accept-terms/page.tsx new file mode 100644 index 0000000..65c26b3 --- /dev/null +++ b/src/app/accept-terms/page.tsx @@ -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(null); + const [acceptTerms, setAcceptTerms] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+
+
+
Loading...
+
+
+ ); + } + + const accent = '#E8A849'; + const navy = '#010D1E'; + const cream = '#f5f0e8'; + + return ( +
+
+ {/* Language toggle */} +
+ + +
+ +

+ {preferredLanguage === 'es' ? 'Términos de Servicio' : 'Terms of Service'} +

+ +

+ {preferredLanguage === 'es' + ? 'Por favor, revisa y acepta nuestros términos de servicio para continuar.' + : 'Please review and accept our terms of service to continue.'} +

+ + {/* Terms content */} +
+ {terms ? ( + <> +

{terms.title}

+

+ {terms.content} +

+ + ) : ( +

+ {preferredLanguage === 'es' ? 'Cargando términos...' : 'Loading terms...'} +

+ )} +
+ + {/* Accept checkbox */} + + + {error &&

{error}

} + + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 5dc96f7..c922ac2 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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(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 (
@@ -1826,7 +1844,7 @@ function UsersSection({ onToast }: { onToast: (msg: string, type: string) => voi const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [editing, setEditing] = useState(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 (
- setShowAdd(!showAdd)} size="sm">+ Add User} /> + setShowAdd(!showAdd)} size="sm">+ Add User} /> {showAdd && (
setNewUser({ ...newUser, username: v })} /> setNewUser({ ...newUser, password: v })} /> -
- - -
+ setNewUser({ ...newUser, role: v })} options={[{ value: 'user', label: 'User' }, { value: 'admin', label: 'Admin' }]} /> + setNewUser({ ...newUser, first_name: v })} /> + setNewUser({ ...newUser, last_name: v })} /> + setNewUser({ ...newUser, email: v })} /> + setNewUser({ ...newUser, phone: v })} /> + setNewUser({ ...newUser, country: v })} /> + setNewUser({ ...newUser, preferred_language: v })} options={[{ value: 'es', label: '🇪🇨 Español' }, { value: 'en', label: '🏴󠁧󠁢󠁥󠁮󠁧󠁿 English' }]} />
Create User @@ -1936,26 +1953,45 @@ function UsersSection({ onToast }: { onToast: (msg: string, type: string) => voi {users.map((u: any) => (
-
{u.username} {u.role}
-
{u.first_name || ''} {u.last_name || ''} {u.comments_disabled ? '• Comments disabled' : ''}
+
+ {u.username} {u.role} + {u.preferred_language && {u.preferred_language === 'es' ? '🇪🇨 ES' : 'EN'}} +
+
+ {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' : ''} +
setEditing(u)}>Edit + {u.role !== 'admin' && deleteUser(u.id)}>Delete}
))}
{editing && (
-
+

Edit User

- setEditing({ ...editing, first_name: v })} /> - setEditing({ ...editing, last_name: v })} /> -
- setEditing({ ...editing, comments_disabled: e.target.checked })} /> - +
+ setEditing({ ...editing, first_name: v })} /> + setEditing({ ...editing, last_name: v })} />
+ setEditing({ ...editing, email: v })} /> +
+ setEditing({ ...editing, phone: v })} /> + setEditing({ ...editing, country: v })} /> +
+ setEditing({ ...editing, preferred_language: v })} options={[{ value: 'es', label: '🇪🇨 Español' }, { value: 'en', label: '🏴󠁧󠁢󠁥󠁮󠁧󠁿 English' }]} /> + setEditing({ ...editing, password: v })} /> + setEditing({ ...editing, comments_disabled: v })} /> + {editing.terms_accepted_at && ( +
+ Terms accepted: {new Date(editing.terms_accepted_at).toLocaleString()} +
+ )}
updateUser(editing)} size="sm">Save @@ -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(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
Loading...
; + } + + return ( +
+ +

+ Manage terms of service and disclaimers shown to users. One version per language. +

+ +
+ {/* Spanish Terms */} +
+
+

🇪🇨 Español

+ {terms.es && ( + + Version: {terms.es.version} + + )} +
+ {terms.es ? ( +
+
{terms.es.title}
+
+ {terms.es.content?.substring(0, 300)}... +
+ setEditing({ ...terms.es })} style={{ marginTop: 12 }}>Edit +
+ ) : ( + setEditing({ language: 'es', title: '', content: '', version: '1.0' })}>Create Spanish Terms + )} +
+ + {/* English Terms */} +
+
+

🏴󠁧󠁢󠁥󠁮󠁧󠁿 English

+ {terms.en && ( + + Version: {terms.en.version} + + )} +
+ {terms.en ? ( +
+
{terms.en.title}
+
+ {terms.en.content?.substring(0, 300)}... +
+ setEditing({ ...terms.en })} style={{ marginTop: 12 }}>Edit +
+ ) : ( + setEditing({ language: 'en', title: '', content: '', version: '1.0' })}>Create English Terms + )} +
+
+ + {/* Edit Modal */} + {editing && ( +
+
+

+ {editing.language === 'es' ? '🇪🇨 ' : '🏴󠁧󠁢󠁥󠁮󠁧󠁿 '}Edit Terms ({editing.language.toUpperCase()}) +

+
+ setEditing({ ...editing, title: v })} placeholder="Terms of Service and Privacy Policy" /> + setEditing({ ...editing, content: v })} rows={10} placeholder="Enter the full terms of service text..." /> + setEditing({ ...editing, version: v })} /> +
+
+ saveTerm(editing)} size="sm">Save + setEditing(null)} size="sm">Cancel +
+
+
+ )} +
+ ); +} + /* ─── 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 ; case 'reviews': return ; case 'users': return ; + case 'terms': return ; case 'weather': return ; case 'trips': return ; case 'messages': return ; diff --git a/src/app/api/admin/terms/route.ts b/src/app/api/admin/terms/route.ts new file mode 100644 index 0000000..6233c99 --- /dev/null +++ b/src/app/api/admin/terms/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 3b2cc0c..3a548c4 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -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) { diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index d2979de..36f9ef0 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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 }); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..1a0af97 --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/terms/route.ts b/src/app/api/terms/route.ts new file mode 100644 index 0000000..19df97b --- /dev/null +++ b/src/app/api/terms/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts new file mode 100644 index 0000000..f71125d --- /dev/null +++ b/src/app/api/user/profile/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 257902f..326d0db 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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(null); + const [acceptTerms, setAcceptTerms] = useState(false); + const [showTerms, setShowTerms] = useState(false); const [error, setError] = useState(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 (
@@ -72,34 +163,74 @@ export default function LoginPage() { }} >
+ {/* Language toggle */} +
+ + +
+

- Welcome back + {mode === 'login' + ? (preferredLanguage === 'es' ? 'Bienvenido de nuevo' : 'Welcome back') + : (preferredLanguage === 'es' ? 'Crear cuenta' : 'Create account')}

-

Log in to your portal.

+

+ {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.')} +

+ + {mode === 'register' && ( + <> + +
+ + +
+ +
+ + +
+ + {/* Terms of Service */} +
+
+ setAcceptTerms(e.target.checked)} + style={{ marginTop: '0.25rem' }} + /> + +
+ + {showTerms && terms && ( +
+

{terms.title}

+

{terms.content}

+
+ )} +
+ + )} + {error &&

{error}

} + +

- Contact reception for login credentials. + {mode === 'login' ? ( + <> + {preferredLanguage === 'es' ? '¿No tienes cuenta? ' : "Don't have an account? "} + + + ) : ( + <> + {preferredLanguage === 'es' ? '¿Ya tienes cuenta? ' : 'Already have an account? '} + + + )}

+ + {mode === 'login' && ( +

+ {preferredLanguage === 'es' ? 'Contacta a recepción para obtener credenciales.' : 'Contact reception for login credentials.'} +

+ )}
); -} +} \ No newline at end of file diff --git a/src/components/CalendarStrip.tsx b/src/components/CalendarStrip.tsx index 4464303..6ce4f28 100644 --- a/src/components/CalendarStrip.tsx +++ b/src/components/CalendarStrip.tsx @@ -50,17 +50,25 @@ const typeEmoji: Record = { 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'; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 735bc5f..532bc3a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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 }) { diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 1d0ed28..a26103f 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -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', '', 9], ['Balcony', '', 10], ['King Bed', '', 11], + ['Queen Bed', '', 13], + ['2 Queen Beds', '', 14], ['Mountain View', '', 12], ];