Add demo mode with hourly reset, example trips & demo banner

DEMO_MODE=true enables: auto-seeded admin + demo user, 3 example trips
(Tokyo, Barcelona, Wien), hourly reset of demo user data, one-click
demo login, visible banner with feature info. Zero behavior change
when DEMO_MODE is not set.
This commit is contained in:
Maurice
2026-03-19 13:25:37 +01:00
parent f8dcce802e
commit e8acbbd129
13 changed files with 558 additions and 5 deletions

View File

@@ -12,6 +12,7 @@ import AdminPage from './pages/AdminPage'
import SettingsPage from './pages/SettingsPage'
import { ToastContainer } from './components/shared/Toast'
import { TranslationProvider } from './i18n'
import DemoBanner from './components/Layout/DemoBanner'
function ProtectedRoute({ children, adminRequired = false }) {
const { isAuthenticated, user, isLoading } = useAuthStore()
@@ -53,13 +54,19 @@ function RootRedirect() {
}
export default function App() {
const { loadUser, token, isAuthenticated } = useAuthStore()
const { loadUser, token, isAuthenticated, demoMode, setDemoMode } = useAuthStore()
const { loadSettings } = useSettingsStore()
useEffect(() => {
if (token) {
loadUser()
}
// Check if demo mode is active
import('./api/client').then(({ authApi }) => {
authApi.getAppConfig?.().then(config => {
if (config?.demo_mode) setDemoMode(true)
}).catch(() => {})
})
}, [])
const { settings } = useSettingsStore()
@@ -82,6 +89,8 @@ export default function App() {
return (
<TranslationProvider>
<ToastContainer />
{demoMode && isAuthenticated && <DemoBanner />}
<div style={demoMode && isAuthenticated ? { paddingTop: 36 } : undefined}>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} />
@@ -128,6 +137,7 @@ export default function App() {
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
</TranslationProvider>
)
}

View File

@@ -53,6 +53,7 @@ export const authApi = {
updateAppSettings: (data) => apiClient.put('/auth/app-settings', data).then(r => r.data),
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
}
export const tripsApi = {

View File

@@ -0,0 +1,130 @@
import React, { useState, useEffect } from 'react'
import { Info, X, Github, Shield, Key, Users, Database, ChevronDown, ChevronUp } from 'lucide-react'
export default function DemoBanner() {
const [expanded, setExpanded] = useState(false)
const [minutesLeft, setMinutesLeft] = useState(null)
useEffect(() => {
const update = () => {
const now = new Date()
setMinutesLeft(59 - now.getMinutes())
}
update()
const interval = setInterval(update, 30000)
return () => clearInterval(interval)
}, [])
const adminFeatures = [
{ icon: Key, text: 'API-Schluessel verwalten (Google Maps, Wetter)' },
{ icon: Users, text: 'Benutzer & Rechte verwalten' },
{ icon: Database, text: 'Automatische Backups & Wiederherstellung' },
{ icon: Shield, text: 'Registrierung & Sicherheitseinstellungen' },
]
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, zIndex: 300 }}>
{/* Main banner bar */}
<div style={{
background: 'linear-gradient(135deg, #f59e0b, #d97706)',
color: '#451a03',
padding: '8px 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
fontSize: 13,
fontWeight: 600,
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
minHeight: 36,
boxShadow: '0 2px 8px rgba(217, 119, 6, 0.3)',
}}>
<Info size={15} style={{ flexShrink: 0 }} />
<span>
Demo-Modus
<span style={{ fontWeight: 400, margin: '0 6px' }}>&middot;</span>
Aenderungen werden stuendlich zurueckgesetzt
{minutesLeft !== null && (
<span style={{ fontWeight: 400, opacity: 0.8, marginLeft: 4 }}>
(naechster Reset in ~{minutesLeft} Min.)
</span>
)}
</span>
<button
onClick={() => setExpanded(!expanded)}
style={{
background: 'rgba(69, 26, 3, 0.15)',
border: 'none',
borderRadius: 6,
padding: '3px 8px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 4,
color: '#451a03',
fontSize: 12,
fontWeight: 600,
fontFamily: 'inherit',
marginLeft: 4,
}}
>
{expanded ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
{expanded ? 'Weniger' : 'Mehr Info'}
</button>
</div>
{/* Expanded info panel */}
{expanded && (
<div style={{
background: '#fffbeb',
borderBottom: '1px solid #fbbf24',
padding: '16px 24px',
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
}}>
<div style={{ maxWidth: 640, margin: '0 auto' }}>
<p style={{ fontSize: 13, color: '#92400e', margin: '0 0 12px', lineHeight: 1.6 }}>
Du nutzt die NOMAD Demo. Du kannst Reisen ansehen, bearbeiten und eigene erstellen
alles wird <strong>jede Stunde automatisch zurueckgesetzt</strong>.
</p>
<p style={{ fontSize: 12, fontWeight: 700, color: '#78350f', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Diese Funktionen sind in der Vollversion verfuegbar:
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: 6 }}>
{adminFeatures.map(({ icon: Icon, text }) => (
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: '#92400e' }}>
<Icon size={14} style={{ flexShrink: 0, opacity: 0.7 }} />
<span>{text}</span>
</div>
))}
</div>
<div style={{
marginTop: 14,
paddingTop: 12,
borderTop: '1px solid #fde68a',
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 12,
color: '#92400e',
}}>
<Github size={14} />
<span>NOMAD ist Open Source </span>
<a
href="https://github.com/mauriceboe/NOMAD"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#78350f', fontWeight: 700, textDecoration: 'underline' }}
>
selbst hosten &rarr;
</a>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -17,7 +17,7 @@ export default function LoginPage() {
const [error, setError] = useState('')
const [appConfig, setAppConfig] = useState(null)
const { login, register } = useAuthStore()
const { login, register, demoLogin } = useAuthStore()
const { setLanguageLocal } = useSettingsStore()
const navigate = useNavigate()
@@ -30,6 +30,19 @@ export default function LoginPage() {
})
}, [])
const handleDemoLogin = async () => {
setError('')
setIsLoading(true)
try {
await demoLogin()
navigate('/dashboard')
} catch (err) {
setError(err.message || 'Demo-Login fehlgeschlagen')
} finally {
setIsLoading(false)
}
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
@@ -315,7 +328,7 @@ export default function LoginPage() {
</form>
{/* Toggle login/register */}
{showRegisterOption && appConfig?.has_users && (
{showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && (
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: '#9ca3af' }}>
{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '}
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError('') }}
@@ -325,6 +338,26 @@ export default function LoginPage() {
</p>
)}
</div>
{/* Demo login button */}
{appConfig?.demo_mode && (
<button onClick={handleDemoLogin} disabled={isLoading}
style={{
marginTop: 16, width: '100%', padding: '14px',
background: 'linear-gradient(135deg, #f59e0b, #d97706)',
color: '#451a03', border: 'none', borderRadius: 14,
fontSize: 15, fontWeight: 700, cursor: isLoading ? 'default' : 'pointer',
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
opacity: isLoading ? 0.7 : 1, transition: 'all 0.2s',
boxShadow: '0 2px 12px rgba(245, 158, 11, 0.3)',
}}
onMouseEnter={e => { if (!isLoading) e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 4px 16px rgba(245, 158, 11, 0.4)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 12px rgba(245, 158, 11, 0.3)' }}
>
<Plane size={18} />
Demo ausprobieren ohne Registrierung
</button>
)}
</div>
</div>

View File

@@ -8,6 +8,7 @@ export const useAuthStore = create((set, get) => ({
isAuthenticated: !!localStorage.getItem('auth_token'),
isLoading: false,
error: null,
demoMode: false,
login: async (email, password) => {
set({ isLoading: true, error: null })
@@ -129,4 +130,28 @@ export const useAuthStore = create((set, get) => ({
await authApi.deleteAvatar()
set(state => ({ user: { ...state.user, avatar_url: null } }))
},
setDemoMode: (val) => set({ demoMode: val }),
demoLogin: async () => {
set({ isLoading: true, error: null })
try {
const data = await authApi.demoLogin()
localStorage.setItem('auth_token', data.token)
set({
user: data.user,
token: data.token,
isAuthenticated: true,
isLoading: false,
demoMode: true,
error: null,
})
connect(data.token)
return data
} catch (err) {
const error = err.response?.data?.error || 'Demo-Login fehlgeschlagen'
set({ isLoading: false, error })
throw new Error(error)
}
},
}))