Replace demo banner with dismissable popup modal

Shows once per session, no layout interference with navbar/map.
Uses sessionStorage so it reappears on next visit.
This commit is contained in:
Maurice
2026-03-19 14:09:12 +01:00
parent f856956428
commit da79059576
3 changed files with 90 additions and 130 deletions

View File

@@ -13,6 +13,7 @@ import SettingsPage from './pages/SettingsPage'
import { ToastContainer } from './components/shared/Toast'
import { TranslationProvider } from './i18n'
import DemoBanner from './components/Layout/DemoBanner'
import { authApi } from './api/client'
function ProtectedRoute({ children, adminRequired = false }) {
const { isAuthenticated, user, isLoading } = useAuthStore()
@@ -61,12 +62,9 @@ export default function App() {
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(() => {})
})
authApi.getAppConfig().then(config => {
if (config?.demo_mode) setDemoMode(true)
}).catch(() => {})
}, [])
const { settings } = useSettingsStore()

View File

@@ -1,16 +1,12 @@
import React, { useState, useEffect } from 'react'
import { Info, Github, Shield, Key, Users, Database, ChevronDown, ChevronUp } from 'lucide-react'
import React, { useState } from 'react'
import { Info, Github, Shield, Key, Users, Database, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
const texts = {
de: {
title: 'Demo-Modus',
resetInfo: 'Aenderungen werden stuendlich zurueckgesetzt',
nextReset: 'naechster Reset in ~{min} Min.',
moreInfo: 'Mehr Info',
lessInfo: 'Weniger',
description: 'Du nutzt die NOMAD Demo. Du kannst Reisen ansehen, bearbeiten und eigene erstellen — alles wird jede Stunde automatisch zurueckgesetzt.',
fullVersionTitle: 'Diese Funktionen sind in der Vollversion verfuegbar:',
title: 'Willkommen zur NOMAD Demo',
description: 'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
fullVersionTitle: 'In der Vollversion zusaetzlich verfuegbar:',
features: [
'API-Schluessel verwalten (Google Maps, Wetter)',
'Benutzer & Rechte verwalten',
@@ -19,15 +15,12 @@ const texts = {
],
selfHost: 'NOMAD ist Open Source — ',
selfHostLink: 'selbst hosten',
close: 'Verstanden',
},
en: {
title: 'Demo Mode',
resetInfo: 'Changes are reset every hour',
nextReset: 'next reset in ~{min} min.',
moreInfo: 'More info',
lessInfo: 'Less',
description: 'You are using the NOMAD demo. You can view, edit and create trips — everything is automatically reset every hour.',
fullVersionTitle: 'These features are available in the full version:',
title: 'Welcome to the NOMAD Demo',
description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
fullVersionTitle: 'Additionally available in the full version:',
features: [
'API key management (Google Maps, Weather)',
'User & permission management',
@@ -36,128 +29,93 @@ const texts = {
],
selfHost: 'NOMAD is open source — ',
selfHostLink: 'self-host it',
close: 'Got it',
},
}
const featureIcons = [Key, Users, Database, Shield]
export default function DemoBanner() {
const [expanded, setExpanded] = useState(false)
const [minutesLeft, setMinutesLeft] = useState(null)
const [dismissed, setDismissed] = useState(() => sessionStorage.getItem('demo_dismissed') === 'true')
const { language } = useTranslation()
const t = texts[language] || texts.en
useEffect(() => {
const update = () => setMinutesLeft(59 - new Date().getMinutes())
update()
const interval = setInterval(update, 30000)
return () => clearInterval(interval)
}, [])
if (dismissed) return null
const bannerHeight = expanded ? undefined : 36
const handleClose = () => {
sessionStorage.setItem('demo_dismissed', 'true')
setDismissed(true)
}
return (
<div style={{ background: 'linear-gradient(135deg, #f59e0b, #d97706)', zIndex: 300 }}>
{/* Main banner bar */}
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}} onClick={handleClose}>
<div style={{
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,
}}>
<Info size={15} style={{ flexShrink: 0 }} />
<span>
{t.title}
<span style={{ fontWeight: 400, margin: '0 6px' }}>&middot;</span>
{t.resetInfo}
{minutesLeft !== null && (
<span style={{ fontWeight: 400, opacity: 0.8, marginLeft: 4 }}>
({t.nextReset.replace('{min}', minutesLeft)})
</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 ? t.lessInfo : t.moreInfo}
</button>
</div>
background: 'white', borderRadius: 20, padding: '32px 28px 24px',
maxWidth: 440, width: '100%',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}} onClick={e => e.stopPropagation()}>
{/* 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",
}}>
<div style={{ maxWidth: 640, margin: '0 auto' }}>
<p style={{ fontSize: 13, color: '#92400e', margin: '0 0 12px', lineHeight: 1.6 }}>
{t.description}
</p>
<p style={{ fontSize: 12, fontWeight: 700, color: '#78350f', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t.fullVersionTitle}
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: 6 }}>
{t.features.map((text, i) => {
const Icon = featureIcons[i]
return (
<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>{t.selfHost}</span>
<a
href="https://github.com/mauriceboe/NOMAD"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#78350f', fontWeight: 700, textDecoration: 'underline' }}
>
{t.selfHostLink} &rarr;
</a>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<div style={{
width: 36, height: 36, borderRadius: 10,
background: 'linear-gradient(135deg, #f59e0b, #d97706)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Info size={20} style={{ color: 'white' }} />
</div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>
{t.title}
</h2>
</div>
)}
<p style={{ fontSize: 14, color: '#6b7280', lineHeight: 1.6, margin: '0 0 20px' }}>
{t.description}
</p>
<p style={{ fontSize: 12, fontWeight: 700, color: '#374151', margin: '0 0 10px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t.fullVersionTitle}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 20 }}>
{t.features.map((text, i) => {
const Icon = featureIcons[i]
return (
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, color: '#4b5563' }}>
<Icon size={15} style={{ flexShrink: 0, color: '#d97706' }} />
<span>{text}</span>
</div>
)
})}
</div>
<div style={{
paddingTop: 16, borderTop: '1px solid #e5e7eb',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#9ca3af' }}>
<Github size={14} />
<span>{t.selfHost}</span>
<a href="https://github.com/mauriceboe/NOMAD" target="_blank" rel="noopener noreferrer"
style={{ color: '#d97706', fontWeight: 600, textDecoration: 'none' }}>
{t.selfHostLink}
</a>
</div>
<button onClick={handleClose} style={{
background: '#111827', color: 'white', border: 'none',
borderRadius: 10, padding: '8px 20px', fontSize: 13,
fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}>
{t.close}
</button>
</div>
</div>
</div>
)
}

View File

@@ -8,7 +8,7 @@ export const useAuthStore = create((set, get) => ({
isAuthenticated: !!localStorage.getItem('auth_token'),
isLoading: false,
error: null,
demoMode: false,
demoMode: localStorage.getItem('demo_mode') === 'true',
login: async (email, password) => {
set({ isLoading: true, error: null })
@@ -131,7 +131,11 @@ export const useAuthStore = create((set, get) => ({
set(state => ({ user: { ...state.user, avatar_url: null } }))
},
setDemoMode: (val) => set({ demoMode: val }),
setDemoMode: (val) => {
if (val) localStorage.setItem('demo_mode', 'true')
else localStorage.removeItem('demo_mode')
set({ demoMode: val })
},
demoLogin: async () => {
set({ isLoading: true, error: null })