BenutzerProfil
This commit is contained in:
BIN
Screenshot_1.png
BIN
Screenshot_1.png
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 MiB |
1
dist/assets/index-BQ19ytI3.css
vendored
1
dist/assets/index-BQ19ytI3.css
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-BaBfIFek.css
vendored
Normal file
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
86
dist/assets/index-Cg2UxsRL.js
vendored
Normal file
File diff suppressed because one or more lines are too long
84
dist/assets/index-DlZengFf.js
vendored
84
dist/assets/index-DlZengFf.js
vendored
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
4
dist/index.html
vendored
@@ -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
7
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
71
src/App.jsx
71
src/App.jsx
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
126
src/components/CountryFlag.jsx
Normal file
126
src/components/CountryFlag.jsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
369
src/components/ProfileTab.jsx
Normal file
369
src/components/ProfileTab.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
67
src/data/countries.js
Normal 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
193
src/hooks/useUserProfile.js
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
141
supabase/migrations/20260323000000_user_profiles.sql
Normal file
141
supabase/migrations/20260323000000_user_profiles.sql
Normal 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;
|
||||||
28
supabase/migrations/20260323010000_user_profile_deletion.sql
Normal file
28
supabase/migrations/20260323010000_user_profile_deletion.sql
Normal 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;
|
||||||
Reference in New Issue
Block a user