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
|
||||
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