BenutzerProfil

This commit is contained in:
Ashikagi
2026-03-23 21:38:11 +01:00
parent f5338ea3b2
commit cd77f88d96
22 changed files with 1145 additions and 109 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

File diff suppressed because one or more lines are too long

1
dist/assets/index-BaBfIFek.css vendored Normal file

File diff suppressed because one or more lines are too long

86
dist/assets/index-Cg2UxsRL.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PAT Test Manager</title> <title>PAT Test Manager</title>
<script type="module" crossorigin src="/assets/index-DlZengFf.js"></script> <script type="module" crossorigin src="/assets/index-Cg2UxsRL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BQ19ytI3.css"> <link rel="stylesheet" crossorigin href="/assets/index-BaBfIFek.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

7
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.45.4", "@supabase/supabase-js": "^2.45.4",
"all": "^0.0.0", "all": "^0.0.0",
"country-flag-icons": "^1.6.15",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7", "jspdf-autotable": "^5.0.7",
"lucide-react": "^0.263.1", "lucide-react": "^0.263.1",
@@ -1845,6 +1846,12 @@
"url": "https://opencollective.com/core-js" "url": "https://opencollective.com/core-js"
} }
}, },
"node_modules/country-flag-icons": {
"version": "1.6.15",
"resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.6.15.tgz",
"integrity": "sha512-92HoA8l6DluEidku8tKBftjuFRj4Rv3zDW1lXxCuNnqAxhUSkvso9gM/Afj4F5BnK+wneHIe3ydI+s+4NA29/Q==",
"license": "MIT"
},
"node_modules/crc-32": { "node_modules/crc-32": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",

View File

@@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.45.4", "@supabase/supabase-js": "^2.45.4",
"all": "^0.0.0", "all": "^0.0.0",
"country-flag-icons": "^1.6.15",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7", "jspdf-autotable": "^5.0.7",
"lucide-react": "^0.263.1", "lucide-react": "^0.263.1",

View File

@@ -6,6 +6,10 @@ import { supabase } from './lib/supabaseClient'
import Landing from './components/Landing' import Landing from './components/Landing'
import SharedAssessmentPage from './components/SharedAssessmentPage' import SharedAssessmentPage from './components/SharedAssessmentPage'
import PublicInfoPage from './components/public/PublicInfoPage' import PublicInfoPage from './components/public/PublicInfoPage'
import ProfileTab from './components/ProfileTab'
import { useUserProfile } from './hooks/useUserProfile'
import { getCountryLabel, getCountryOption } from './data/countries'
import CountryFlag from './components/CountryFlag'
const readShareToken = () => { const readShareToken = () => {
if (typeof window === 'undefined') return '' if (typeof window === 'undefined') return ''
@@ -32,6 +36,13 @@ function App() {
if (stored === 'light' || stored === 'dark') return stored if (stored === 'light' || stored === 'dark') return stored
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}) })
const {
profile,
loading: profileLoading,
error: profileError,
saveProfile,
deleteProfileData
} = useUserProfile(session)
useEffect(() => { useEffect(() => {
const root = document.documentElement const root = document.documentElement
@@ -89,7 +100,7 @@ function App() {
}, [shareToken]) }, [shareToken])
useEffect(() => { useEffect(() => {
if (isEditingAssessment && activeTab === 'analysis') { if (isEditingAssessment && activeTab !== 'assessments') {
setActiveTab('assessments') setActiveTab('assessments')
} }
}, [activeTab, isEditingAssessment]) }, [activeTab, isEditingAssessment])
@@ -194,13 +205,29 @@ function App() {
) )
} }
const userDisplayName = [profile?.firstName, profile?.lastName]
.filter(Boolean)
.join(' ')
const selectedCountry = getCountryOption(profile?.country)
const countryLabel = getCountryLabel(profile?.country)
const hasName = Boolean(userDisplayName)
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100"> <div className="min-h-screen bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
<header className="bg-white/80 dark:bg-gray-900/70 backdrop-blur border-b border-gray-200 dark:border-gray-800"> <header className="bg-white/80 dark:bg-gray-900/70 backdrop-blur border-b border-gray-200 dark:border-gray-800">
<div className="max-w-7xl mx-auto px-6 py-3 flex items-center justify-between gap-3 flex-wrap"> <div className="max-w-7xl mx-auto px-6 py-3 flex items-center justify-between gap-3 flex-wrap">
<div> <div>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Angemeldet</p> <p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Angemeldet</p>
<p className="text-sm font-semibold text-gray-800 dark:text-gray-100">{session?.user?.email}</p> <div className="flex items-center gap-2 flex-wrap text-sm font-semibold text-gray-800 dark:text-gray-100">
{countryLabel && (
<CountryFlag
countryCode={selectedCountry?.code || profile?.country}
title={countryLabel}
className="h-4 w-6 rounded-[2px] shadow-sm ring-1 ring-black/10"
/>
)}
<span>{hasName ? userDisplayName : session?.user?.email}</span>
</div>
</div> </div>
<nav className="flex items-center gap-2"> <nav className="flex items-center gap-2">
@@ -229,6 +256,20 @@ function App() {
> >
Analyse Analyse
</button> </button>
<button
type="button"
onClick={() => setActiveTab('profile')}
disabled={isEditingAssessment}
className={`px-4 py-2 rounded-lg border text-sm font-semibold transition ${
activeTab === 'profile'
? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 border-gray-900 dark:border-gray-100'
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800'
} ${isEditingAssessment ? 'opacity-50 cursor-not-allowed' : ''}`}
title={isEditingAssessment ? 'Profil ist während der Bearbeitung deaktiviert' : 'Profil öffnen'}
>
Profil
</button>
</nav> </nav>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -243,11 +284,27 @@ function App() {
</div> </div>
</header> </header>
<PatTestManager {activeTab !== 'profile' && (
user={session?.user} <PatTestManager
activeTab={activeTab} user={session?.user}
onEditingStateChange={setIsEditingAssessment} activeTab={activeTab}
/> onEditingStateChange={setIsEditingAssessment}
testsVisible={Boolean(profile?.testsVisible)}
isProfileLoading={profileLoading}
/>
)}
{activeTab === 'profile' && (
<ProfileTab
session={session}
profile={profile}
loading={profileLoading}
error={profileError}
onSave={saveProfile}
onDelete={deleteProfileData}
onSessionChange={setSession}
/>
)}
</div> </div>
) )
} }

