feat: enhance club management features and member requests
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 50s

- Updated the backend to include a new `requested_club_id` field in the registration request model.
- Replaced the club memberships router with a new club join requests router for better management of membership applications.
- Added API endpoints for listing public clubs and managing club join requests, improving user experience during registration and membership processes.
- Enhanced the ClubsPage in the frontend to support member management and join requests, including new modals for adding members and handling requests.
- Updated API utility functions to accommodate new endpoints for club join requests and public club listings.
This commit is contained in:
Lars 2026-05-05 16:40:49 +02:00
parent 7d476268b8
commit 0f08e8df58
13 changed files with 1313 additions and 19 deletions

View File

@ -154,14 +154,14 @@ def read_root():
}
# Register routers
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
app.include_router(auth.router)
app.include_router(profiles.router)
app.include_router(exercises.router)
app.include_router(exercise_progression_graphs.router)
app.include_router(clubs.router)
app.include_router(club_memberships.router)
app.include_router(club_join_requests.router)
app.include_router(skills.router)
app.include_router(training_planning.router)
app.include_router(training_framework_programs.router)

View File

@ -0,0 +1,29 @@
-- Migration 040: Antrag auf Vereinsbeitritt (pending → accept/reject durch Vereins-/Plattform-Admin)
CREATE TABLE IF NOT EXISTS club_membership_requests (
id SERIAL PRIMARY KEY,
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'rejected', 'withdrawn')),
message TEXT,
decided_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
decided_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_membership_requests_pending
ON club_membership_requests (profile_id, club_id)
WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_club_membership_requests_club_status
ON club_membership_requests (club_id, status);
CREATE INDEX IF NOT EXISTS idx_club_membership_requests_profile
ON club_membership_requests (profile_id);
DROP TRIGGER IF EXISTS club_membership_requests_update ON club_membership_requests;
CREATE TRIGGER club_membership_requests_update
BEFORE UPDATE ON club_membership_requests
FOR EACH ROW EXECUTE FUNCTION update_timestamp();

View File

@ -19,6 +19,7 @@ class RegisterRequest(BaseModel):
email: EmailStr
password: str
name: Optional[str] = None
requested_club_id: Optional[int] = Field(default=None, ge=1)
class PasswordResetRequest(BaseModel):
email: str

View File

