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
+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]);
}
}