feat: harden bootstrap and add tests

This commit is contained in:
2026-06-28 16:43:03 -05:00
parent 01ea9e391d
commit 991e73fcff
39 changed files with 3399 additions and 459 deletions
+2
View File
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic'; // Prevents static generation
// GET /api/admin/menu — returns ALL menu items (including inactive)
export async function GET() {
try {
+2
View File
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic'; // Prevents static generation
export async function GET() {
try {
await requireRole('admin');
+10 -3
View File
@@ -1,11 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextResponse } from 'next/server';
import { createUser } from '@/lib/auth';
export async function POST() {
try {
const user = await createUser('mmendoza', 'W3canbeheroes*-*', 'admin');
const username = process.env.BOOTSTRAP_ADMIN_USERNAME || 'mmendoza';
const password = process.env.BOOTSTRAP_ADMIN_PASSWORD;
if (!password) {
return NextResponse.json({ error: 'Bootstrap password is not configured' }, { status: 500 });
}
const user = await createUser(username, password, 'admin');
return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role } });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to create user' }, { status: 500 });
return NextResponse.json({ error: err?.message || 'Failed to create bootstrap user' }, { status: 500 });
}
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET() {
return NextResponse.json(
{
ok: true,
status: 'healthy',
timestamp: new Date().toISOString(),
},
{
status: 200,
headers: {
'cache-control': 'no-store, max-age=0',
},
}
);
}
@@ -1,6 +1,8 @@
import { db } from '@/lib/db';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic'; // Prevents static generation
export async function GET() {
try {
const { rows } = await db.query(`
+2
View File
@@ -2,6 +2,8 @@ import { NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic'; // Prevents static generation
export async function GET() {
try {
const session = await requireRole('user');
+39 -7
View File
@@ -3,26 +3,50 @@
import { useEffect, useState } from 'react';
import Slideshow from '@/components/Slideshow';
import CalendarStrip from '@/components/CalendarStrip';
import MeasuredText from '@/components/MeasuredText';
import WeatherWidget from '@/components/WeatherWidget';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import { t, getLanguage } from '@/lib/i18n';
import { t, useLanguage } from '@/lib/i18n';
const gold = '#E8A849';
const navy = '#010D1E';
const cream = '#f5f0e8';
const homeCopy = {
en: {
title: 'La Huasca',
subtitle: 'A mountain retreat with warm hospitality and local flavor.',
highlights: ['common.hotel', 'common.restaurant', 'common.andean_highlands'],
},
es: {
title: 'La Huasca',
subtitle: 'Un refugio de montaña con hospitalidad cálida y sabor local.',
highlights: ['common.hotel', 'common.restaurant', 'common.andean_highlands'],
},
} as const;
const fallbackTaglineKey = 'common.home_tagline';
export default function HomePage() {
const [tagline, setTagline] = useState('Hotel · Restaurant · Andean Highlands');
const language = useLanguage();
const [tagline, setTagline] = useState(t(fallbackTaglineKey, language));
useEffect(() => {
let cancelled = false;
const fallbackTagline = t(fallbackTaglineKey, language);
setTagline(fallbackTagline);
fetch('/api/site-assets?key=tagline')
.then((r) => r.json())
.then((j) => {
if (j.data) setTagline(j.data);
if (!cancelled && j.data) setTagline(j.data);
})
.catch(() => {});
}, []);
return () => {
cancelled = true;
};
}, [language]);
const copy = homeCopy[language];
return (
<main style={{ minHeight: 'calc(100vh - 56px)', background: navy, color: cream }}>
@@ -64,10 +88,18 @@ export default function HomePage() {
color: cream,
}}
>
La Huasca
{copy.title}
</h1>
<p style={{ fontSize: '18px', lineHeight: 1.55, color: 'rgba(245,240,232,0.78)', maxWidth: '52ch', margin: '0 0 2rem' }}>
{t('common.hotel', getLanguage())} · {t('common.restaurant', getLanguage())} · {t('common.andean_highlands', getLanguage())}
{copy.highlights.map((key, index) => (
<span key={key}>
{t(key, language)}
{index < copy.highlights.length - 1 ? ' · ' : ''}
</span>
))}
</p>
<p style={{ fontSize: '16px', lineHeight: 1.65, color: 'rgba(245,240,232,0.7)', maxWidth: '58ch', margin: 0 }}>
{copy.subtitle}
</p>
</section>
+33 -17
View File
@@ -1,21 +1,35 @@
'use client';
import { useState, useEffect } from 'react';
import { getLanguage, setLanguage } from '@/lib/i18n';
import { Language, setLanguage, useLanguage } from '@/lib/i18n';
function Flag({ language }: { language: Language }) {
if (language === 'en') {
return (
<svg width="24" height="16" viewBox="0 0 24 16" fill="none">
<rect width="24" height="16" fill="white" />
<rect x="0" y="6" width="24" height="4" fill="#CE1124" />
<rect x="10" y="0" width="4" height="16" fill="#CE1124" />
</svg>
);
}
return (
<svg width="24" height="16" viewBox="0 0 24 16" fill="none">
<rect width="24" height="16" fill="white" />
<rect y="0" width="24" height="5.33" fill="#EF3340" />
<rect y="10.67" width="24" height="5.33" fill="#EF3340" />
<rect y="5.33" width="24" height="5.33" fill="#073685" />
<path d="M12 8L10.39 9.18L11.02 7.24L9.39 6.82L11.2 6.82L12 5L12.81 6.82L14.62 6.82L12.98 7.24L13.61 9.18L12 8Z" fill="#FFD100" />
</svg>
);
}
export default function LanguageSwitcher() {
const [currentLang, setCurrentLang] = useState<'en' | 'es'>('en');
useEffect(() => {
setCurrentLang(getLanguage());
}, []);
const currentLang = useLanguage();
const nextLanguage = currentLang === 'en' ? 'es' : 'en';
const toggleLanguage = () => {
const newLang = currentLang === 'en' ? 'es' : 'en';
setLanguage(newLang);
setCurrentLang(newLang);
// Reload the page to apply the language change
window.location.reload();
setLanguage(nextLanguage);
};
return (
@@ -29,13 +43,15 @@ export default function LanguageSwitcher() {
background: 'rgba(255, 255, 255, 0.9)',
border: '1px solid #ddd',
borderRadius: '4px',
padding: '0.5rem 1rem',
padding: '0.5rem',
cursor: 'pointer',
fontSize: '0.9rem',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
aria-label={`Switch to ${currentLang === 'en' ? 'Spanish' : 'English'}`}
>
{currentLang === 'en' ? 'ES' : 'EN'}
<Flag language={nextLanguage} />
</button>
);
}
}
+74
View File
@@ -0,0 +1,74 @@
'use client';
export type AdminNavItem = {
id: string;
label: string;
icon: string;
};
export type AdminSidebarProps = {
items: AdminNavItem[];
activeSection: string;
onSelect: (id: string) => void;
};
const C = {
gold: '#D4A853',
goldLight: 'rgba(212,168,83,0.12)',
navy: '#010D1E',
};
export default function AdminSidebar({ items, activeSection, onSelect }: AdminSidebarProps) {
return (
<aside
style={{
width: 260,
background: C.navy,
height: '100vh',
position: 'sticky',
top: 0,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
transition: 'transform 300ms',
borderRight: '1px solid rgba(255,255,255,0.06)',
}}
>
<div style={{ padding: '24px 20px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<h1 style={{ margin: 0, fontSize: '18px', fontWeight: 700, color: C.gold }}>🏨 Hotel La Huasca</h1>
<p style={{ margin: '4px 0 0', fontSize: '11px', color: 'rgba(255,255,255,0.4)' }}>Admin Portal</p>
</div>
<nav style={{ padding: '16px 12px', flex: 1 }}>
{items.map((item) => (
<button
key={item.id}
onClick={() => onSelect(item.id)}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '10px 14px',
marginBottom: '4px',
borderRadius: 10,
border: 'none',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
background: activeSection === item.id ? C.goldLight : 'transparent',
color: activeSection === item.id ? C.gold : 'rgba(255,255,255,0.6)',
transition: 'all 200ms',
textAlign: 'left',
}}
>
<span style={{ fontSize: '18px' }}>{item.icon}</span>
{item.label}
</button>
))}
</nav>
<div style={{ padding: '16px 20px', borderTop: '1px solid rgba(255,255,255,0.06)', fontSize: '11px', color: 'rgba(255,255,255,0.3)' }}>
v1.0.0
</div>
</aside>
);
}
+55
View File
@@ -0,0 +1,55 @@
'use client';
const C = {
gold: '#D4A853',
navy: '#010D1E',
card: '#FFFFFF',
shadow: '0 1px 4px rgba(1,13,30,0.06)',
textLight: '#888888',
};
function StatCard({ icon, value, label }: { icon: string; value: number; label: string }) {
return (
<div
style={{
background: C.card,
borderRadius: 14,
padding: '16px 20px',
boxShadow: C.shadow,
display: 'flex',
alignItems: 'center',
gap: '14px',
minWidth: '160px',
}}
>
<span style={{ fontSize: '28px' }}>{icon}</span>
<div>
<div style={{ fontSize: '22px', fontWeight: 700, color: C.navy }}>{value}</div>
<div style={{ fontSize: '12px', color: C.textLight }}>{label}</div>
</div>
</div>
);
}
export type DashboardProps = {
rooms: Array<unknown>;
slides: Array<unknown>;
events: Array<unknown>;
reviews: Array<{ approved?: boolean }>;
places: Array<unknown>;
};
export default function Dashboard({ rooms, slides, events, reviews, places }: DashboardProps) {
return (
<div>
<h2 style={{ margin: '0 0 20px', fontSize: '22px', fontWeight: 700, color: C.navy }}>Dashboard</h2>
<div style={{ display: 'flex', gap: '14px', flexWrap: 'wrap', marginBottom: '24px' }}>
<StatCard icon="🏨" value={rooms?.length || 0} label="Rooms" />
<StatCard icon="🎞️" value={slides?.length || 0} label="Slides" />
<StatCard icon="📅" value={events?.length || 0} label="Events" />
<StatCard icon="🗺️" value={places?.length || 0} label="Places" />
<StatCard icon="⭐" value={reviews?.filter((r) => r.approved)?.length || 0} label="Approved Reviews" />
</div>
</div>
);
}
+139 -122
View File
@@ -1,154 +1,171 @@
'use client';
import { useState, useEffect } from 'react';
import { t } from '@/lib/i18n';
import { useEffect, useState } from 'react';
const C = {
gold: '#D4A853',
navy: '#010D1E',
bg: '#F7F4EF',
card: '#FFFFFF',
border: 'rgba(1,13,30,0.08)',
text: '#444444',
textLight: '#888888',
danger: '#D32F2F',
};
function I({ label, value, onChange, type = 'text', style }: any) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{label && <label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>{label}</label>}
<input
type={type}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
style={{
padding: '8px 12px',
borderRadius: 8,
border: `1px solid ${C.border}`,
fontSize: '14px',
outline: 'none',
background: '#FAFAFA',
...style,
}}
/>
</div>
);
}
function SecHead({ title }: { title: string }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px', flexWrap: 'wrap', gap: '10px' }}>
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: C.navy }}>{title}</h2>
</div>
);
}
export default function WeatherSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
const [enabled, setEnabled] = useState(true);
const [cities, setCities] = useState<Array<{name: string, lat: number, lon: number}>>([
{ name: 'La Huasca', lat: -31.525, lon: -65.125 }
]);
const [cities, setCities] = useState<{ name: string; lat: string; lon: string }[]>([]);
const [newCity, setNewCity] = useState({ name: '', lat: '', lon: '' });
const [loading, setLoading] = useState(true);
// Load weather config from API
useEffect(() => {
fetch('/api/site-assets?key=weather_config')
.then(r => r.json())
.then(data => {
if (data.data) {
try {
const loadConfig = async () => {
try {
const res = await fetch('/api/site-assets?key=weather_config');
if (res.ok) {
const data = await res.json();
if (data.data) {
const config = JSON.parse(data.data);
setEnabled(config.enabled ?? true);
setCities(config.cities ?? [{ name: 'La Huasca', lat: -31.525, lon: -65.125 }]);
} catch (e) {
console.error('Failed to parse weather config:', e);
setCities(config.cities ?? []);
}
}
})
.catch(err => {
} catch (err) {
console.error('Failed to load weather config:', err);
});
} finally {
setLoading(false);
}
};
loadConfig();
}, []);
const addCity = () => {
setCities([...cities, { name: '', lat: 0, lon: 0 }]);
const saveConfig = async () => {
try {
const config = { enabled, cities };
const res = await fetch('/api/site-assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'weather_config', value: JSON.stringify(config) }),
});
if (res.ok) {
onToast('Weather settings saved', 'success');
} else {
throw new Error('Failed to save');
}
} catch (err) {
onToast('Error saving weather settings', 'error');
}
};
const updateCity = (index: number, field: string, value: string | number) => {
const updated = [...cities];
if (field === 'name') {
updated[index].name = value as string;
} else if (field === 'lat') {
updated[index].lat = value as number;
} else if (field === 'lon') {
updated[index].lon = value as number;
const addCity = () => {
if (newCity.name && newCity.lat && newCity.lon) {
setCities([...cities, newCity]);
setNewCity({ name: '', lat: '', lon: '' });
}
setCities(updated);
};
const removeCity = (index: number) => {
setCities(cities.filter((_, i) => i !== index));
};
const saveConfig = async () => {
try {
const config = { enabled, cities };
const response = await fetch('/api/site-assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: 'weather_config',
data: JSON.stringify(config)
})
});
if (response.ok) {
onToast(t('admin.weather.save_success', 'en'), 'success');
} else {
onToast(t('admin.weather.save_error', 'en'), 'error');
}
} catch (err) {
console.error('Failed to save weather config:', err);
onToast(t('admin.weather.save_error', 'en'), 'error');
}
};
if (loading) {
return (
<div>
<SecHead title="🌤️ Weather Widget" />
<div style={{ textAlign: 'center', padding: '40px', color: C.textLight }}>Loading...</div>
</div>
);
}
return (
<div style={{ padding: '2rem' }}>
<h2 style={{ marginBottom: '1.5rem' }}>{t('admin.weather.title', 'en')}</h2>
<p style={{ marginBottom: '2rem', color: '#666' }}>{t('admin.weather.description', 'en')}</p>
<div>
<SecHead title="🌤️ Weather Widget" />
<div style={{ background: C.card, borderRadius: 12, padding: '24px', marginBottom: '24px', border: `1px solid ${C.border}` }}>
<p style={{ color: C.text, marginBottom: '24px' }}>Configure the weather widget displayed on the homepage</p>
<div style={{ marginBottom: '2rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
{t('admin.weather.enabled', 'en')}
</label>
</div>
<div style={{ marginBottom: '24px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
style={{ width: '18px', height: '18px' }}
/>
<span style={{ fontWeight: 500, color: C.text }}>Enable Weather Widget</span>
</label>
</div>
<div style={{ marginBottom: '2rem' }}>
<h3 style={{ marginBottom: '1rem' }}>{t('admin.weather.cities', 'en')}</h3>
{cities.map((city, index) => (
<div key={index} style={{ display: 'flex', gap: '1rem', marginBottom: '1rem', alignItems: 'end' }}>
<div>
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.9rem' }}>
{t('admin.weather.city_name', 'en')}
</label>
<input
type="text"
value={city.name}
onChange={(e) => updateCity(index, 'name', e.target.value)}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<h3 style={{ margin: '0 0 16px', color: C.text, fontSize: '16px' }}>Cities</h3>
<div style={{ marginBottom: '16px' }}>
{cities.map((city, index) => (
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px', padding: '8px', background: C.bg, borderRadius: '8px' }}>
<span style={{ flex: 1, color: C.text }}>{city.name} ({city.lat}, {city.lon})</span>
<button
onClick={() => removeCity(index)}
style={{ background: C.danger, color: 'white', border: 'none', borderRadius: '4px', padding: '4px 8px', cursor: 'pointer', fontSize: '12px' }}
>
Remove
</button>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.9rem' }}>
{t('admin.weather.latitude', 'en')}
</label>
<input
type="number"
step="0.001"
value={city.lat}
onChange={(e) => updateCity(index, 'lat', parseFloat(e.target.value) || 0)}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', width: '100px' }}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.9rem' }}>
{t('admin.weather.longitude', 'en')}
</label>
<input
type="number"
step="0.001"
value={city.lon}
onChange={(e) => updateCity(index, 'lon', parseFloat(e.target.value) || 0)}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', width: '100px' }}
/>
</div>
<button
onClick={() => removeCity(index)}
style={{ padding: '0.5rem 1rem', background: '#ff4444', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
>
×
</button>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', padding: '16px', background: C.bg, borderRadius: '8px' }}>
<h4 style={{ margin: '0 0 8px', color: C.text, fontSize: '14px' }}>Add City</h4>
<I label="City Name" value={newCity.name} onChange={(v: string) => setNewCity({ ...newCity, name: v })} />
<div style={{ display: 'flex', gap: '12px' }}>
<I label="Latitude" value={newCity.lat} onChange={(v: string) => setNewCity({ ...newCity, lat: v })} />
<I label="Longitude" value={newCity.lon} onChange={(v: string) => setNewCity({ ...newCity, lon: v })} />
</div>
))}
<button
onClick={addCity}
style={{ padding: '0.5rem 1rem', background: '#0070f3', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
>
{t('admin.weather.add_city', 'en')}
</button>
</div>
<button
onClick={addCity}
style={{ alignSelf: 'flex-start', background: C.gold, color: 'white', border: 'none', borderRadius: '6px', padding: '8px 16px', cursor: 'pointer', fontWeight: 500 }}
>
Add City
</button>
</div>
<button
onClick={saveConfig}
style={{ padding: '0.75rem 1.5rem', background: '#0070f3', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '1rem' }}
>
{t('admin.weather.save', 'en')}
</button>
<div style={{ marginTop: '24px' }}>
<button
onClick={saveConfig}
style={{ background: C.gold, color: 'white', border: 'none', borderRadius: '6px', padding: '12px 24px', cursor: 'pointer', fontWeight: 600, fontSize: '14px' }}
>
Save Settings
</button>
</div>
</div>
</div>
);
}
}
+5 -1
View File
@@ -2,9 +2,12 @@ import { Pool } from 'pg';
function createPool() {
const connectionString = process.env.DATABASE_URL;
const isBuildPhase = typeof process.env.NEXT_PHASE === 'string' && process.env.NEXT_PHASE.includes('phase-production-build');
if (!connectionString) {
if (typeof process.env.NEXT_PHASE === 'string' && process.env.NEXT_PHASE.includes('phase-production-build')) {
if (isBuildPhase) {
// Build-time fallback only: keeps static rendering and type generation from crashing.
// Runtime imports should still fail fast when DATABASE_URL is missing.
return {
query: async () => {
throw new Error('DATABASE_URL environment variable is not set');
@@ -17,6 +20,7 @@ function createPool() {
removeListener: () => {},
} as unknown as Pool;
}
throw new Error('DATABASE_URL environment variable is not set');
}
+68 -45
View File
@@ -1,12 +1,17 @@
// Simple translation utility for La Huasca website
// Translation utility for La Huasca.
// Keep it simple, but structured enough to scale without scattering key lookups.
import { useSyncExternalStore } from 'react';
// Define languages
export type Language = 'en' | 'es';
export const DEFAULT_LANGUAGE: Language = 'en';
export const supportedLanguages: Language[] = ['en', 'es'];
// Translation data
export const translations = {
const LANGUAGE_STORAGE_KEY = 'language';
const LANGUAGE_CHANGE_EVENT = 'languagechange';
export const translations: Record<Language, Record<string, string>> = {
en: {
// Common
'common.hotel': 'Hotel',
'common.restaurant': 'Restaurant',
'common.andean_highlands': 'Andean Highlands',
@@ -18,8 +23,9 @@ export const translations = {
'common.user_portal': 'User Portal',
'common.login': 'Login',
'common.logout': 'Logout',
// Navigation
'common.language': 'Language',
'common.home_tagline': 'Hotel · Restaurant · Andean Highlands',
'common.home_subtitle': 'A mountain retreat with warm hospitality and local flavor.',
'nav.brand': 'Brand',
'nav.gallery': 'Images',
'nav.slideshow': 'Slideshow',
@@ -31,8 +37,6 @@ export const translations = {
'nav.reviews': 'Reviews',
'nav.users': 'Users',
'nav.weather': 'Weather',
// Weather widget
'weather.currently': 'Currently',
'weather.today': 'Today',
'weather.max': 'Max',
@@ -40,8 +44,6 @@ export const translations = {
'weather.wind': 'Wind',
'weather.kmh': 'km/h',
'weather_updated': 'Updated',
// Admin panel
'admin.brand': 'Brand',
'admin.gallery': 'Images',
'admin.slideshow': 'Slideshow',
@@ -53,8 +55,6 @@ export const translations = {
'admin.reviews': 'Reviews',
'admin.users': 'Users',
'admin.weather': 'Weather',
// Weather admin
'admin.weather.title': 'Weather Widget',
'admin.weather.description': 'Configure the weather widget displayed on the homepage',
'admin.weather.enabled': 'Enable Weather Widget',
@@ -66,7 +66,6 @@ export const translations = {
'admin.weather.save': 'Save Settings',
},
es: {
// Common
'common.hotel': 'Hotel',
'common.restaurant': 'Restaurante',
'common.andean_highlands': 'Tierras Altas Andinas',
@@ -78,8 +77,9 @@ export const translations = {
'common.user_portal': 'Portal de Usuario',
'common.login': 'Iniciar Sesión',
'common.logout': 'Cerrar Sesión',
// Navigation
'common.language': 'Idioma',
'common.home_tagline': 'Hotel · Restaurante · Tierras Altas Andinas',
'common.home_subtitle': 'Un refugio de montaña con hospitalidad cálida y sabor local.',
'nav.brand': 'Marca',
'nav.gallery': 'Imágenes',
'nav.slideshow': 'Presentación',
@@ -91,8 +91,6 @@ export const translations = {
'nav.reviews': 'Reseñas',
'nav.users': 'Usuarios',
'nav.weather': 'Clima',
// Weather widget
'weather.currently': 'Actualmente',
'weather.today': 'Hoy',
'weather.max': 'Máx',
@@ -100,8 +98,6 @@ export const translations = {
'weather.wind': 'Viento',
'weather.kmh': 'km/h',
'weather_updated': 'Actualizado',
// Admin panel
'admin.brand': 'Marca',
'admin.gallery': 'Imágenes',
'admin.slideshow': 'Presentación',
@@ -113,8 +109,6 @@ export const translations = {
'admin.reviews': 'Reseñas',
'admin.users': 'Usuarios',
'admin.weather': 'Clima',
// Weather admin
'admin.weather.title': 'Widget del Clima',
'admin.weather.description': 'Configurar el widget del clima mostrado en la página de inicio',
'admin.weather.enabled': 'Habilitar Widget del Clima',
@@ -124,36 +118,65 @@ export const translations = {
'admin.weather.latitude': 'Latitud',
'admin.weather.longitude': 'Longitud',
'admin.weather.save': 'Guardar Configuración',
}
},
};
// Simple translation function
export function t(key: string, language: Language = 'en'): string {
if (language === 'es' && key in translations.es) {
return translations.es[key as keyof typeof translations.es];
}
if (key in translations.en) {
return translations.en[key as keyof typeof translations.en];
}
function normalizeLanguage(language?: string | null): Language {
return language === 'es' ? 'es' : DEFAULT_LANGUAGE;
}
export function t(key: string, language: Language = DEFAULT_LANGUAGE): string {
const current = translations[language]?.[key];
if (current) return current;
const fallback = translations[DEFAULT_LANGUAGE]?.[key];
if (fallback) return fallback;
return key;
}
// Get language from localStorage or default to 'en'
export function getLanguage(): Language {
if (typeof window !== 'undefined') {
const savedLang = localStorage.getItem('language');
if (savedLang && (savedLang === 'en' || savedLang === 'es')) {
return savedLang as Language;
}
if (typeof window === 'undefined') {
return DEFAULT_LANGUAGE;
}
try {
return normalizeLanguage(localStorage.getItem(LANGUAGE_STORAGE_KEY));
} catch {
return DEFAULT_LANGUAGE;
}
return 'en';
}
// Set language in localStorage
export function setLanguage(lang: Language) {
if (typeof window !== 'undefined') {
localStorage.setItem('language', lang);
}
}
if (typeof window === 'undefined') return;
localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
window.dispatchEvent(new Event(LANGUAGE_CHANGE_EVENT));
}
export function subscribeLanguage(callback: () => void) {
if (typeof window === 'undefined') return () => {};
const onLanguageChange = () => callback();
const onStorage = (event: StorageEvent) => {
if (event.key === LANGUAGE_STORAGE_KEY) {
callback();
}
};
window.addEventListener(LANGUAGE_CHANGE_EVENT, onLanguageChange);
window.addEventListener('storage', onStorage);
return () => {
window.removeEventListener(LANGUAGE_CHANGE_EVENT, onLanguageChange);
window.removeEventListener('storage', onStorage);
};
}
export function useLanguage(): Language {
return useSyncExternalStore(subscribeLanguage, getLanguage, () => DEFAULT_LANGUAGE);
}
export function getFallbackLanguage(language: Language): Language {
return language === 'es' ? 'en' : 'es';
}
+19 -8
View File
@@ -257,14 +257,25 @@ export async function seed() {
throw new Error('Seeding is disabled in production');
}
const adminPassword = process.env.BOOTSTRAP_ADMIN_PASSWORD;
const userPassword = process.env.BOOTSTRAP_USER_PASSWORD;
const wifiPassword = process.env.BOOTSTRAP_WIFI_PASSWORD;
const tagline = process.env.BOOTSTRAP_TAGLINE || 'Hotel · Restaurant · Andean Highlands';
const { rows: adminRows } = await db.query(`SELECT id FROM users WHERE username = 'admin'`);
if (adminRows.length === 0) {
const adminHash = bcrypt.hashSync('admin', 10);
const userHash = bcrypt.hashSync('user', 10);
if (adminRows.length === 0 && adminPassword) {
const adminHash = bcrypt.hashSync(adminPassword, 10);
const seedRows: Array<[string, string, string]> = [['admin', adminHash, 'admin']];
if (userPassword) {
seedRows.push(['user', bcrypt.hashSync(userPassword, 10), 'user']);
}
await db.query(
`INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3), ($4, $5, $6)`,
['admin', adminHash, 'admin', 'user', userHash, 'user']
`INSERT INTO users (username, password_hash, role) VALUES ${seedRows
.map((_, index) => `($${index * 3 + 1}, $${index * 3 + 2}, $${index * 3 + 3})`)
.join(', ')}`,
seedRows.flat()
);
}
@@ -295,13 +306,13 @@ export async function seed() {
}
const { rows: configWifiRows } = await db.query(`SELECT key FROM config WHERE key = 'wifi_password'`);
if (configWifiRows.length === 0) {
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['wifi_password', 'LaHuascaGuest2026']);
if (configWifiRows.length === 0 && wifiPassword) {
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['wifi_password', wifiPassword]);
}
const { rows: configTaglineRows } = await db.query(`SELECT key FROM config WHERE key = 'tagline'`);
if (configTaglineRows.length === 0) {
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['tagline', 'Hotel · Restaurant · Andean Highlands']);
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['tagline', tagline]);
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Simple middleware for Next.js Edge Runtime
// This is a basic implementation that doesn't verify tokens
// In a production environment, you would want to verify the token
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Protect admin routes
if (pathname.startsWith('/admin')) {
// Get session token from cookies
const token = request.cookies.get('session')?.value;
// If no token, redirect to login
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
// Note: In a real implementation, you would verify the token here
// For now, we're just checking that a token exists
}
// Redirect authenticated users from login page to home
if (pathname === '/login') {
const token = request.cookies.get('session')?.value;
if (token) {
// In a real implementation, you would verify the token and check role
// For now, we'll just redirect to home
return NextResponse.redirect(new URL('/', request.url));
}
}
return NextResponse.next();
}
// Configure which paths the middleware should run on
export const config = {
matcher: ['/admin/:path*', '/login'],
};