Add login, role-based portals, and browser-local admin image upload
This commit is contained in:
@@ -24,3 +24,20 @@ src/app/page.tsx
|
|||||||
src/components/MeasuredText.tsx
|
src/components/MeasuredText.tsx
|
||||||
README.md
|
README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
The app now includes simple browser-local authentication with two hardcoded accounts:
|
||||||
|
|
||||||
|
- `admin` / `admin` — Admin portal (`/admin`)
|
||||||
|
- `user` / `user` — User portal (`/user`)
|
||||||
|
|
||||||
|
The logged-in role is stored in `localStorage`. The **Login / Logout** control lives in the Navbar.
|
||||||
|
|
||||||
|
## Admin portal
|
||||||
|
|
||||||
|
Visit `/admin` after logging in as `admin` to upload images. Images are previewed in a gallery and can be deleted individually. For now, uploaded images are stored as base64 strings in the browser's `localStorage` under the key `la-huasca-admin-images` — they are not persisted on a server.
|
||||||
|
|
||||||
|
## User portal
|
||||||
|
|
||||||
|
Visit `/user` after logging in as `user` to see a member portal with mock reservations, discount coupons, offers, the WiFi password, and a list of benefits.
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
|
||||||
|
const IMAGES_KEY = 'la-huasca-admin-images';
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const { isAdmin, hydrated } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const [images, setImages] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydrated) return;
|
||||||
|
if (!isAdmin) {
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stored = localStorage.getItem(IMAGES_KEY);
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
setImages(JSON.parse(stored));
|
||||||
|
} catch {
|
||||||
|
setImages([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isAdmin, hydrated, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(IMAGES_KEY, JSON.stringify(images));
|
||||||
|
}, [images]);
|
||||||
|
|
||||||
|
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files) return;
|
||||||
|
Array.from(files).forEach((file) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string;
|
||||||
|
setImages((prev) => [...prev, result]);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (index: number) => {
|
||||||
|
setImages((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!hydrated || !isAdmin) {
|
||||||
|
return (
|
||||||
|
<main style={{ padding: '2rem' }}>
|
||||||
|
<p>Please log in as admin to access this page.</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ padding: '2rem' }}>
|
||||||
|
<h1>Admin Portal</h1>
|
||||||
|
<p>Total uploaded images: <strong>{images.length}</strong></p>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
background: '#1a1a1a',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Upload images
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
onChange={handleUpload}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{images.length === 0 ? (
|
||||||
|
<p style={{ color: 'dimgray' }}>No images uploaded yet.</p>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{images.map((src, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={`Uploaded preview ${i + 1}`}
|
||||||
|
style={{ width: '100%', height: '160px', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(i)}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem',
|
||||||
|
background: 'crimson',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { login } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
const ok = login(username, password);
|
||||||
|
if (ok) {
|
||||||
|
router.push(username === 'admin' ? '/admin' : '/user');
|
||||||
|
} else {
|
||||||
|
setError('Invalid username or password.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ padding: '2rem', maxWidth: '28rem', margin: '0 auto' }}>
|
||||||
|
<h1 style={{ marginBottom: '1rem' }}>Login</h1>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1rem',
|
||||||
|
padding: '1.5rem',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||||
|
Username
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
style={{ padding: '0.5rem', borderRadius: '4px', border: '1px solid #ccc' }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
style={{ padding: '0.5rem', borderRadius: '4px', border: '1px solid #ccc' }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p style={{ color: 'crimson', margin: 0 }}>{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem',
|
||||||
|
background: '#1a1a1a',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p style={{ marginTop: '1rem', color: 'dimgray' }}>
|
||||||
|
Demo accounts: <strong>admin</strong> / admin and <strong>user</strong> / user.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
|
||||||
|
const reservations = [
|
||||||
|
{ id: 1, date: '2026-07-12', time: '19:00', guests: 4, table: 'Terrace 3' },
|
||||||
|
{ id: 2, date: '2026-07-25', time: '20:30', guests: 2, table: 'Barrel 7' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const coupons = [
|
||||||
|
{ code: 'WELCOME20', discount: '20% off your next stay' },
|
||||||
|
{ code: 'COFFEE5', discount: '$5 off any coffee tasting' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const offers = [
|
||||||
|
{ title: 'Summer Wine Weekend', desc: 'Complimentary wine tasting with every two-night stay.' },
|
||||||
|
{ title: 'Sunset Dinner', desc: 'Three-course menu on the terrace every Friday.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const benefits = [
|
||||||
|
'Priority booking for peak weekends',
|
||||||
|
'Late checkout on availability',
|
||||||
|
'Free welcome cocktail',
|
||||||
|
'Member-only events and tastings',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function UserPage() {
|
||||||
|
const { isUser, hydrated } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydrated) return;
|
||||||
|
if (!isUser) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}, [isUser, hydrated, router]);
|
||||||
|
|
||||||
|
if (!hydrated || !isUser) {
|
||||||
|
return (
|
||||||
|
<main style={{ padding: '2rem' }}>
|
||||||
|
<p>Please log in as user to access this page.</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ padding: '2rem', maxWidth: '48rem', margin: '0 auto' }}>
|
||||||
|
<h1>User Portal</h1>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: '2rem' }}>
|
||||||
|
<h2>Future Reservations</h2>
|
||||||
|
{reservations.length === 0 ? (
|
||||||
|
<p style={{ color: 'dimgray' }}>No upcoming reservations.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ padding: 0, listStyle: 'none' }}>
|
||||||
|
{reservations.map((r) => (
|
||||||
|
<li
|
||||||
|
key={r.id}
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.date} at {r.time} · {r.guests} guests · {r.table}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: '2rem' }}>
|
||||||
|
<h2>Discount Coupons</h2>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
{coupons.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.code}
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
border: '2px dashed #1a1a1a',
|
||||||
|
borderRadius: '8px',
|
||||||
|
minWidth: '12rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{c.code}</strong>
|
||||||
|
<p style={{ margin: '0.5rem 0 0', color: 'dimgray' }}>{c.discount}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: '2rem' }}>
|
||||||
|
<h2>Offers</h2>
|
||||||
|
<ul>
|
||||||
|
{offers.map((o) => (
|
||||||
|
<li key={o.title}>
|
||||||
|
<strong>{o.title}</strong> — {o.desc}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: '2rem' }}>
|
||||||
|
<h2>WiFi Password</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
background: '#f4f4f4',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
LaHuascaGuest2026
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Member Benefits</h2>
|
||||||
|
<ul>
|
||||||
|
{benefits.map((b) => (
|
||||||
|
<li key={b}>{b}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ href: '/', label: 'Home' },
|
||||||
|
{ href: '/rooms', label: 'Rooms' },
|
||||||
|
{ href: '/coffee', label: 'Coffee' },
|
||||||
|
{ href: '/landscape', label: 'Landscape' },
|
||||||
|
{ href: '/comments', label: 'Comments' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const { isLoggedIn, logout } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
router.push('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
display: 'flex',
|
||||||
|
gap: '1.5rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '1rem 2rem',
|
||||||
|
background: '#1a1a1a',
|
||||||
|
color: '#fff',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link href="/" style={{ fontWeight: 700, color: '#fff', textDecoration: 'none' }}>
|
||||||
|
La Huasca
|
||||||
|
</Link>
|
||||||
|
{links.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
style={{ color: '#e5e5e5', textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid #fff',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '0.4rem 0.75rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
color: '#e5e5e5',
|
||||||
|
textDecoration: 'none',
|
||||||
|
border: '1px solid #e5e5e5',
|
||||||
|
padding: '0.4rem 0.75rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const ROLE_KEY = 'la-huasca-role';
|
||||||
|
const IMAGES_KEY = 'la-huasca-admin-images';
|
||||||
|
|
||||||
|
type Role = 'admin' | 'user' | null;
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const [role, setRole] = useState<Role>(null);
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = typeof window !== 'undefined' ? localStorage.getItem(ROLE_KEY) : null;
|
||||||
|
setRole((stored as Role) ?? null);
|
||||||
|
setHydrated(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback((username: string, password: string): boolean => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
if (username === 'admin' && password === 'admin') {
|
||||||
|
localStorage.setItem(ROLE_KEY, 'admin');
|
||||||
|
setRole('admin');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (username === 'user' && password === 'user') {
|
||||||
|
localStorage.setItem(ROLE_KEY, 'user');
|
||||||
|
setRole('user');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.removeItem(ROLE_KEY);
|
||||||
|
localStorage.removeItem(IMAGES_KEY);
|
||||||
|
setRole(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isAdmin = role === 'admin';
|
||||||
|
const isUser = role === 'user';
|
||||||
|
const isLoggedIn = role !== null;
|
||||||
|
|
||||||
|
return { role, hydrated, login, logout, isAdmin, isUser, isLoggedIn };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user