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