@ -302,7 +302,33 @@ async def register(req: RegisterRequest, request: Request):
email_verified, verification_token, verification_expires,
trial_ends_at, created_at
) VALUES (%s, %s, %s, 'email', %s, 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
RETURNING id
""", (name, email, pin_hash, role, verification_token, verification_expires, trial_ends))
new_profile_id = cur.fetchone()["id"]
req_club = req.requested_club_id
if req_club is not None:
cur.execute(
"SELECT id FROM clubs WHERE id = %s AND status = 'active'",
(int(req_club),),
)
if cur.fetchone():
cur.execute(
"""
SELECT id FROM club_membership_requests
WHERE profile_id = %s AND club_id = %s AND status = 'pending'
LIMIT 1
""",
(new_profile_id, int(req_club)),
)
if not cur.fetchone():
cur.execute(
"""
INSERT INTO club_membership_requests (profile_id, club_id, status, message)
VALUES (%s, %s, 'pending', NULL)
""",
(new_profile_id, int(req_club)),
)
verify_url = verification_link(verification_token)

View File

@ -0,0 +1,281 @@
"""
Anträge auf Vereinsbeitritt: Nutzer stellt Antrag, Vereins-/Plattform-Admin nimmt an oder lehnt ab.
"""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from auth import require_auth
from club_tenancy import can_manage_club_org
from db import get_db, get_cursor, r2d
router = APIRouter(prefix="/api", tags=["club_join_requests"])
_ALLOWED_MEMBER_ROLES = frozenset({"club_admin", "trainer", "division_lead", "content_editor"})
def _normalize_roles(raw: List[str]) -> List[str]:
out: List[str] = []
seen = set()
for r in raw:
if not isinstance(r, str):
raise HTTPException(status_code=400, detail="Rollen müssen Strings sein")
code = r.strip().lower()
if not code or code not in _ALLOWED_MEMBER_ROLES:
raise HTTPException(status_code=400, detail=f"Unbekannte Rolle: {code}")
if code not in seen:
seen.add(code)
out.append(code)
return out
def _club_active(cur, club_id: int) -> bool:
cur.execute("SELECT 1 FROM clubs WHERE id = %s AND status = 'active'", (club_id,))
return cur.fetchone() is not None
def _assert_manage_club(cur, session: dict, club_id: int) -> None:
pid = session["profile_id"]
role = session.get("role")
if not can_manage_club_org(cur, pid, club_id, role):
raise HTTPException(status_code=403, detail="Keine Berechtigung für Mitglieder-Verwaltung in diesem Verein")
def _is_active_member(cur, profile_id: int, club_id: int) -> bool:
cur.execute(
"""
SELECT 1 FROM club_members
WHERE profile_id = %s AND club_id = %s AND status = 'active'
LIMIT 1
""",
(profile_id, club_id),
)
return cur.fetchone() is not None
def _upsert_active_member_with_roles(cur, club_id: int, profile_id: int, roles: List[str]) -> None:
roles_n = _normalize_roles(roles)
if not roles_n:
raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben")
cur.execute(
"""
INSERT INTO club_members (profile_id, club_id, status)
VALUES (%s, %s, 'active')
ON CONFLICT (profile_id, club_id)
DO UPDATE SET status = 'active', updated_at = NOW()
RETURNING id
""",
(profile_id, club_id),
)
cm_id = cur.fetchone()["id"]
cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,))
for rc in roles_n:
cur.execute(
"""
INSERT INTO club_member_roles (club_member_id, role_code)
VALUES (%s, %s)
ON CONFLICT (club_member_id, role_code) DO NOTHING
""",
(cm_id, rc),
)
class JoinRequestCreate(BaseModel):
club_id: int = Field(..., ge=1)
message: Optional[str] = Field(None, max_length=2000)
class JoinRequestAccept(BaseModel):
roles: List[str] = Field(default_factory=lambda: ["trainer"])
def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]:
cur.execute(
"""
SELECT r.*, c.name AS club_name, c.abbreviation AS club_abbreviation
FROM club_membership_requests r
INNER JOIN clubs c ON c.id = r.club_id
WHERE r.id = %s AND r.profile_id = %s
""",
(req_id, viewer_profile_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Antrag nicht gefunden")
return r2d(row)
@router.get("/me/club-join-requests")
def get_my_join_requests(session: dict = Depends(require_auth)):
pid = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT r.*, c.name AS club_name, c.abbreviation AS club_abbreviation
FROM club_membership_requests r
INNER JOIN clubs c ON c.id = r.club_id
WHERE r.profile_id = %s
ORDER BY r.created_at DESC
LIMIT 100
""",
(pid,),
)
return [r2d(r) for r in cur.fetchall()]
@router.post("/me/club-join-requests", status_code=201)
def create_my_join_request(body: JoinRequestCreate, session: dict = Depends(require_auth)):
"""Antrag stellen (nicht möglich wenn bereits aktives Mitglied)."""
pid = session["profile_id"]
msg = (body.message or "").strip() or None
cid = body.club_id
with get_db() as conn:
cur = get_cursor(conn)
if not _club_active(cur, cid):
raise HTTPException(status_code=404, detail="Verein nicht gefunden oder nicht aktiv")
if _is_active_member(cur, pid, cid):
raise HTTPException(status_code=400, detail="Du bist bereits Mitglied in diesem Verein")
cur.execute(
"""
SELECT id FROM club_membership_requests
WHERE profile_id = %s AND club_id = %s AND status = 'pending'
LIMIT 1
""",
(pid, cid),
)
if cur.fetchone():
raise HTTPException(status_code=409, detail="Für diesen Verein liegt bereits ein offener Antrag vor")
cur.execute(
"""
INSERT INTO club_membership_requests (profile_id, club_id, status, message)
VALUES (%s, %s, 'pending', %s)
RETURNING id
""",
(pid, cid, msg),
)
rid = cur.fetchone()["id"]
conn.commit()
with get_db() as conn:
cur = get_cursor(conn)
return _response_one(cur, rid, pid)
@router.delete("/me/club-join-requests/{request_id}")
def withdraw_my_join_request(request_id: int, session: dict = Depends(require_auth)):
pid = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
UPDATE club_membership_requests
SET status = 'withdrawn', updated_at = NOW()
WHERE id = %s AND profile_id = %s AND status = 'pending'
RETURNING id
""",
(request_id, pid),
)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden")
conn.commit()
return {"ok": True}
@router.get("/clubs/{club_id}/join-requests")
def list_club_join_requests(club_id: int, session: dict = Depends(require_auth)):
"""Offene Anträge für einen Verein (Vereins-/Plattform-Admin)."""
with get_db() as conn:
cur = get_cursor(conn)
_assert_manage_club(cur, session, club_id)
cur.execute(
"""
SELECT r.*, p.email AS applicant_email, p.name AS applicant_name
FROM club_membership_requests r
INNER JOIN profiles p ON p.id = r.profile_id
WHERE r.club_id = %s AND r.status = 'pending'
ORDER BY r.created_at ASC
""",
(club_id,),
)
return [r2d(r) for r in cur.fetchall()]
@router.post("/clubs/{club_id}/join-requests/{request_id}/accept")
def accept_club_join_request(
club_id: int,
request_id: int,
body: JoinRequestAccept,
session: dict = Depends(require_auth),
):
admin_pid = session["profile_id"]
roles = _normalize_roles(body.roles)
with get_db() as conn:
cur = get_cursor(conn)
_assert_manage_club(cur, session, club_id)
cur.execute(
"""
SELECT id, profile_id, status FROM club_membership_requests
WHERE id = %s AND club_id = %s
""",
(request_id, club_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Antrag nicht gefunden")
if row["status"] != "pending":
raise HTTPException(status_code=400, detail="Antrag ist nicht mehr offen")
applicant_id = row["profile_id"]
cur.execute(
"""
UPDATE club_membership_requests
SET status = 'accepted',
decided_by_profile_id = %s,
decided_at = NOW(),
updated_at = NOW()
WHERE id = %s AND club_id = %s AND status = 'pending'
RETURNING id
""",
(admin_pid, request_id, club_id),
)
if not cur.fetchone():
raise HTTPException(status_code=409, detail="Antrag konnte nicht angenommen werden")
_upsert_active_member_with_roles(cur, club_id, applicant_id, roles)
conn.commit()
return {"ok": True, "profile_id": applicant_id, "club_id": club_id}
@router.post("/clubs/{club_id}/join-requests/{request_id}/reject")
def reject_club_join_request(club_id: int, request_id: int, session: dict = Depends(require_auth)):
admin_pid = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
_assert_manage_club(cur, session, club_id)
cur.execute(
"""
UPDATE club_membership_requests
SET status = 'rejected',
decided_by_profile_id = %s,
decided_at = NOW(),
updated_at = NOW()
WHERE id = %s AND club_id = %s AND status = 'pending'
RETURNING id
""",
(admin_pid, request_id, club_id),
)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden")
conn.commit()
return {"ok": True}

View File

@ -57,6 +57,48 @@ def list_clubs(
return [r2d(r) for r in rows]
# ── Öffentliches Vereinsverzeichnis (Registrierung / Antrag ohne Mitgliedschaft) ──
@router.get("/clubs/public-directory")
def public_club_directory():
"""Aktive Vereine zur Auswahl bei Registrierung oder Beitrittsantrag (nur id, name, Kürzel)."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, name, abbreviation
FROM clubs
WHERE status = 'active'
ORDER BY name
"""
)
return [r2d(r) for r in cur.fetchall()]
# ── Aktive Mitglieder für Trainer-/Co-Trainer-Auswahl (jeder Vereinsmitglied) ──
@router.get("/clubs/{club_id}/members/directory")
def club_members_directory(club_id: int, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
if not is_platform_admin(role):
assert_club_member(cur, profile_id, club_id)
cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,))
if not cur.fetchone():
raise HTTPException(404, "Verein nicht gefunden")
cur.execute(
"""
SELECT p.id, p.name, p.email
FROM club_members cm
INNER JOIN profiles p ON p.id = cm.profile_id
WHERE cm.club_id = %s AND cm.status = 'active'
ORDER BY COALESCE(p.name, ''), p.email
""",
(club_id,),
)
return [r2d(r) for r in cur.fetchall()]
# ── Get Club ──────────────────────────────────────────────────────────
@router.get("/clubs/{club_id}")
def get_club(club_id: int, session=Depends(require_auth)):

View File

@ -11,7 +11,7 @@ from fastapi import APIRouter, HTTPException, Header, Depends
from db import get_db, get_cursor, r2d
from auth import require_auth
from club_tenancy import assert_club_member, memberships_with_roles
from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin
from models import ProfileCreate, ProfileUpdate
router = APIRouter(prefix="/api", tags=["profiles"])
@ -50,12 +50,20 @@ def get_current_profile(session=Depends(require_auth)):
# ── Admin Profile Management ──────────────────────────────────────────────────
@router.get("/profiles")
def list_profiles(session=Depends(require_auth)):
"""List all profiles (admin)."""
"""Liste aller Profile (nur Plattform-Admin)."""
role = (session.get("role") or "").lower()
if not is_platform_admin(role):
raise HTTPException(status_code=403, detail="Nur Plattform-Administratoren dürfen alle Profile einsehen")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles ORDER BY created")
rows = cur.fetchall()
return [r2d(r) for r in rows]
out = []
for r in rows:
d = r2d(r)
d.pop("pin_hash", None)
out.append(d)
return out
@router.post("/profiles")

View File

@ -1,14 +1,15 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.17"
APP_VERSION = "0.8.18"
BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260505039"
DB_SCHEMA_VERSION = "20260505040"
MODULE_VERSIONS = {
"auth": "1.0.0",
"profiles": "1.1.0", # /profiles/me: clubs[], pin_hash ausgeblendet, active_club_id
"clubs": "0.3.0",
"auth": "1.1.0", # Registrierung: optional requested_club_id → Beitrittsantrag
"profiles": "1.2.0", # GET /profiles nur Plattform-Admin; pin_hash aus Liste entfernt
"clubs": "0.4.0", # public-directory, members/directory; Vereins-GUI verwendet Endpoints
"club_memberships": "1.0.0",
"club_join_requests": "1.0.0",
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
@ -24,6 +25,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.18",
"date": "2026-05-05",
"changes": [
"DB 040 club_membership_requests; API Antrag/Abrufen/annehmen/ablehnen; öffentliches Vereinsverzeichnis; Mitglieder-Directory für Trainerwahl",
"GUI: Vereinsverwaltung Tab Mitglieder & Anträge; Registrierung/Einstellungen Vereinsantrag; Gruppenformular Haupt- und Co-Trainer",
"GET /profiles nur noch für Plattform-Admins",
],
},
{
"version": "0.8.17",
"date": "2026-05-05",

View File

@ -10,6 +10,12 @@ function AccountSettingsPage() {
const [name, setName] = useState('')
const [savingProfile, setSavingProfile] = useState(false)
const [publicClubsDir, setPublicClubsDir] = useState([])
const [myJoinRequests, setMyJoinRequests] = useState([])
const [joinClubId, setJoinClubId] = useState('')
const [joinMessage, setJoinMessage] = useState('')
const [joinBusy, setJoinBusy] = useState(false)
const [newPw1, setNewPw1] = useState('')
const [newPw2, setNewPw2] = useState('')
const [savingPw, setSavingPw] = useState(false)
@ -22,6 +28,32 @@ function AccountSettingsPage() {
setName(typeof user?.name === 'string' ? user.name : '')
}, [user])
const refreshJoinRequests = () => {
api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {})
}
useEffect(() => {
if (!user?.id) return
api.listPublicClubsDirectory().then(setPublicClubsDir).catch(() => {})
refreshJoinRequests()
}, [user?.id])
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 = publicClubsDir.filter(
(c) => !memberClubIds.has(c.id) && !pendingClubIds.has(c.id)
)
const joinStatusLabel = (s) =>
({
pending: 'ausstehend',
accepted: 'angenommen',
rejected: 'abgelehnt',
withdrawn: 'zurückgezogen',
})[s] || s
/** API: boolean true / Legacy: fehlt oder false → als „nicht verifiziert“ behandeln */
const emailExplicitlyVerified =
user?.email_verified === true ||
@ -214,9 +246,133 @@ function AccountSettingsPage() {
<span style={{ textTransform: 'uppercase', letterSpacing: '0.03em', fontWeight: 600 }}>
{user?.tier || 'free'}
</span>
<strong style={{ color: 'var(--text2)' }}>Vereine</strong>
<span style={{ lineHeight: 1.45 }}>
{user?.clubs?.length ? (
<>
{user.clubs.map((c) => (
<div key={c.id}>
<strong style={{ color: 'var(--text1)' }}>{c.name}</strong>
{': '}
{(c.roles || []).length ? (c.roles || []).join(', ') : '—'}
</div>
))}
</>
) : (
'—'
)}
</span>
</div>
</div>
<div className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Vereinsbeitritt</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Beantrage die Mitgliedschaft in einem Verein. Vereinsadministratoren können den Antrag unter
Vereinsverwaltung Mitglieder annehmen oder ablehnen.
</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"
style={{
marginLeft: '0.35rem',
fontSize: '0.75rem',
padding: '0.15rem 0.45rem',
borderRadius: '6px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
}}
onClick={async () => {
if (!confirm('Antrag wirklich zurückziehen?')) return
try {
await api.withdrawClubJoinRequest(r.id)
refreshJoinRequests()
} catch (err) {
showErr(err.message || 'Zurückziehen fehlgeschlagen.')
}
}}
>
zurückziehen
</button>
</>
) : null}
</li>
))}
</ul>
</div>
)}
<form
onSubmit={async (e) => {
e.preventDefault()
if (!joinClubId) {
showErr('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()
showOk('Antrag gesendet.')
} catch (err) {
showErr(err.message || 'Antrag fehlgeschlagen.')
} finally {
setJoinBusy(false)
}
}}
>
<label className="form-label" htmlFor="join-club-select">
Verein auswählen
</label>
<select
id="join-club-select"
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="join-msg" style={{ marginTop: '0.75rem' }}>
Nachricht (optional)
</label>
<textarea
id="join-msg"
className="form-input"
rows={2}
value={joinMessage}
onChange={(e) => setJoinMessage(e.target.value)}
placeholder="z. B. Trainingsgruppe oder Kontakt zum Verein"
/>
<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' }}>Passwort ändern</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>

View File

@ -2,6 +2,13 @@ import React, { useState, useEffect } from 'react'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
const CLUB_ROLE_OPTIONS = [
{ code: 'club_admin', label: 'Vereinsadmin' },
{ code: 'trainer', label: 'Trainer' },
{ code: 'division_lead', label: 'Spartenleitung' },
{ code: 'content_editor', label: 'Inhalte bearbeiten' },
]
function ClubsPage() {
const { user } = useAuth()
const [activeTab, setActiveTab] = useState('clubs')
@ -13,6 +20,19 @@ function ClubsPage() {
const [editing, setEditing] = useState(null)
const [modalType, setModalType] = useState('club')
const [membersAdminClubId, setMembersAdminClubId] = useState(null)
const [clubMembersAdmin, setClubMembersAdmin] = useState([])
const [joinRequestsAdmin, setJoinRequestsAdmin] = useState([])
const [membersAdminLoading, setMembersAdminLoading] = useState(false)
const [groupMemberDirectory, setGroupMemberDirectory] = useState([])
const [showAddMemberModal, setShowAddMemberModal] = useState(false)
const [profilesAdminList, setProfilesAdminList] = useState([])
const [addMemberForm, setAddMemberForm] = useState({ profile_id: '', roles: ['trainer'] })
const [editMemberModal, setEditMemberModal] = useState(null)
const [acceptJoinModal, setAcceptJoinModal] = useState(null)
// Form state
const [formData, setFormData] = useState({})
@ -58,6 +78,70 @@ function ClubsPage() {
}
}
useEffect(() => {
if (!clubs.length) {
setMembersAdminClubId(null)
return
}
const mcl = clubs.filter((c) => isPlatformAdmin || clubAdminClubIds.has(c.id))
if (!mcl.length) {
setMembersAdminClubId(null)
return
}
setMembersAdminClubId((prev) =>
prev != null && mcl.some((x) => x.id === prev) ? prev : mcl[0].id
)
}, [clubs, isPlatformAdmin, user?.clubs])
useEffect(() => {
if (activeTab !== 'members' || !membersAdminClubId || !canManageClub(membersAdminClubId)) return
let cancelled = false
setMembersAdminLoading(true)
Promise.all([
api.listClubMembers(membersAdminClubId, { includeInactive: true }),
api.listClubJoinRequests(membersAdminClubId),
])
.then(([m, j]) => {
if (!cancelled) {
setClubMembersAdmin(m)
setJoinRequestsAdmin(j)
}
})
.catch((err) => {
if (!cancelled) alert('Mitglieder/Anträge: ' + err.message)
})
.finally(() => {
if (!cancelled) setMembersAdminLoading(false)
})
return () => {
cancelled = true
}
}, [activeTab, membersAdminClubId])
useEffect(() => {
if (!showModal || modalType !== 'group' || !formData.club_id) {
setGroupMemberDirectory([])
return
}
let cancelled = false
api
.clubMembersDirectory(formData.club_id)
.then((rows) => {
if (!cancelled) setGroupMemberDirectory(rows)
})
.catch(() => {
if (!cancelled) setGroupMemberDirectory([])
})
return () => {
cancelled = true
}
}, [showModal, modalType, formData.club_id])
useEffect(() => {
if (!showAddMemberModal || !isPlatformAdmin) return
api.listProfiles().then(setProfilesAdminList).catch(() => setProfilesAdminList([]))
}, [showAddMemberModal, isPlatformAdmin])
const handleCreate = (type) => {
setEditing(null)
setModalType(type)
@ -93,10 +177,34 @@ function ClubsPage() {
setShowModal(true)
}
const manageableClubs = clubs.filter((c) => canManageClub(c.id))
const reloadMembersAdmin = async () => {
if (!membersAdminClubId || !canManageClub(membersAdminClubId)) return
try {
const [m, j] = await Promise.all([
api.listClubMembers(membersAdminClubId, { includeInactive: true }),
api.listClubJoinRequests(membersAdminClubId),
])
setClubMembersAdmin(m)
setJoinRequestsAdmin(j)
} catch (err) {
console.error(err)
}
}
const handleEdit = (item, type) => {
setEditing(item)
setModalType(type)
setFormData({ ...item })
if (type === 'group') {
const co = item.co_trainer_ids
setFormData({
...item,
co_trainer_ids: Array.isArray(co) ? co : [],
})
} else {
setFormData({ ...item })
}
setShowModal(true)
}
@ -145,10 +253,24 @@ function ClubsPage() {
await api.createDivision(formData)
}
} else if (modalType === 'group') {
const trainerRaw = formData.trainer_id
const trainer_id =
trainerRaw === '' || trainerRaw === undefined || trainerRaw === null
? null
: Number(trainerRaw)
const coRaw = formData.co_trainer_ids
const co_trainer_ids = Array.isArray(coRaw)
? coRaw.map((x) => Number(x)).filter((n) => Number.isFinite(n))
: []
const payload = {
...formData,
trainer_id: Number.isFinite(trainer_id) ? trainer_id : null,
co_trainer_ids,
}
if (editing) {
await api.updateTrainingGroup(editing.id, formData)
await api.updateTrainingGroup(editing.id, payload)
} else {
await api.createTrainingGroup(formData)
await api.createTrainingGroup(payload)
}
}
@ -187,7 +309,10 @@ function ClubsPage() {
marginBottom: '1.5rem',
borderBottom: '2px solid var(--border)'
}}>
{['clubs', 'divisions', 'groups'].map(tab => (
{(canManageOrgSomewhere
? ['clubs', 'divisions', 'groups', 'members']
: ['clubs', 'divisions', 'groups']
).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
@ -204,6 +329,7 @@ function ClubsPage() {
{tab === 'clubs' && 'Vereine'}
{tab === 'divisions' && 'Sparten'}
{tab === 'groups' && 'Trainingsgruppen'}
{tab === 'members' && 'Mitglieder'}
</button>
))}
</div>
@ -440,6 +566,156 @@ function ClubsPage() {
</>
)}
{activeTab === 'members' && canManageOrgSomewhere && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<h2>Mitglieder &amp; Beitrittsanträge</h2>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<label style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>Verein</label>
<select
className="form-input"
style={{ minWidth: '200px' }}
value={membersAdminClubId ?? ''}
onChange={(e) =>
setMembersAdminClubId(e.target.value ? parseInt(e.target.value, 10) : null)
}
>
{manageableClubs.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<button
type="button"
className="btn btn-primary"
onClick={() => {
setAddMemberForm({ profile_id: '', roles: ['trainer'] })
setShowAddMemberModal(true)
}}
>
Mitglied hinzufügen
</button>
</div>
</div>
{manageableClubs.length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)' }}>
Keine Vereine, für die du Mitglieder verwalten darfst.
</p>
</div>
) : membersAdminLoading ? (
<p style={{ color: 'var(--text2)' }}>Laden</p>
) : (
<>
{joinRequestsAdmin.length > 0 && (
<div className="card" style={{ marginBottom: '1rem' }}>
<h3 style={{ marginTop: 0 }}>Offene Beitrittsanträge</h3>
<div style={{ display: 'grid', gap: '0.75rem' }}>
{joinRequestsAdmin.map((req) => (
<div
key={req.id}
style={{
padding: '0.75rem',
borderRadius: '8px',
border: '1px solid var(--border)',
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: '0.5rem',
}}
>
<div>
<strong>{req.applicant_name || req.applicant_email || 'Nutzer'}</strong>
<div style={{ fontSize: '0.85rem', color: 'var(--text2)' }}>
{req.applicant_email} · Profil #{req.profile_id}
</div>
{req.message ? (
<div style={{ marginTop: '0.35rem', fontSize: '0.875rem' }}>{req.message}</div>
) : null}
</div>
<div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-primary"
onClick={() =>
setAcceptJoinModal({
id: req.id,
label: req.applicant_name || req.applicant_email,
roles: ['trainer'],
})
}
>
Annehmen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={async () => {
if (!confirm('Antrag ablehnen?')) return
try {
await api.rejectClubJoinRequest(membersAdminClubId, req.id)
await reloadMembersAdmin()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Ablehnen
</button>
</div>
</div>
))}
</div>
</div>
)}
<div className="card">
<h3 style={{ marginTop: 0 }}>Mitglieder</h3>
{clubMembersAdmin.length === 0 ? (
<p style={{ color: 'var(--text2)' }}>Noch keine Mitglieder erfasst.</p>
) : (
<div style={{ display: 'grid', gap: '0.65rem' }}>
{clubMembersAdmin.map((m) => (
<div
key={m.membership_id}
style={{
padding: '0.65rem',
borderRadius: '8px',
background: 'var(--surface2)',
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: '0.5rem',
}}
>
<div>
<strong>{m.name || m.email}</strong>
<div style={{ fontSize: '0.8rem', color: 'var(--text2)' }}>
{m.email} · #{m.profile_id} · {m.status}
</div>
<div style={{ fontSize: '0.8rem', marginTop: '0.25rem' }}>
Rollen: {(m.roles || []).join(', ') || '—'}
</div>
</div>
<button
type="button"
className="btn btn-secondary"
onClick={() => setEditMemberModal(m)}
>
Bearbeiten
</button>
</div>
))}
</div>
)}
</div>
</>
)}
</>
)}
{/* Modal */}
{showModal && (
<div style={{
@ -718,6 +994,50 @@ function ClubsPage() {
/>
</div>
<div className="form-row">
<label className="form-label">Haupttrainer</label>
<select
className="form-input"
value={formData.trainer_id != null ? String(formData.trainer_id) : ''}
onChange={(e) =>
updateFormField(
'trainer_id',
e.target.value === '' ? '' : parseInt(e.target.value, 10)
)
}
>
<option value=""></option>
{groupMemberDirectory.map((p) => (
<option key={p.id} value={p.id}>
{(p.name || p.email || '').trim()} (#{p.id})
</option>
))}
</select>
<p style={{ fontSize: '0.75rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
Liste = aktive Vereinsmitglieder. Nach Zuweisungen ggf. Seite neu laden.
</p>
</div>
<div className="form-row">
<label className="form-label">Co-Trainer (Mehrfachauswahl)</label>
<select
multiple
className="form-input"
size={Math.min(8, Math.max(4, groupMemberDirectory.length || 4))}
value={(formData.co_trainer_ids || []).map(String)}
onChange={(e) => {
const opts = [...e.target.selectedOptions].map((o) => parseInt(o.value, 10))
updateFormField('co_trainer_ids', opts)
}}
>
{groupMemberDirectory.map((p) => (
<option key={p.id} value={p.id}>
{(p.name || p.email || '').trim()} (#{p.id})
</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Status</label>
<select
@ -749,6 +1069,340 @@ function ClubsPage() {
</div>
</div>
)}
{showAddMemberModal && membersAdminClubId && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1100,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '480px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Mitglied hinzufügen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
{isPlatformAdmin
? 'Nutzer aus der Liste wählen oder Profil-ID eingeben.'
: 'Profil-ID des Nutzers (z. B. aus der Nutzerverwaltung). Offene Beitrittsanträge kannst du oben direkt annehmen.'}
</p>
{isPlatformAdmin && profilesAdminList.length > 0 ? (
<div className="form-row">
<label className="form-label">Profil</label>
<select
className="form-input"
value={addMemberForm.profile_id === '' ? '' : String(addMemberForm.profile_id)}
onChange={(e) =>
setAddMemberForm((prev) => ({ ...prev, profile_id: e.target.value }))
}
>
<option value="">Bitte wählen</option>
{profilesAdminList.map((p) => (
<option key={p.id} value={p.id}>
#{p.id} {p.email || '—'} ({p.name || 'ohne Name'})
</option>
))}
</select>
</div>
) : (
<div className="form-row">
<label className="form-label">Profil-ID</label>
<input
type="number"
min={1}
className="form-input"
value={addMemberForm.profile_id}
onChange={(e) =>
setAddMemberForm((prev) => ({
...prev,
profile_id: e.target.value === '' ? '' : parseInt(e.target.value, 10),
}))
}
/>
</div>
)}
<div className="form-row">
<span className="form-label">Rollen</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{CLUB_ROLE_OPTIONS.map((opt) => (
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<input
type="checkbox"
checked={addMemberForm.roles.includes(opt.code)}
onChange={() => {
setAddMemberForm((prev) => {
const set = new Set(prev.roles)
if (set.has(opt.code)) set.delete(opt.code)
else set.add(opt.code)
return { ...prev, roles: Array.from(set) }
})
}}
/>
{opt.label}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
onClick={async () => {
const raw = addMemberForm.profile_id
const profile_id = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
if (!Number.isFinite(profile_id) || profile_id < 1) {
alert('Gültige Profil-ID wählen.')
return
}
if (!addMemberForm.roles.length) {
alert('Mindestens eine Rolle.')
return
}
try {
await api.addClubMember(membersAdminClubId, {
profile_id,
roles: addMemberForm.roles,
})
setShowAddMemberModal(false)
await reloadMembersAdmin()
await loadData()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Hinzufügen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setShowAddMemberModal(false)}
>
Abbrechen
</button>
</div>
</div>
</div>
)}
{acceptJoinModal && membersAdminClubId && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1100,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '480px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Antrag annehmen</h2>
<p style={{ color: 'var(--text2)' }}>{acceptJoinModal.label}</p>
<div className="form-row">
<span className="form-label">Rollen bei Aufnahme</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{CLUB_ROLE_OPTIONS.map((opt) => (
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<input
type="checkbox"
checked={acceptJoinModal.roles.includes(opt.code)}
onChange={() => {
setAcceptJoinModal((prev) => {
if (!prev) return prev
const set = new Set(prev.roles)
if (set.has(opt.code)) set.delete(opt.code)
else set.add(opt.code)
const roles = Array.from(set)
return { ...prev, roles: roles.length ? roles : ['trainer'] }
})
}}
/>
{opt.label}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
onClick={async () => {
try {
await api.acceptClubJoinRequest(
membersAdminClubId,
acceptJoinModal.id,
acceptJoinModal.roles.length ? acceptJoinModal.roles : ['trainer']
)
setAcceptJoinModal(null)
await reloadMembersAdmin()
await loadData()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Aufnehmen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setAcceptJoinModal(null)}
>
Abbrechen
</button>
</div>
</div>
</div>
)}
{editMemberModal && membersAdminClubId && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1100,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '480px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Mitglied bearbeiten</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
{editMemberModal.name || editMemberModal.email} (#{editMemberModal.profile_id})
</p>
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={editMemberModal.status || 'active'}
onChange={(e) =>
setEditMemberModal((prev) => (prev ? { ...prev, status: e.target.value } : prev))
}
>
<option value="active">aktiv</option>
<option value="inactive">inaktiv</option>
</select>
</div>
<div className="form-row">
<span className="form-label">Rollen</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{CLUB_ROLE_OPTIONS.map((opt) => (
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<input
type="checkbox"
checked={(editMemberModal.roles || []).includes(opt.code)}
onChange={() => {
setEditMemberModal((prev) => {
if (!prev) return prev
const set = new Set(prev.roles || [])
if (set.has(opt.code)) set.delete(opt.code)
else set.add(opt.code)
let roles = Array.from(set)
if (!roles.length) roles = ['trainer']
return { ...prev, roles }
})
}}
/>
{opt.label}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>
<button
type="button"
className="btn btn-primary"
onClick={async () => {
try {
await api.updateClubMember(membersAdminClubId, editMemberModal.profile_id, {
roles: editMemberModal.roles,
status: editMemberModal.status,
})
setEditMemberModal(null)
await reloadMembersAdmin()
await loadData()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Speichern
</button>
<button
type="button"
className="btn"
style={{ background: 'var(--danger)', color: '#fff', border: 'none' }}
onClick={async () => {
if (
!confirm(
'Mitgliedschaft wirklich entfernen? (Nutzer verliert alle Rollen in diesem Verein.)'
)
)
return
try {
await api.removeClubMember(membersAdminClubId, editMemberModal.profile_id)
setEditMemberModal(null)
await reloadMembersAdmin()
await loadData()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Aus Verein entfernen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setEditMemberModal(null)}>
Schließen
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
@ -11,11 +11,18 @@ function LoginPage() {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState('')
const [publicClubs, setPublicClubs] = useState([])
const [requestedClubId, setRequestedClubId] = useState('')
const [resending, setResending] = useState(false)
const navigate = useNavigate()
const { checkAuth } = useAuth()
useEffect(() => {
if (mode !== 'register') return
api.listPublicClubsDirectory().then(setPublicClubs).catch(() => setPublicClubs([]))
}, [mode])
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
@ -29,10 +36,15 @@ function LoginPage() {
await checkAuth()
navigate('/')
} else {
await api.register(email, password, name)
const extra =
requestedClubId !== ''
? { requested_club_id: parseInt(requestedClubId, 10) }
: {}
await api.register(email, password, name, extra)
setSuccess('Registrierung erfolgreich! Bitte prüfe deine E-Mails (auch Spam).')
setMode('login')
setPassword('')
setRequestedClubId('')
}
} catch (err) {
setError(err.message || 'Ein Fehler ist aufgetreten')
@ -84,6 +96,7 @@ function LoginPage() {
onClick={() => {
setMode('login')
setError('')
setRequestedClubId('')
}}
style={{ flex: 1 }}
>
@ -104,6 +117,7 @@ function LoginPage() {
<form onSubmit={handleSubmit}>
{mode === 'register' && (
<>
<div className="form-row">
<label className="form-label">Name</label>
<input
@ -115,6 +129,26 @@ function LoginPage() {
placeholder="Dein Name"
/>
</div>
<div className="form-row">
<label className="form-label">Verein (optional)</label>
<select
className="form-input"
value={requestedClubId}
onChange={(e) => setRequestedClubId(e.target.value)}
>
<option value="">Kein Antrag / später</option>
{publicClubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name}
{c.abbreviation ? ` (${c.abbreviation})` : ''}
</option>
))}
</select>
<p style={{ fontSize: '0.72rem', color: 'var(--text3)', marginTop: '0.35rem', lineHeight: 1.4 }}>
Nach der E-Mail-Bestätigung kann der Vereinsadmin deinen Beitritt freigeben.
</p>
</div>
</>
)}
<div className="form-row">

View File

@ -95,10 +95,10 @@ export async function login(email, password) {
})
}
export async function register(email, password, name) {
export async function register(email, password, name, extra = {}) {
return request('/api/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password, name })
body: JSON.stringify({ email, password, name, ...extra }),
})
}
@ -202,6 +202,51 @@ export async function removeClubMember(clubId, profileId) {
return request(`/api/clubs/${clubId}/members/${profileId}`, { method: 'DELETE' })
}
/** Aktive Vereine (öffentlich, für Registrierungswahl). */
export async function listPublicClubsDirectory() {
return request('/api/clubs/public-directory')
}
/** Vereinsinternes Mitgliederverzeichnis (Trainer-/Co-Auswahl). */
export async function clubMembersDirectory(clubId) {
return request(`/api/clubs/${clubId}/members/directory`)
}
/** Eigene Beitrittsanträge. */
export async function getMyClubJoinRequests() {
return request('/api/me/club-join-requests')
}
export async function createClubJoinRequest(payload) {
return request('/api/me/club-join-requests', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function withdrawClubJoinRequest(requestId) {
return request(`/api/me/club-join-requests/${requestId}`, { method: 'DELETE' })
}
/** Offene Anträge (Vereins-/Plattform-Admin). */
export async function listClubJoinRequests(clubId) {
return request(`/api/clubs/${clubId}/join-requests`)
}
export async function acceptClubJoinRequest(clubId, requestId, roles = ['trainer']) {
return request(`/api/clubs/${clubId}/join-requests/${requestId}/accept`, {
method: 'POST',
body: JSON.stringify({ roles }),
})
}
export async function rejectClubJoinRequest(clubId, requestId) {
return request(`/api/clubs/${clubId}/join-requests/${requestId}/reject`, {
method: 'POST',
body: JSON.stringify({}),
})
}
export async function listDivisions(clubId) {
const query = clubId ? `?club_id=${clubId}` : ''
return request(`/api/divisions${query}`)
@ -1069,6 +1114,14 @@ export const api = {
addClubMember,
updateClubMember,
removeClubMember,
listPublicClubsDirectory,
clubMembersDirectory,
getMyClubJoinRequests,
createClubJoinRequest,
withdrawClubJoinRequest,
listClubJoinRequests,
acceptClubJoinRequest,
rejectClubJoinRequest,
listDivisions,
createDivision,
updateDivision,

View File

@ -1,6 +1,6 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.17"
export const APP_VERSION = "0.8.18"
export const BUILD_DATE = "2026-05-05"
export const PAGE_VERSIONS = {