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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
130
client/src/components/Layout/DemoBanner.jsx
Normal file
130
client/src/components/Layout/DemoBanner.jsx
Normal 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' }}>·</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 →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user