View File

@@ -0,0 +1,126 @@
import React from 'react';
import {
AR,
AT,
AU,
BA,
BE,
BG,
BR,
CA,
CH,
CL,
CN,
CO,
CZ,
DE,
DK,
EE,
ES,
FI,
FR,
GB,
GR,
HR,
HU,
IE,
IN,
IS,
IT,
JP,
KR,
LI,
LT,
LU,
LV,
MT,
MX,
NL,
NO,
NZ,
PL,
PT,
RO,
SE,
SI,
SK,
TR,
UA,
US,
ZA
} from 'country-flag-icons/react/3x2';
const normalizeCountryCode = (value = '') => String(value || '').trim().toUpperCase();
const FLAG_COMPONENTS = {
AR,
AT,
AU,
BA,
BE,
BG,
BR,
CA,
CH,
CL,
CN,
CO,
CZ,
DE,
DK,
EE,
ES,
FI,
FR,
GB,
GR,
HR,
HU,
IE,
IN,
IS,
IT,
JP,
KR,
LI,
LT,
LU,
LV,
MT,
MX,
NL,
NO,
NZ,
PL,
PT,
RO,
SE,
SI,
SK,
TR,
UA,
US,
ZA
};
export default function CountryFlag({
countryCode = '',
title = '',
className = 'h-4 w-6 rounded-[2px] shadow-sm'
}) {
const normalizedCode = normalizeCountryCode(countryCode);
const FlagIcon = FLAG_COMPONENTS[normalizedCode];
if (!FlagIcon) {
return (
<span
className="inline-flex h-4 w-6 items-center justify-center rounded-[2px] bg-gray-200 text-[10px] dark:bg-gray-700"
title={title || 'Kein Land'}
>
🌍
</span>
);
}
return <FlagIcon title={title || normalizedCode} className={className} />;
}

View File

