feat: harden bootstrap and add tests
This commit is contained in:
+5
-1
@@ -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
@@ -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
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user