onboarding modal

This commit is contained in:
2026-04-20 18:07:56 +03:00
parent 08a5c2e560
commit 17a3a89865
+161
View File
@@ -0,0 +1,161 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { motion } from 'motion/react'
import { IconCookieFilled, IconKeyFilled, IconDeviceDesktopFilled, IconArrowRight, IconUpload, IconUserFilled } from '@tabler/icons-react'
import * as api from '../api'
import { useStore } from '../store'
import { useFocusTrap } from '../hooks/useFocusTrap'
const LIFETIME_KEYS = [
{ value: 0, key: 'settings.session.thisSession' },
{ value: 86400, key: 'settings.session.oneDay' },
{ value: 604800, key: 'settings.session.oneWeek' },
{ value: 2592000, key: 'settings.session.thirtyDays' },
{ value: 7776000, key: 'settings.session.ninetyDays' },
{ value: 31536000, key: 'settings.session.oneYear' },
]
export default function OnboardingModal() {
const trapRef = useFocusTrap(true)
const { t } = useTranslation()
const setShowOnboarding = useStore(s => s.setShowOnboarding)
const [lifetime, setLifetime] = useState(2592000)
const [username, setUsername] = useState('')
const [registering, setRegistering] = useState(false)
const [passkeyDone, setPasskeyDone] = useState(false)
const [error, setError] = useState('')
const [importResult, setImportResult] = useState('')
const [importing, setImporting] = useState(false)
const supportsPasskey = !!window.PublicKeyCredential
const dismiss = async () => {
const settings = await api.fetchSettings()
await api.saveSettings({ ...settings, cookieLifetime: lifetime })
if (username.trim()) {
await api.updateMe({ display_name: username.trim() })
}
setShowOnboarding(false)
}
const setupPasskey = async () => {
setRegistering(true)
setError('')
try {
await api.registerPasskey()
setPasskeyDone(true)
} catch (e) {
setError(e instanceof Error ? e.message : t('onboarding.passkeyFailed'))
}
setRegistering(false)
}
return (
<motion.div className="settings-overlay"
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
<motion.div ref={trapRef} className="onboarding-panel"
role="dialog" aria-modal="true" aria-label={t('onboarding.welcomeTo')}
initial={{ opacity: 0, scale: 0.96, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 10 }}
transition={{ duration: 0.25 }}>
<h2>{t('onboarding.welcomeTo')}</h2>
<div className="onboard-section">
<div className="onboard-icon"><IconUserFilled size={20} /></div>
<div>
<h4>{t('onboarding.chooseName')}</h4>
<p>{t('onboarding.chooseNameDesc')}</p>
</div>
</div>
<input
type="text"
className="onboard-username"
placeholder={t('common.anonymous')}
value={username}
onChange={e => setUsername(e.target.value)}
maxLength={30}
autoComplete="nickname"
/>
<div className="onboard-divider" />
<div className="onboard-section">
<div className="onboard-icon"><IconCookieFilled size={20} /></div>
<div>
<h4>{t('onboarding.progressSaved')}</h4>
<p>{t('onboarding.progressSavedDesc')}</p>
</div>
</div>
<div className="onboard-section">
<label className="settings-label">{t('onboarding.rememberFor')}</label>
<div className="settings-grid-3">
{LIFETIME_KEYS.map(opt => (
<button
key={opt.value}
className={`btn btn-sm ${lifetime === opt.value ? 'btn-primary' : ''}`}
onClick={() => setLifetime(opt.value)}
>
{t(opt.key)}
</button>
))}
</div>
</div>
<div className="onboard-divider" />
<div className="onboard-section">
<div className="onboard-icon"><IconDeviceDesktopFilled size={20} /></div>
<div>
<h4>{t('onboarding.playAcrossDevices')}</h4>
<p>{t('onboarding.playAcrossDevicesDesc')}</p>
</div>
</div>
{supportsPasskey && !passkeyDone && (
<button className="btn btn-sm" onClick={setupPasskey} disabled={registering} style={{ marginBottom: 8 }}>
<IconKeyFilled size={14} style={{ marginRight: 4 }} />
{registering ? t('onboarding.settingUp') : t('onboarding.setupPasskey')}
</button>
)}
{passkeyDone && <span className="onboard-success" style={{ display: 'block', marginBottom: 8 }}>{t('onboarding.passkeySaved')}</span>}
{error && <div className="onboard-error" role="alert">{error}</div>}
<div className="onboard-divider" />
<div className="onboard-actions">
<button className="btn btn-sm onboard-import-btn" disabled={importing} onClick={() => {
const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'
input.onchange = async () => {
if (!input.files?.length) return
setImporting(true); setImportResult('')
try {
const data = JSON.parse(await input.files[0].text())
const res = await api.importData(data)
let msg = t('onboarding.imported', { count: res.imported })
if (res.skipped?.length) msg += ' ' + t('onboarding.skipped', { items: res.skipped.join(', ') })
setImportResult(msg)
} catch (e) { setImportResult(e instanceof Error ? e.message : t('onboarding.importFailed')) }
setImporting(false)
}; input.click()
}}>
<IconUpload size={14} style={{ marginRight: 6 }} />
<span className="onboard-import-text">
<span>{t('onboarding.comingFrom')}</span>
<span>{t('onboarding.anotherInstance')}</span>
</span>
</button>
<button className="btn btn-primary onboard-start-btn" onClick={dismiss}>
<span>{passkeyDone ? t('onboarding.done') : t('onboarding.startPlaying')}</span>
<IconArrowRight size={14} />
</button>
</div>
{importResult && <p className="settings-hint" style={{ marginTop: 8 }}>{importResult}</p>}
</motion.div>
</motion.div>
)
}