390 lines
18 KiB
JavaScript
390 lines
18 KiB
JavaScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { Check, ChevronDown } from 'lucide-react';
|
|
import { countryOptions, getCountryLabel, getCountryOption } from '../data/countries';
|
|
import CountryFlag from './CountryFlag';
|
|
import { useTranslation } from '../i18n/LanguageContext';
|
|
import { getTranslator } from '../i18n/translations';
|
|
|
|
const emptyStatus = {
|
|
message: '',
|
|
error: ''
|
|
};
|
|
|
|
export default function ProfileTab({
|
|
session,
|
|
profile,
|
|
loading,
|
|
error,
|
|
onSave,
|
|
onDelete,
|
|
onSessionChange
|
|
}) {
|
|
const t = useTranslation();
|
|
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);
|
|
// Nach dem Speichern in der neu gewählten Sprache übersetzen
|
|
const tNew = getTranslator(formValues.language);
|
|
|
|
if (result?.ok) {
|
|
if (result.session) {
|
|
onSessionChange?.(result.session);
|
|
}
|
|
|
|
setStatus({
|
|
message: result.emailUpdateRequested
|
|
? tNew('profile.saved_email')
|
|
: tNew('profile.saved'),
|
|
error: ''
|
|
});
|
|
} else {
|
|
setStatus({
|
|
message: '',
|
|
error: result?.error?.message || tNew('profile.error_save')
|
|
});
|
|
}
|
|
|
|
setSaving(false);
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
const confirmed = window.confirm(t('profile.delete_confirm'));
|
|
|
|
if (!confirmed) return;
|
|
|
|
setDeleting(true);
|
|
setStatus(emptyStatus);
|
|
|
|
const result = await onDelete?.();
|
|
|
|
if (result?.ok) {
|
|
setStatus({
|
|
message: t('profile.deleted'),
|
|
error: ''
|
|
});
|
|
} else {
|
|
setStatus({
|
|
message: '',
|
|
error: result?.error?.message || t('profile.error_delete')
|
|
});
|
|
}
|
|
|
|
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">{t('profile.title_label')}</p>
|
|
<h1 className="text-3xl font-bold mt-2">{t('profile.title')}</h1>
|
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
|
{t('profile.description')}
|
|
</p>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="px-6 py-10 text-sm text-gray-600 dark:text-gray-300">
|
|
{t('profile.loading')}
|
|
</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">{t('profile.first_name')}</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={t('profile.first_name_placeholder')}
|
|
/>
|
|
</label>
|
|
|
|
<label className="block">
|
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('profile.last_name')}</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={t('profile.last_name_placeholder')}
|
|
/>
|
|
</label>
|
|
|
|
<label className="block md:col-span-2">
|
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('profile.email')}</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">
|
|
{t('profile.email_hint')}
|
|
</span>
|
|
</label>
|
|
|
|
<label className="block">
|
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('profile.country')}</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) || t('profile.country_none')}
|
|
className="h-5 w-7 rounded-[3px] shadow-sm ring-1 ring-black/10"
|
|
/>
|
|
<span className="font-medium">
|
|
{selectedCountry?.name || getCountryLabel(formValues.country) || t('profile.country_none')}
|
|
</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={t('profile.country_none')}
|
|
className="h-5 w-7 rounded-[3px] shadow-sm"
|
|
/>
|
|
<span>{t('profile.country_none')}</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">
|
|
{t('profile.country_hint')}
|
|
</span>
|
|
</label>
|
|
|
|
<label className="block">
|
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('profile.display')}</span>
|
|
<select
|
|
value={formValues.language || 'de'}
|
|
onChange={updateField('language')}
|
|
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 text-sm text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
|
>
|
|
<option value="de">Deutsch</option>
|
|
<option value="en">Englisch</option>
|
|
<option value="it">Italienisch</option>
|
|
<option value="es">Spanisch</option>
|
|
<option value="pt">Portugiesisch</option>
|
|
</select>
|
|
<span className="mt-2 block text-xs text-gray-500 dark:text-gray-400">
|
|
{t('profile.display_hint')}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="block">
|
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">{t('profile.rank')}</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">
|
|
{t('profile.rank_placeholder')}
|
|
</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">
|
|
{t('profile.rank_hint')}
|
|
</p>
|
|
</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">{t('profile.tests_visible_title')}</h2>
|
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300 max-w-2xl">
|
|
{t('profile.tests_visible_desc')}
|
|
</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 ? t('profile.visible') : t('profile.not_visible')}
|
|
</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">
|
|
{t('profile.account_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 ? t('profile.deleting') : t('profile.delete')}
|
|
</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 ? t('profile.saving') : t('profile.save')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|