@@ -24,6 +24,7 @@ const PatDetail = ({
finalizeDisabledReason = '', finalizeDisabledReason = '',
onFinalize, onFinalize,
isShareActive = false, isShareActive = false,
testsVisible = true,
canShare = false, canShare = false,
shareDisabledReason = '', shareDisabledReason = '',
onShare, onShare,
@@ -73,8 +74,12 @@ const PatDetail = ({
{currentAssessment?.patType} {currentAssessment?.patType}
</span> </span>
{isShareActive && ( {isShareActive && (
<span className="px-4 py-2 rounded-full text-sm font-semibold bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200"> <span className={`px-4 py-2 rounded-full text-sm font-semibold ${
Freigabe aktiv testsVisible
? 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'
}`}>
{testsVisible ? 'Freigabe aktiv' : 'Freigabe pausiert'}
</span> </span>
)} )}
{currentAssessment?.isFinalized && ( {currentAssessment?.isFinalized && (

View File

@@ -49,6 +49,7 @@ const PatList = ({
assessments, assessments,
overview, overview,
assessmentShareTokens = {}, assessmentShareTokens = {},
testsVisible = true,
onCreate, onCreate,
onEdit, onEdit,
onDelete, onDelete,
@@ -395,8 +396,12 @@ const PatList = ({
{assessment.patType} {assessment.patType}
</span> </span>
{hasShare && ( {hasShare && (
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200"> <span className={`px-3 py-1 rounded-full text-xs font-semibold ${
Freigabe aktiv testsVisible
? 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'
}`}>
{testsVisible ? 'Freigabe aktiv' : 'Freigabe pausiert'}
</span> </span>
)} )}
{assessment.isFinalized && ( {assessment.isFinalized && (

View File

@@ -28,7 +28,9 @@ const isLocalhostShare = () =>
export default function PatTestManager({ export default function PatTestManager({
user, user,
activeTab = 'assessments', activeTab = 'assessments',
onEditingStateChange onEditingStateChange,
testsVisible = false,
isProfileLoading = false
}) { }) {
const visiblePatTypes = useMemo(() => patTypes || {}, []); const visiblePatTypes = useMemo(() => patTypes || {}, []);
const patTypeOptions = Object.keys(visiblePatTypes); const patTypeOptions = Object.keys(visiblePatTypes);
@@ -228,6 +230,16 @@ export default function PatTestManager({
const handleShareFromDetail = useCallback(() => { const handleShareFromDetail = useCallback(() => {
if (!currentAssessment) return; if (!currentAssessment) return;
if (isProfileLoading) {
alert('Profil wird noch geladen. Bitte versuche es gleich noch einmal.');
return;
}
if (!testsVisible) {
alert('Aktiviere im Profil zuerst "Tests sichtbar", bevor du Tests teilst.');
return;
}
if (!isCurrentAssessmentPersisted) { if (!isCurrentAssessmentPersisted) {
alert('Bitte den Test zuerst speichern, bevor du ihn teilen kannst.'); alert('Bitte den Test zuerst speichern, bevor du ihn teilen kannst.');
return; return;
@@ -239,7 +251,14 @@ export default function PatTestManager({
} }
handleShare(currentAssessment); handleShare(currentAssessment);
}, [currentAssessment, handleShare, hasUnsavedChanges, isCurrentAssessmentPersisted]); }, [
currentAssessment,
handleShare,
hasUnsavedChanges,
isCurrentAssessmentPersisted,
isProfileLoading,
testsVisible
]);
const handleRevokeShareFromDetail = useCallback(() => { const handleRevokeShareFromDetail = useCallback(() => {
if (!currentAssessment || !isCurrentAssessmentShareActive) return; if (!currentAssessment || !isCurrentAssessmentShareActive) return;
handleRevokeShare(currentAssessment); handleRevokeShare(currentAssessment);
@@ -248,7 +267,11 @@ export default function PatTestManager({
? 'Bitte zuerst speichern' ? 'Bitte zuerst speichern'
: hasUnsavedChanges : hasUnsavedChanges
? 'Bitte erst speichern, damit der aktuelle Stand geteilt wird' ? 'Bitte erst speichern, damit der aktuelle Stand geteilt wird'
: ''; : isProfileLoading
? 'Profil wird geladen'
: !testsVisible
? 'Aktiviere im Profil "Tests sichtbar"'
: '';
const finalizeDisabledReason = getFinalizeDisabledReason(currentAssessment); const finalizeDisabledReason = getFinalizeDisabledReason(currentAssessment);
if (!showList) { if (!showList) {
@@ -273,7 +296,8 @@ export default function PatTestManager({
finalizeDisabledReason={finalizeDisabledReason} finalizeDisabledReason={finalizeDisabledReason}
onFinalize={handleFinalize} onFinalize={handleFinalize}
isShareActive={isCurrentAssessmentShareActive} isShareActive={isCurrentAssessmentShareActive}
canShare={isCurrentAssessmentPersisted && !hasUnsavedChanges} testsVisible={testsVisible}
canShare={testsVisible && !isProfileLoading && isCurrentAssessmentPersisted && !hasUnsavedChanges}
shareDisabledReason={shareDisabledReason} shareDisabledReason={shareDisabledReason}
onShare={handleShareFromDetail} onShare={handleShareFromDetail}
onRevokeShare={handleRevokeShareFromDetail} onRevokeShare={handleRevokeShareFromDetail}
@@ -299,6 +323,7 @@ export default function PatTestManager({
assessments={visibleAssessments} assessments={visibleAssessments}
overview={<LiveOverview assessments={visibleAssessments} />} overview={<LiveOverview assessments={visibleAssessments} />}
assessmentShareTokens={assessmentShareTokens} assessmentShareTokens={assessmentShareTokens}
testsVisible={testsVisible}
selectedPatType={selectedPatType} selectedPatType={selectedPatType}
onPatTypeChange={setSelectedPatType} onPatTypeChange={setSelectedPatType}
onCreate={(patType) => { onCreate={(patType) => {

View File

@@ -0,0 +1,369 @@
import React, { useEffect, useRef, useState } from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { countryOptions, getCountryLabel, getCountryOption } from '../data/countries';
import CountryFlag from './CountryFlag';
const emptyStatus = {
message: '',
error: ''
};
export default function ProfileTab({
session,
profile,
loading,
error,
onSave,
onDelete,
onSessionChange
}) {
const [formValues, setFormValues] = useState(profile);
const [status, setStatus] = useState(emptyStatus);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [isCountryOpen, setIsCountryOpen] = useState(false);
const countryDropdownRef = useRef(null);
const selectedCountry = getCountryOption(formValues.country);
const hasCustomCountryValue = Boolean(formValues.country) && !selectedCountry;
useEffect(() => {
setFormValues(profile);
}, [profile]);
useEffect(() => {
if (!isCountryOpen) return undefined;
const handlePointerDown = (event) => {
if (countryDropdownRef.current && !countryDropdownRef.current.contains(event.target)) {
setIsCountryOpen(false);
}
};
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setIsCountryOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isCountryOpen]);
const updateField = (field) => (event) => {
const nextValue = field === 'testsVisible'
? event.target.checked
: event.target.value;
setFormValues((current) => ({
...current,
[field]: nextValue
}));
};
const handleCountrySelect = (countryCode) => {
setFormValues((current) => ({
...current,
country: countryCode
}));
setIsCountryOpen(false);
};
const handleSubmit = async (event) => {
event.preventDefault();
setSaving(true);
setStatus(emptyStatus);
const result = await onSave(formValues);
if (result?.ok) {
if (result.session) {
onSessionChange?.(result.session);
}
setStatus({
message: result.emailUpdateRequested
? 'Profil gespeichert. Die neue Mailadresse muss ggf. noch per E-Mail bestätigt werden.'
: 'Profil gespeichert.',
error: ''
});
} else {
setStatus({
message: '',
error: result?.error?.message || 'Profil konnte nicht gespeichert werden.'
});
}
setSaving(false);
};
const handleDelete = async () => {
const confirmed = window.confirm(
'Profil wirklich löschen?\n\nDabei werden alle Tests dieses Benutzers sowie vorhandene Analysepläne dauerhaft entfernt.'
);
if (!confirmed) return;
setDeleting(true);
setStatus(emptyStatus);
const result = await onDelete?.();
if (result?.ok) {
setStatus({
message: 'Profil und alle Tests wurden gelöscht. Das Konto bleibt bestehen und kann neu eingerichtet werden.',
error: ''
});
} else {
setStatus({
message: '',
error: result?.error?.message || 'Profil konnte nicht gelöscht werden.'
});
}
setDeleting(false);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-sky-50 to-emerald-100 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 p-6 text-gray-900 dark:text-gray-100">
<div className="max-w-4xl mx-auto">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 overflow-hidden">
<div className="px-6 py-5 border-b border-gray-200 dark:border-gray-800">
<p className="text-xs uppercase tracking-[0.25em] text-sky-700 dark:text-sky-300">Benutzerprofil</p>
<h1 className="text-3xl font-bold mt-2">Profil verwalten</h1>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
Name, Vorname, Mailadresse, Land und die Sichtbarkeit deiner Testfreigaben werden hier gepflegt.
</p>
</div>
{loading ? (
<div className="px-6 py-10 text-sm text-gray-600 dark:text-gray-300">
Profil wird geladen...
</div>
) : (
<form onSubmit={handleSubmit} className="px-6 py-6 space-y-6">
<div className="grid md:grid-cols-2 gap-5">
<label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Vorname</span>
<input
type="text"
value={formValues.firstName || ''}
onChange={updateField('firstName')}
className="mt-1 w-full px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-sky-500"
placeholder="Max"
/>
</label>
<label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Nachname</span>
<input
type="text"
value={formValues.lastName || ''}
onChange={updateField('lastName')}
className="mt-1 w-full px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-sky-500"
placeholder="Mustermann"
/>
</label>
<label className="block md:col-span-2">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Mailadresse</span>
<input
type="email"
value={formValues.email || session?.user?.email || ''}
onChange={updateField('email')}
required
className="mt-1 w-full px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-sky-500"
placeholder="you@example.com"
/>
<span className="mt-2 block text-xs text-gray-500 dark:text-gray-400">
Wenn du die Mailadresse änderst, wird dir eine Bestätigung an die neue Adresse gesendet.
</span>
</label>
<label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Land</span>
<div ref={countryDropdownRef} className="relative mt-1">
<button
type="button"
onClick={() => setIsCountryOpen((current) => !current)}
className="flex w-full items-center justify-between rounded-lg border border-gray-200 bg-white px-4 py-3 text-left text-sm text-gray-800 transition hover:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
>
<span className="inline-flex items-center gap-3">
<CountryFlag
countryCode={selectedCountry?.code || formValues.country}
title={selectedCountry?.name || getCountryLabel(formValues.country) || 'Land auswählen'}
className="h-5 w-7 rounded-[3px] shadow-sm ring-1 ring-black/10"
/>
<span className="font-medium">
{selectedCountry?.name || getCountryLabel(formValues.country) || 'Land auswählen'}
</span>
</span>
<ChevronDown
className={`h-4 w-4 text-gray-500 transition ${isCountryOpen ? 'rotate-180' : ''}`}
aria-hidden="true"
/>
</button>
{isCountryOpen && (
<div className="absolute left-0 right-0 z-20 mt-2 max-h-72 overflow-y-auto rounded-xl border border-gray-200 bg-white p-2 shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<button
type="button"
onClick={() => handleCountrySelect('')}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition ${
!formValues.country
? 'bg-sky-50 text-sky-700 dark:bg-sky-950/40 dark:text-sky-200'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800'
}`}
>
<span className="inline-flex items-center gap-3">
<CountryFlag
countryCode=""
title="Kein Land ausgewählt"
className="h-5 w-7 rounded-[3px] shadow-sm"
/>
<span>Kein Land ausgewählt</span>
</span>
{!formValues.country && <Check className="h-4 w-4" aria-hidden="true" />}
</button>
{hasCustomCountryValue && (
<button
type="button"
onClick={() => handleCountrySelect(formValues.country)}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm text-gray-700 transition hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800"
>
<span className="inline-flex items-center gap-3">
<CountryFlag
countryCode={formValues.country}
title={getCountryLabel(formValues.country)}
className="h-5 w-7 rounded-[3px] shadow-sm ring-1 ring-black/10"
/>
<span>{getCountryLabel(formValues.country)}</span>
</span>
<Check className="h-4 w-4" aria-hidden="true" />
</button>
)}
{countryOptions.map((country) => {
const isSelected = country.code === formValues.country;
return (
<button
key={country.code}
type="button"
onClick={() => handleCountrySelect(country.code)}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition ${
isSelected
? 'bg-sky-50 text-sky-700 dark:bg-sky-950/40 dark:text-sky-200'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800'
}`}
>
<span className="inline-flex items-center gap-3">
<CountryFlag
countryCode={country.code}
title={country.name}
className="h-5 w-7 rounded-[3px] shadow-sm ring-1 ring-black/10"
/>
<span>{country.name}</span>
</span>
{isSelected && <Check className="h-4 w-4" aria-hidden="true" />}
</button>
);
})}
</div>
)}
</div>
<span className="mt-2 block text-xs text-gray-500 dark:text-gray-400">
Die ausgewählte Flagge wird später auch im Kopfbereich neben deiner Mailadresse angezeigt.
</span>
</label>
<div className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Ranglistenplatz</span>
<div className="mt-1 rounded-lg border border-dashed border-amber-300 bg-amber-50 px-4 py-3 dark:border-amber-900/70 dark:bg-amber-950/30">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
Platzhalter
</span>
<span className="rounded-full bg-white px-3 py-1 text-sm font-semibold text-amber-700 shadow-sm dark:bg-gray-900 dark:text-amber-300">
# -
</span>
</div>
<p className="mt-2 text-xs text-amber-700/80 dark:text-amber-200/80">
Hier kann spater der Ranglistenplatz des Benutzers angezeigt werden.
</p>
</div>
</div>
</div>
<div className="rounded-xl border border-sky-200 dark:border-sky-900/70 bg-sky-50 dark:bg-sky-950/30 p-5">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Tests sichtbar</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300 max-w-2xl">
Ist dieser Schalter aktiv, funktionieren deine Freigabelinks. Wenn du ihn deaktivierst,
bleiben vorhandene Links gespeichert, sind aber nicht mehr öffentlich sichtbar.
</p>
</div>
<label className="inline-flex items-center gap-3 rounded-full border border-sky-300 dark:border-sky-800 px-4 py-2 bg-white dark:bg-gray-900">
<input
type="checkbox"
checked={Boolean(formValues.testsVisible)}
onChange={updateField('testsVisible')}
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500"
/>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100">
{formValues.testsVisible ? 'Sichtbar' : 'Nicht sichtbar'}
</span>
</label>
</div>
</div>
{(error || status.error) && (
<div className="rounded-lg border border-rose-200 dark:border-rose-900/70 bg-rose-50 dark:bg-rose-950/30 px-4 py-3 text-sm text-rose-700 dark:text-rose-200">
{status.error || error}
</div>
)}
{status.message && (
<div className="rounded-lg border border-emerald-200 dark:border-emerald-900/70 bg-emerald-50 dark:bg-emerald-950/30 px-4 py-3 text-sm text-emerald-700 dark:text-emerald-200">
{status.message}
</div>
)}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="text-sm text-gray-500 dark:text-gray-400">
Konto-ID: <span className="font-mono">{session?.user?.id || '—'}</span>
</div>
<div className="flex items-center gap-3 flex-wrap">
<button
type="button"
onClick={handleDelete}
disabled={deleting || saving}
className="px-5 py-3 rounded-lg border border-rose-300 bg-white text-rose-700 font-semibold hover:bg-rose-50 transition disabled:opacity-60 disabled:cursor-not-allowed dark:border-rose-800 dark:bg-gray-900 dark:text-rose-300 dark:hover:bg-rose-950/40"
>
{deleting ? 'Löscht...' : 'Profil löschen'}
</button>
<button
type="submit"
disabled={saving || deleting}
className="px-5 py-3 rounded-lg bg-sky-600 text-white font-semibold hover:bg-sky-700 transition disabled:opacity-60 disabled:cursor-not-allowed"
>
{saving ? 'Speichert...' : 'Profil speichern'}
</button>
</div>
</div>
</form>
)}
</div>
</div>
</div>
);
}

View File

@@ -27,7 +27,7 @@ export default function SharedAssessmentPage({ shareToken, themeToggle }) {
if (!sharedAssessment) { if (!sharedAssessment) {
setAssessment(null) setAssessment(null)
setError('Dieser Freigabelink ist ungültig oder nicht mehr verfügbar.') setError('Dieser Freigabelink ist ungültig, deaktiviert oder aktuell nicht öffentlich sichtbar.')
return return
} }

67
src/data/countries.js Normal file
View File

@@ -0,0 +1,67 @@
const COUNTRY_OPTIONS = [
{ code: 'AR', name: 'Argentinien' },
{ code: 'AU', name: 'Australien' },
{ code: 'AT', name: 'Österreich' },
{ code: 'BE', name: 'Belgien' },
{ code: 'BA', name: 'Bosnien und Herzegowina' },
{ code: 'BR', name: 'Brasilien' },
{ code: 'BG', name: 'Bulgarien' },
{ code: 'CA', name: 'Kanada' },
{ code: 'CL', name: 'Chile' },
{ code: 'CN', name: 'China' },
{ code: 'CO', name: 'Kolumbien' },
{ code: 'CZ', name: 'Tschechien' },
{ code: 'DK', name: 'Dänemark' },
{ code: 'DE', name: 'Deutschland' },
{ code: 'EE', name: 'Estland' },
{ code: 'FI', name: 'Finnland' },
{ code: 'FR', name: 'Frankreich' },
{ code: 'GR', name: 'Griechenland' },
{ code: 'GB', name: 'Vereinigtes Königreich' },
{ code: 'IE', name: 'Irland' },
{ code: 'IS', name: 'Island' },
{ code: 'IN', name: 'Indien' },
{ code: 'IT', name: 'Italien' },
{ code: 'JP', name: 'Japan' },
{ code: 'HR', name: 'Kroatien' },
{ code: 'LV', name: 'Lettland' },
{ code: 'LI', name: 'Liechtenstein' },
{ code: 'LT', name: 'Litauen' },
{ code: 'LU', name: 'Luxemburg' },
{ code: 'MT', name: 'Malta' },
{ code: 'MX', name: 'Mexiko' },
{ code: 'NL', name: 'Niederlande' },
{ code: 'NZ', name: 'Neuseeland' },
{ code: 'NO', name: 'Norwegen' },
{ code: 'PL', name: 'Polen' },
{ code: 'PT', name: 'Portugal' },
{ code: 'RO', name: 'Rumänien' },
{ code: 'SE', name: 'Schweden' },
{ code: 'CH', name: 'Schweiz' },
{ code: 'SK', name: 'Slowakei' },
{ code: 'SI', name: 'Slowenien' },
{ code: 'ES', name: 'Spanien' },
{ code: 'ZA', name: 'Südafrika' },
{ code: 'KR', name: 'Südkorea' },
{ code: 'TR', name: 'Türkei' },
{ code: 'UA', name: 'Ukraine' },
{ code: 'HU', name: 'Ungarn' },
{ code: 'US', name: 'Vereinigte Staaten' }
];
export const countryOptions = [...COUNTRY_OPTIONS].sort((left, right) =>
left.name.localeCompare(right.name, 'de')
);
export const getCountryOption = (value = '') => {
const normalizedValue = String(value || '').trim().toUpperCase();
if (!normalizedValue) return null;
return countryOptions.find((country) => country.code === normalizedValue) || null;
};
export const getCountryLabel = (value = '') => {
const option = getCountryOption(value);
if (option) return option.name;
return String(value || '').trim();
};

193
src/hooks/useUserProfile.js Normal file
View File

@@ -0,0 +1,193 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { supabase } from '../lib/supabaseClient';
const normalizeEmail = (value = '') => value.trim().toLowerCase();
const buildDefaultProfile = (email = '') => ({
firstName: '',
lastName: '',
country: '',
testsVisible: false,
email
});
const mapFromDb = (row, email = '') => ({
firstName: row?.first_name || '',
lastName: row?.last_name || '',
country: row?.country || '',
testsVisible: Boolean(row?.tests_visible),
email
});
const mapToDb = (profile, userId) => ({
user_id: userId,
first_name: profile.firstName.trim(),
last_name: profile.lastName.trim(),
country: profile.country.trim(),
tests_visible: Boolean(profile.testsVisible)
});
export const useUserProfile = (session) => {
const userId = session?.user?.id || '';
const sessionEmail = useMemo(() => normalizeEmail(session?.user?.email || ''), [session?.user?.email]);
const [profile, setProfile] = useState(() => buildDefaultProfile(sessionEmail));
const [loading, setLoading] = useState(Boolean(userId));
const [error, setError] = useState('');
useEffect(() => {
if (!userId) {
setProfile(buildDefaultProfile(sessionEmail));
setLoading(false);
return;
}
setProfile((current) => ({
...current,
email: sessionEmail
}));
setLoading(true);
}, [sessionEmail, userId]);
const loadProfile = useCallback(async () => {
if (!supabase || !userId) {
setProfile(buildDefaultProfile(sessionEmail));
setLoading(false);
setError('');
return;
}
setLoading(true);
setError('');
try {
const { data, error: loadError } = await supabase
.from('user_profiles')
.select('first_name, last_name, country, tests_visible')
.eq('user_id', userId)
.maybeSingle();
if (loadError) throw loadError;
setProfile(mapFromDb(data, sessionEmail));
} catch (loadError) {
setProfile(buildDefaultProfile(sessionEmail));
setError(loadError.message || 'Profil konnte nicht geladen werden.');
} finally {
setLoading(false);
}
}, [sessionEmail, userId]);
useEffect(() => {
loadProfile();
}, [loadProfile]);
const saveProfile = useCallback(async (nextValues) => {
if (!supabase || !userId) {
return {
ok: false,
error: new Error('Supabase ist nicht konfiguriert.')
};
}
const normalizedEmail = normalizeEmail(nextValues.email);
if (!normalizedEmail) {
return {
ok: false,
error: new Error('Bitte eine gültige E-Mail-Adresse eintragen.')
};
}
try {
let emailUpdateRequested = false;
if (normalizedEmail !== sessionEmail) {
const { error: emailError } = await supabase.auth.updateUser({
email: normalizedEmail
});
if (emailError) throw emailError;
emailUpdateRequested = true;
}
const { data, error: saveError } = await supabase
.from('user_profiles')
.upsert(mapToDb(nextValues, userId), { onConflict: 'user_id' })
.select('first_name, last_name, country, tests_visible')
.single();
if (saveError) throw saveError;
const nextProfile = mapFromDb(
data,
emailUpdateRequested ? normalizedEmail : sessionEmail
);
setProfile(nextProfile);
setError('');
const { data: sessionData } = await supabase.auth.getSession();
return {
ok: true,
profile: nextProfile,
session: sessionData.session,
emailUpdateRequested
};
} catch (saveError) {
const wrappedError = saveError instanceof Error
? saveError
: new Error(saveError?.message || 'Profil konnte nicht gespeichert werden.');
setError(wrappedError.message);
return {
ok: false,
error: wrappedError
};
}
}, [sessionEmail, userId]);
const deleteProfileData = useCallback(async () => {
if (!supabase || !userId) {
return {
ok: false,
error: new Error('Supabase ist nicht konfiguriert.')
};
}
try {
const { error: deleteError } = await supabase.rpc('delete_current_user_profile_data');
if (deleteError) throw deleteError;
const nextProfile = buildDefaultProfile(sessionEmail);
setProfile(nextProfile);
setError('');
return {
ok: true,
profile: nextProfile
};
} catch (deleteError) {
const wrappedError = deleteError instanceof Error
? deleteError
: new Error(deleteError?.message || 'Profil konnte nicht gelöscht werden.');
setError(wrappedError.message);
return {
ok: false,
error: wrappedError
};
}
}, [sessionEmail, userId]);
return {
profile,
loading,
error,
saveProfile,
deleteProfileData,
refreshProfile: loadProfile
};
};

View File

@@ -50,6 +50,16 @@ const buildShareUrl = (shareToken) => {
return url.toString() return url.toString()
} }
const mapShareError = (error) => {
const message = error?.message || ''
if (message.includes('Tests are hidden in the profile')) {
return new Error('Deine Tests sind im Profil aktuell nicht sichtbar.')
}
return error
}
export const createAssessmentShareLink = async (assessment) => { export const createAssessmentShareLink = async (assessment) => {
if (!assessment) { if (!assessment) {
throw new Error('Kein Test zum Teilen gefunden.') throw new Error('Kein Test zum Teilen gefunden.')
@@ -63,7 +73,7 @@ export const createAssessmentShareLink = async (assessment) => {
p_assessment_id: assessment.id p_assessment_id: assessment.id
}) })
if (error) throw error if (error) throw mapShareError(error)
const row = getSingleRow(data) const row = getSingleRow(data)
if (!row?.share_token) { if (!row?.share_token) {
@@ -120,7 +130,7 @@ export const getSharedAssessment = async (shareToken) => {
p_share_token: shareToken p_share_token: shareToken
}) })
if (error) throw error if (error) throw mapShareError(error)
const row = getSingleRow(data) const row = getSingleRow(data)
return row ? mapSharedAssessment(row) : null return row ? mapSharedAssessment(row) : null

View File

@@ -0,0 +1,141 @@
create table if not exists public.user_profiles (
user_id uuid primary key references auth.users(id) on delete cascade,
first_name text not null default '',
last_name text not null default '',
country text not null default '',
tests_visible boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
drop trigger if exists trg_user_profiles_updated_at on public.user_profiles;
create trigger trg_user_profiles_updated_at
before update on public.user_profiles
for each row execute function public.set_updated_at();
alter table public.user_profiles enable row level security;
drop policy if exists "Allow users to insert own user profile" on public.user_profiles;
create policy "Allow users to insert own user profile"
on public.user_profiles
for insert
with check (auth.uid() = user_id);
drop policy if exists "Allow users to select own user profile" on public.user_profiles;
create policy "Allow users to select own user profile"
on public.user_profiles
for select
using (auth.uid() = user_id);
drop policy if exists "Allow users to update own user profile" on public.user_profiles;
create policy "Allow users to update own user profile"
on public.user_profiles
for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
insert into public.user_profiles (user_id)
select id
from auth.users
on conflict (user_id) do nothing;
create or replace function public.handle_new_user_profile()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.user_profiles (user_id)
values (new.id)
on conflict (user_id) do nothing;
return new;
end;
$$;
drop trigger if exists on_auth_user_created_profile on auth.users;
create trigger on_auth_user_created_profile
after insert on auth.users
for each row execute function public.handle_new_user_profile();
create or replace function public.create_or_get_assessment_share(p_assessment_id uuid)
returns table (
assessment_id uuid,
share_token text
)
language plpgsql
security definer
set search_path = public
as $$
declare
v_user_id uuid;
begin
v_user_id := auth.uid();
if v_user_id is null then
raise exception 'Not authenticated';
end if;
if not exists (
select 1
from public.assessments a
where a.id = p_assessment_id
and a.user_id = v_user_id
) then
raise exception 'Assessment not found';
end if;
if not exists (
select 1
from public.user_profiles p
where p.user_id = v_user_id
and p.tests_visible = true
) then
raise exception 'Tests are hidden in the profile';
end if;
insert into public.assessment_shares (assessment_id, user_id, share_token)
values (p_assessment_id, v_user_id, public.generate_share_token())
on conflict on constraint assessment_shares_pkey do nothing;
return query
select s.assessment_id, s.share_token
from public.assessment_shares s
where s.assessment_id = p_assessment_id
and s.user_id = v_user_id
limit 1;
end;
$$;
create or replace function public.get_shared_assessment(p_share_token text)
returns table (
id uuid,
pat_type text,
datum date,
name text,
exercises jsonb
)
language sql
security definer
set search_path = public
as $$
select
a.id,
a.pat_type,
a.datum,
a.name,
a.exercises
from public.assessment_shares s
join public.assessments a on a.id = s.assessment_id
join public.user_profiles p on p.user_id = a.user_id
where s.share_token = p_share_token
and p.tests_visible = true
limit 1;
$$;
revoke all on function public.create_or_get_assessment_share(uuid) from public;
grant execute on function public.create_or_get_assessment_share(uuid) to authenticated;
revoke all on function public.get_shared_assessment(text) from public;
grant execute on function public.get_shared_assessment(text) to anon, authenticated;

View File

@@ -0,0 +1,28 @@
create or replace function public.delete_current_user_profile_data()
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
v_user_id uuid;
begin
v_user_id := auth.uid();
if v_user_id is null then
raise exception 'Not authenticated';
end if;
delete from public.training_plans
where user_id = v_user_id;
delete from public.assessments
where user_id = v_user_id;
delete from public.user_profiles
where user_id = v_user_id;
end;
$$;
revoke all on function public.delete_current_user_profile_data() from public;
grant execute on function public.delete_current_user_profile_data() to authenticated;