shinkan-jinkendo/frontend/src/pages/OnboardingPage.jsx
Lars fa10450315
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m20s
Update Version and Enhance Club Creation Request Management
- Incremented application version to 0.8.192 and database schema version to 20260606081.
- Updated club module versions for 'clubs' and 'club_creation_requests' to reflect recent changes.
- Implemented logic to mark approved club creation requests as 'superseded' when the associated club is deleted.
- Refactored frontend components to clear session storage for coach-related keys upon logout and during login checks.
- Enhanced onboarding page to accurately display the status of club creation requests based on their validity.
2026-06-07 07:31:05 +02:00

384 lines
14 KiB
JavaScript

import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import EmailVerificationBanner from '../components/EmailVerificationBanner'
import { resolveAccountState } from '../utils/accountState'
const joinStatusLabel = (s) =>
({
pending: 'ausstehend',
accepted: 'angenommen',
rejected: 'abgelehnt',
withdrawn: 'zurückgezogen',
})[s] || s
const creationStatusLabel = (s) =>
({
pending: 'ausstehend',
approved: 'freigegeben',
rejected: 'abgelehnt',
withdrawn: 'zurückgezogen',
superseded: 'Verein entfernt',
})[s] || s
/** Freigabe noch gültig (Verein existiert). */
function isActiveApprovedCreation(req) {
return req.status === 'approved' && req.created_club_id
}
/**
* Onboarding für Nutzer ohne aktive Vereinsmitgliedschaft (Phase A).
*/
export default function OnboardingPage() {
const { user, checkAuth } = useAuth()
const [publicClubs, setPublicClubs] = useState([])
const [myJoinRequests, setMyJoinRequests] = useState([])
const [joinClubId, setJoinClubId] = useState('')
const [joinMessage, setJoinMessage] = useState('')
const [joinBusy, setJoinBusy] = useState(false)
const [myCreationRequests, setMyCreationRequests] = useState([])
const [createName, setCreateName] = useState('')
const [createAbbr, setCreateAbbr] = useState('')
const [createDesc, setCreateDesc] = useState('')
const [createMessage, setCreateMessage] = useState('')
const [createBusy, setCreateBusy] = useState(false)
const [error, setError] = useState('')
const [ok, setOk] = useState('')
const accountState = resolveAccountState(user)
const emailOk = accountState !== 'unverified'
const refreshJoinRequests = () => {
if (!emailOk) return
api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {})
}
const refreshCreationRequests = () => {
if (!emailOk) return
api.getMyClubCreationRequests().then(setMyCreationRequests).catch(() => {})
}
useEffect(() => {
api.listPublicClubsDirectory().then(setPublicClubs).catch(() => {})
refreshJoinRequests()
refreshCreationRequests()
}, [user?.id, emailOk])
const memberClubIds = new Set((user?.clubs || []).map((c) => c.id))
const pendingClubIds = new Set(
myJoinRequests.filter((r) => r.status === 'pending').map((r) => r.club_id)
)
const joinClubChoices = publicClubs.filter(
(c) => !memberClubIds.has(c.id) && !pendingClubIds.has(c.id)
)
const hasPendingCreation = myCreationRequests.some((r) => r.status === 'pending')
const handleCreateClub = async (e) => {
e.preventDefault()
setError('')
setOk('')
const name = (createName || '').trim()
if (!name) {
setError('Bitte einen Vereinsnamen angeben.')
return
}
setCreateBusy(true)
try {
await api.createClubCreationRequest({
proposed_name: name,
proposed_abbreviation: (createAbbr || '').trim() || undefined,
proposed_description: (createDesc || '').trim() || undefined,
message: (createMessage || '').trim() || undefined,
})
setCreateName('')
setCreateAbbr('')
setCreateDesc('')
setCreateMessage('')
refreshCreationRequests()
setOk(
'Gründungsantrag gesendet. Nach Freigabe durch den Plattform-Administrator wird dein Verein angelegt.'
)
} catch (err) {
setError(err.message || 'Antrag fehlgeschlagen.')
} finally {
setCreateBusy(false)
}
}
const handleJoin = async (e) => {
e.preventDefault()
setError('')
setOk('')
if (!joinClubId) {
setError('Bitte einen Verein auswählen.')
return
}
setJoinBusy(true)
try {
await api.createClubJoinRequest({
club_id: parseInt(joinClubId, 10),
message: (joinMessage || '').trim() || undefined,
})
setJoinMessage('')
setJoinClubId('')
refreshJoinRequests()
await checkAuth()
setOk('Antrag gesendet. Der Vereinsadmin kann ihn unter Vereinsverwaltung annehmen.')
} catch (err) {
setError(err.message || 'Antrag fehlgeschlagen.')
} finally {
setJoinBusy(false)
}
}
return (
<div className="page-padding app-page" style={{ padding: '1rem', maxWidth: '40rem' }}>
<h1 style={{ marginTop: 0, fontSize: '1.5rem' }}>Willkommen bei Shinkan</h1>
<p style={{ color: 'var(--text2)', lineHeight: 1.5, marginBottom: '1.25rem' }}>
Shinkan ist die Trainingsplanungs-Plattform für Vereine. Um Übungen, Planung und Medien zu nutzen,
brauchst du eine Mitgliedschaft in einem Verein oder du beantragst die Gründung eines neuen Vereins.
</p>
<EmailVerificationBanner profile={user} />
{!emailOk ? (
<div className="card">
<p style={{ margin: 0, color: 'var(--text2)', lineHeight: 1.5 }}>
Bitte bestätige zuerst deine E-Mail-Adresse. Danach kannst du einen Beitrittsantrag stellen.
</p>
</div>
) : (
<>
{ok ? (
<p role="status" style={{ color: 'var(--accent-dark)', marginBottom: '1rem' }}>
{ok}
</p>
) : null}
{error ? (
<p role="alert" style={{ color: 'var(--danger)', marginBottom: '1rem' }}>
{error}
</p>
) : null}
<div className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Bestehendem Verein beitreten</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Wähle einen Verein und sende einen Beitrittsantrag. Nach Freigabe durch den Vereinsadmin
stehen dir alle Funktionen zur Verfügung.
</p>
{myJoinRequests.length > 0 ? (
<div style={{ marginBottom: '1rem' }}>
<strong style={{ fontSize: '0.9rem' }}>Meine Anträge</strong>
<ul
style={{
margin: '0.5rem 0 0',
paddingLeft: '1.25rem',
color: 'var(--text2)',
fontSize: '0.9rem',
}}
>
{myJoinRequests.map((r) => (
<li key={r.id} style={{ marginBottom: '0.35rem' }}>
{r.club_name || `Verein #${r.club_id}`} {joinStatusLabel(r.status)}
{r.status === 'pending' ? (
<>
{' '}
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '0.75rem', padding: '0.15rem 0.45rem' }}
onClick={async () => {
if (!confirm('Antrag wirklich zurückziehen?')) return
try {
await api.withdrawClubJoinRequest(r.id)
refreshJoinRequests()
} catch (err) {
setError(err.message || 'Zurückziehen fehlgeschlagen.')
}
}}
>
zurückziehen
</button>
</>
) : null}
</li>
))}
</ul>
</div>
) : null}
<form onSubmit={handleJoin}>
<label className="form-label" htmlFor="onb-join-club">
Verein
</label>
<select
id="onb-join-club"
className="form-input"
value={joinClubId}
onChange={(e) => setJoinClubId(e.target.value)}
>
<option value=""></option>
{joinClubChoices.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name}
{c.abbreviation ? ` (${c.abbreviation})` : ''}
</option>
))}
</select>
<label className="form-label" htmlFor="onb-join-msg" style={{ marginTop: '0.75rem' }}>
Nachricht (optional)
</label>
<textarea
id="onb-join-msg"
className="form-input"
rows={2}
value={joinMessage}
onChange={(e) => setJoinMessage(e.target.value)}
/>
<button
type="submit"
className="btn btn-primary"
disabled={joinBusy}
style={{ marginTop: '0.85rem' }}
>
{joinBusy ? 'Senden…' : 'Beitritt beantragen'}
</button>
</form>
</div>
<div className="card">
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Neuen Verein gründen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Stelle einen Antrag auf Vereinsgründung. Nach Freigabe durch den Plattform-Administrator
wird der Verein mit Free-Abo angelegt und du wirst Hauptverwalter.
</p>
{myCreationRequests.length > 0 ? (
<div style={{ marginBottom: '1rem' }}>
<strong style={{ fontSize: '0.9rem' }}>Meine Gründungsanträge</strong>
<ul
style={{
margin: '0.5rem 0 0',
paddingLeft: '1.25rem',
color: 'var(--text2)',
fontSize: '0.9rem',
}}
>
{myCreationRequests.map((r) => (
<li key={r.id} style={{ marginBottom: '0.35rem' }}>
{r.proposed_name} {creationStatusLabel(r.status)}
{isActiveApprovedCreation(r) && r.created_club_name
? ` (${r.created_club_name})`
: null}
{r.status === 'pending' ? (
<>
{' '}
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '0.75rem', padding: '0.15rem 0.45rem' }}
onClick={async () => {
if (!confirm('Antrag wirklich zurückziehen?')) return
try {
await api.withdrawClubCreationRequest(r.id)
refreshCreationRequests()
} catch (err) {
setError(err.message || 'Zurückziehen fehlgeschlagen.')
}
}}
>
zurückziehen
</button>
</>
) : null}
{isActiveApprovedCreation(r) ? (
<>
{' '}
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '0.75rem', padding: '0.15rem 0.45rem' }}
onClick={() => checkAuth()}
>
App aktualisieren
</button>
</>
) : null}
</li>
))}
</ul>
</div>
) : null}
{hasPendingCreation ? (
<p style={{ margin: 0, color: 'var(--text2)', fontSize: '0.875rem' }}>
Du hast bereits einen offenen Gründungsantrag. Bitte warte auf die Freigabe oder ziehe
den Antrag zurück.
</p>
) : (
<form onSubmit={handleCreateClub}>
<label className="form-label" htmlFor="onb-create-name">
Vereinsname
</label>
<input
id="onb-create-name"
className="form-input"
value={createName}
onChange={(e) => setCreateName(e.target.value)}
maxLength={200}
required
/>
<label className="form-label" htmlFor="onb-create-abbr" style={{ marginTop: '0.75rem' }}>
Kürzel (optional)
</label>
<input
id="onb-create-abbr"
className="form-input"
value={createAbbr}
onChange={(e) => setCreateAbbr(e.target.value)}
maxLength={50}
/>
<label className="form-label" htmlFor="onb-create-desc" style={{ marginTop: '0.75rem' }}>
Beschreibung (optional)
</label>
<textarea
id="onb-create-desc"
className="form-input"
rows={2}
value={createDesc}
onChange={(e) => setCreateDesc(e.target.value)}
/>
<label className="form-label" htmlFor="onb-create-msg" style={{ marginTop: '0.75rem' }}>
Nachricht an den Administrator (optional)
</label>
<textarea
id="onb-create-msg"
className="form-input"
rows={2}
value={createMessage}
onChange={(e) => setCreateMessage(e.target.value)}
/>
<button
type="submit"
className="btn btn-primary"
disabled={createBusy}
style={{ marginTop: '0.85rem' }}
>
{createBusy ? 'Senden…' : 'Gründung beantragen'}
</button>
</form>
)}
</div>
</>
)}
<p style={{ marginTop: '1.25rem', fontSize: '0.875rem' }}>
<Link to="/settings">Einstellungen</Link> (Passwort, Profil)
</p>
</div>
)
}