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
- 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.
384 lines
14 KiB
JavaScript
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>
|
|
)
|
|
}
|