645 lines
24 KiB
TypeScript
645 lines
24 KiB
TypeScript
import React from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { CheckCircle2, Lock, MailWarning, User } from 'lucide-react';
|
|
import { YStack, XStack } from '@tamagui/stacks';
|
|
import { SizableText as Text } from '@tamagui/text';
|
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
|
import toast from 'react-hot-toast';
|
|
import { MobileShell } from './components/MobileShell';
|
|
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
|
import { MobileField, MobileInput, MobileSelect, MobileColorInput } from './components/FormControls';
|
|
import {
|
|
fetchTenantProfile,
|
|
updateTenantProfile,
|
|
getTenantPackagesOverview,
|
|
getTenantSettings,
|
|
updateTenantSettings,
|
|
type TenantAccountProfile,
|
|
} from '../api';
|
|
import { getApiErrorMessage, getApiValidationMessage } from '../lib/apiError';
|
|
import { ADMIN_PROFILE_PATH } from '../constants';
|
|
import { useBackNavigation } from './hooks/useBackNavigation';
|
|
import { useAdminTheme } from './theme';
|
|
import i18n from '../i18n';
|
|
import { extractBrandingForm, type BrandingFormValues } from '../lib/brandingForm';
|
|
import { DEFAULT_EVENT_BRANDING } from '@/guest/context/EventBrandingContext';
|
|
|
|
type ProfileFormState = {
|
|
name: string;
|
|
email: string;
|
|
preferredLocale: string;
|
|
currentPassword: string;
|
|
password: string;
|
|
passwordConfirmation: string;
|
|
};
|
|
|
|
const LOCALE_OPTIONS = [
|
|
{ value: '', labelKey: 'profile.locale.auto', fallback: 'Automatisch' },
|
|
{ value: 'de', label: 'Deutsch' },
|
|
{ value: 'en', label: 'English' },
|
|
];
|
|
|
|
type TabKey = 'account' | 'branding';
|
|
|
|
const TENANT_BRANDING_DEFAULTS = {
|
|
primary: DEFAULT_EVENT_BRANDING.primaryColor,
|
|
accent: DEFAULT_EVENT_BRANDING.secondaryColor,
|
|
background: DEFAULT_EVENT_BRANDING.backgroundColor,
|
|
surface: DEFAULT_EVENT_BRANDING.palette?.surface ?? DEFAULT_EVENT_BRANDING.backgroundColor,
|
|
headingFont: DEFAULT_EVENT_BRANDING.typography?.heading ?? DEFAULT_EVENT_BRANDING.fontFamily ?? '',
|
|
bodyFont: DEFAULT_EVENT_BRANDING.typography?.body ?? DEFAULT_EVENT_BRANDING.fontFamily ?? '',
|
|
mode: DEFAULT_EVENT_BRANDING.mode ?? 'auto',
|
|
buttonStyle: DEFAULT_EVENT_BRANDING.buttons?.style ?? 'filled',
|
|
buttonRadius: DEFAULT_EVENT_BRANDING.buttons?.radius ?? 12,
|
|
buttonPrimary: DEFAULT_EVENT_BRANDING.buttons?.primary ?? DEFAULT_EVENT_BRANDING.primaryColor,
|
|
buttonSecondary: DEFAULT_EVENT_BRANDING.buttons?.secondary ?? DEFAULT_EVENT_BRANDING.secondaryColor,
|
|
linkColor: DEFAULT_EVENT_BRANDING.buttons?.linkColor ?? DEFAULT_EVENT_BRANDING.secondaryColor,
|
|
fontSize: DEFAULT_EVENT_BRANDING.typography?.sizePreset ?? 'm',
|
|
logoMode: DEFAULT_EVENT_BRANDING.logo?.mode ?? 'emoticon',
|
|
logoPosition: DEFAULT_EVENT_BRANDING.logo?.position ?? 'left',
|
|
logoSize: DEFAULT_EVENT_BRANDING.logo?.size ?? 'm',
|
|
};
|
|
|
|
const buildTenantBrandingFormBase = (): BrandingFormValues => ({
|
|
...TENANT_BRANDING_DEFAULTS,
|
|
logoDataUrl: '',
|
|
logoValue: '',
|
|
});
|
|
|
|
export default function MobileProfileAccountPage() {
|
|
const { t } = useTranslation('settings');
|
|
const { text, muted, danger, subtle, primary, accentSoft } = useAdminTheme();
|
|
const back = useBackNavigation(ADMIN_PROFILE_PATH);
|
|
|
|
const [profile, setProfile] = React.useState<TenantAccountProfile | null>(null);
|
|
const [activeTab, setActiveTab] = React.useState<TabKey>('account');
|
|
const [brandingAllowed, setBrandingAllowed] = React.useState(false);
|
|
const [brandingLoading, setBrandingLoading] = React.useState(true);
|
|
const [brandingSaving, setBrandingSaving] = React.useState(false);
|
|
const [brandingError, setBrandingError] = React.useState<string | null>(null);
|
|
const [tenantSettings, setTenantSettings] = React.useState<Record<string, unknown> | null>(null);
|
|
const [brandingForm, setBrandingForm] = React.useState<BrandingFormValues>(buildTenantBrandingFormBase);
|
|
const [form, setForm] = React.useState<ProfileFormState>({
|
|
name: '',
|
|
email: '',
|
|
preferredLocale: '',
|
|
currentPassword: '',
|
|
password: '',
|
|
passwordConfirmation: '',
|
|
});
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [savingAccount, setSavingAccount] = React.useState(false);
|
|
const [savingPassword, setSavingPassword] = React.useState(false);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const loadErrorMessage = t('profile.errors.load', 'Profil konnte nicht geladen werden.');
|
|
const brandingLoadError = t('profile.errors.brandingLoad', 'Standard-Branding konnte nicht geladen werden.');
|
|
const brandingSaveError = t('profile.errors.brandingSave', 'Standard-Branding konnte nicht gespeichert werden.');
|
|
|
|
const dateFormatter = React.useMemo(
|
|
() =>
|
|
new Intl.DateTimeFormat(i18n.language || 'de', {
|
|
day: '2-digit',
|
|
month: 'long',
|
|
year: 'numeric',
|
|
}),
|
|
[i18n.language],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await fetchTenantProfile();
|
|
setProfile(data);
|
|
setForm((prev) => ({
|
|
...prev,
|
|
name: data.name ?? '',
|
|
email: data.email ?? '',
|
|
preferredLocale: data.preferred_locale ?? '',
|
|
}));
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(getApiErrorMessage(err, loadErrorMessage));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
let active = true;
|
|
(async () => {
|
|
try {
|
|
const overview = await getTenantPackagesOverview();
|
|
if (!active) return;
|
|
setBrandingAllowed(Boolean(overview.activePackage?.branding_allowed));
|
|
} catch {
|
|
if (active) {
|
|
setBrandingAllowed(false);
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
let active = true;
|
|
(async () => {
|
|
setBrandingLoading(true);
|
|
try {
|
|
const payload = await getTenantSettings();
|
|
if (!active) return;
|
|
const settings = (payload.settings ?? {}) as Record<string, unknown>;
|
|
setTenantSettings(settings);
|
|
setBrandingForm(extractBrandingForm(settings, TENANT_BRANDING_DEFAULTS));
|
|
setBrandingError(null);
|
|
} catch (err) {
|
|
if (active) {
|
|
setBrandingError(getApiErrorMessage(err, brandingLoadError));
|
|
}
|
|
} finally {
|
|
if (active) {
|
|
setBrandingLoading(false);
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [brandingLoadError]);
|
|
|
|
const verifiedAt = profile?.email_verified_at ? new Date(profile.email_verified_at) : null;
|
|
const verifiedDate = verifiedAt ? dateFormatter.format(verifiedAt) : null;
|
|
const emailStatusLabel = profile?.email_verified
|
|
? t('profile.status.emailVerified', 'E-Mail bestätigt')
|
|
: t('profile.status.emailNotVerified', 'Bestätigung erforderlich');
|
|
const emailHint = profile?.email_verified
|
|
? t('profile.status.verifiedHint', 'Bestätigt am {{date}}.', { date: verifiedDate ?? '' })
|
|
: t('profile.status.unverifiedHint', 'Bei Änderung der E-Mail senden wir dir automatisch eine neue Bestätigung.');
|
|
const brandingTabEnabled = brandingAllowed;
|
|
|
|
React.useEffect(() => {
|
|
if (!brandingTabEnabled && activeTab === 'branding') {
|
|
setActiveTab('account');
|
|
}
|
|
}, [brandingTabEnabled, activeTab]);
|
|
|
|
const buildPayload = (includePassword: boolean) => ({
|
|
name: form.name.trim(),
|
|
email: form.email.trim(),
|
|
preferred_locale: form.preferredLocale ? form.preferredLocale : null,
|
|
...(includePassword
|
|
? {
|
|
current_password: form.currentPassword,
|
|
password: form.password,
|
|
password_confirmation: form.passwordConfirmation,
|
|
}
|
|
: {}),
|
|
});
|
|
|
|
const handleAccountSave = async () => {
|
|
setSavingAccount(true);
|
|
try {
|
|
const updated = await updateTenantProfile(buildPayload(false));
|
|
setProfile(updated);
|
|
setError(null);
|
|
toast.success(t('profile.toasts.updated', 'Profil wurde aktualisiert.'));
|
|
} catch (err) {
|
|
const message = getApiValidationMessage(err, t('profile.errors.update', 'Profil konnte nicht aktualisiert werden.'));
|
|
setError(message);
|
|
toast.error(message);
|
|
} finally {
|
|
setSavingAccount(false);
|
|
}
|
|
};
|
|
|
|
const handlePasswordSave = async () => {
|
|
setSavingPassword(true);
|
|
try {
|
|
const updated = await updateTenantProfile(buildPayload(true));
|
|
setProfile(updated);
|
|
setError(null);
|
|
setForm((prev) => ({
|
|
...prev,
|
|
currentPassword: '',
|
|
password: '',
|
|
passwordConfirmation: '',
|
|
}));
|
|
toast.success(t('profile.toasts.passwordChanged', 'Passwort wurde aktualisiert.'));
|
|
} catch (err) {
|
|
const message = getApiValidationMessage(err, t('profile.errors.update', 'Profil konnte nicht aktualisiert werden.'));
|
|
setError(message);
|
|
toast.error(message);
|
|
} finally {
|
|
setSavingPassword(false);
|
|
}
|
|
};
|
|
|
|
const handleBrandingSave = async () => {
|
|
if (!tenantSettings) {
|
|
return;
|
|
}
|
|
setBrandingSaving(true);
|
|
try {
|
|
const existingBranding =
|
|
tenantSettings && typeof (tenantSettings as Record<string, unknown>).branding === 'object'
|
|
? ((tenantSettings as Record<string, unknown>).branding as Record<string, unknown>)
|
|
: {};
|
|
const settings = {
|
|
...tenantSettings,
|
|
branding: {
|
|
...existingBranding,
|
|
primary_color: brandingForm.primary,
|
|
secondary_color: brandingForm.accent,
|
|
accent_color: brandingForm.accent,
|
|
background_color: brandingForm.background,
|
|
surface_color: brandingForm.surface,
|
|
font_family: brandingForm.bodyFont,
|
|
heading_font: brandingForm.headingFont,
|
|
body_font: brandingForm.bodyFont,
|
|
font_size: brandingForm.fontSize,
|
|
mode: brandingForm.mode,
|
|
typography: {
|
|
...(typeof existingBranding.typography === 'object' ? (existingBranding.typography as Record<string, unknown>) : {}),
|
|
heading: brandingForm.headingFont,
|
|
body: brandingForm.bodyFont,
|
|
size: brandingForm.fontSize,
|
|
},
|
|
palette: {
|
|
...(typeof existingBranding.palette === 'object' ? (existingBranding.palette as Record<string, unknown>) : {}),
|
|
primary: brandingForm.primary,
|
|
secondary: brandingForm.accent,
|
|
background: brandingForm.background,
|
|
surface: brandingForm.surface,
|
|
},
|
|
},
|
|
};
|
|
const updated = await updateTenantSettings(settings);
|
|
const nextSettings = (updated.settings ?? {}) as Record<string, unknown>;
|
|
setTenantSettings(nextSettings);
|
|
setBrandingForm(extractBrandingForm(nextSettings, TENANT_BRANDING_DEFAULTS));
|
|
setBrandingError(null);
|
|
toast.success(t('profile.branding.updated', 'Standard-Branding gespeichert.'));
|
|
} catch (err) {
|
|
const message = getApiErrorMessage(err, brandingSaveError);
|
|
setBrandingError(message);
|
|
toast.error(message);
|
|
} finally {
|
|
setBrandingSaving(false);
|
|
}
|
|
};
|
|
|
|
const passwordReady =
|
|
form.currentPassword.trim().length > 0 &&
|
|
form.password.trim().length > 0 &&
|
|
form.passwordConfirmation.trim().length > 0;
|
|
const brandingDisabled = brandingLoading || brandingSaving;
|
|
|
|
return (
|
|
<MobileShell
|
|
activeTab="profile"
|
|
title={t('profile.title', 'Profil')}
|
|
onBack={back}
|
|
>
|
|
{brandingTabEnabled ? (
|
|
<XStack space="$2">
|
|
<TabButton
|
|
label={t('profile.tabs.account', 'Account')}
|
|
active={activeTab === 'account'}
|
|
onPress={() => setActiveTab('account')}
|
|
/>
|
|
<TabButton
|
|
label={t('profile.tabs.branding', 'Standard-Branding')}
|
|
active={activeTab === 'branding'}
|
|
onPress={() => setActiveTab('branding')}
|
|
/>
|
|
</XStack>
|
|
) : null}
|
|
|
|
{activeTab === 'branding' && brandingTabEnabled ? (
|
|
<>
|
|
{brandingError ? (
|
|
<MobileCard>
|
|
<Text fontWeight="700" color={danger}>
|
|
{brandingError}
|
|
</Text>
|
|
</MobileCard>
|
|
) : null}
|
|
|
|
<MobileCard space="$3">
|
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
|
{t('profile.branding.title', 'Standard-Branding')}
|
|
</Text>
|
|
<Text fontSize="$sm" color={muted}>
|
|
{t('profile.branding.hint', 'Diese Werte dienen als Vorschlag für neue Events und beschleunigen die Einrichtung.')}
|
|
</Text>
|
|
</MobileCard>
|
|
|
|
<MobileCard space="$3">
|
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
|
{t('profile.branding.theme', 'Theme')}
|
|
</Text>
|
|
<YStack space="$3">
|
|
<MobileField label={t('events.branding.mode', 'Theme')}>
|
|
<MobileSelect
|
|
value={brandingForm.mode}
|
|
onChange={(event) => setBrandingForm((prev) => ({ ...prev, mode: event.target.value as BrandingFormValues['mode'] }))}
|
|
disabled={brandingDisabled}
|
|
>
|
|
<option value="light">{t('events.branding.modeLight', 'Light')}</option>
|
|
<option value="auto">{t('events.branding.modeAuto', 'Auto')}</option>
|
|
<option value="dark">{t('events.branding.modeDark', 'Dark')}</option>
|
|
</MobileSelect>
|
|
</MobileField>
|
|
<MobileField label={t('events.branding.fontSize', 'Font Size')}>
|
|
<MobileSelect
|
|
value={brandingForm.fontSize}
|
|
onChange={(event) => setBrandingForm((prev) => ({ ...prev, fontSize: event.target.value as BrandingFormValues['fontSize'] }))}
|
|
disabled={brandingDisabled}
|
|
>
|
|
<option value="s">{t('events.branding.fontSizeSmall', 'S')}</option>
|
|
<option value="m">{t('events.branding.fontSizeMedium', 'M')}</option>
|
|
<option value="l">{t('events.branding.fontSizeLarge', 'L')}</option>
|
|
</MobileSelect>
|
|
</MobileField>
|
|
</YStack>
|
|
</MobileCard>
|
|
|
|
<MobileCard space="$3">
|
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
|
{t('events.branding.colors', 'Colors')}
|
|
</Text>
|
|
<YStack space="$3">
|
|
<ColorField
|
|
label={t('events.branding.primary', 'Primary Color')}
|
|
value={brandingForm.primary}
|
|
onChange={(value) => setBrandingForm((prev) => ({ ...prev, primary: value }))}
|
|
disabled={brandingDisabled}
|
|
/>
|
|
<ColorField
|
|
label={t('events.branding.accent', 'Accent Color')}
|
|
value={brandingForm.accent}
|
|
onChange={(value) => setBrandingForm((prev) => ({ ...prev, accent: value }))}
|
|
disabled={brandingDisabled}
|
|
/>
|
|
<ColorField
|
|
label={t('events.branding.backgroundColor', 'Background Color')}
|
|
value={brandingForm.background}
|
|
onChange={(value) => setBrandingForm((prev) => ({ ...prev, background: value }))}
|
|
disabled={brandingDisabled}
|
|
/>
|
|
<ColorField
|
|
label={t('events.branding.surfaceColor', 'Surface Color')}
|
|
value={brandingForm.surface}
|
|
onChange={(value) => setBrandingForm((prev) => ({ ...prev, surface: value }))}
|
|
disabled={brandingDisabled}
|
|
/>
|
|
</YStack>
|
|
</MobileCard>
|
|
|
|
<MobileCard space="$3">
|
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
|
{t('events.branding.fonts', 'Fonts')}
|
|
</Text>
|
|
<YStack space="$3">
|
|
<MobileField label={t('events.branding.headingFont', 'Headline Font')}>
|
|
<MobileInput
|
|
value={brandingForm.headingFont}
|
|
onChange={(event) => setBrandingForm((prev) => ({ ...prev, headingFont: event.target.value }))}
|
|
placeholder={t('events.branding.headingFontPlaceholder', 'Playfair Display')}
|
|
hasError={false}
|
|
disabled={brandingDisabled}
|
|
/>
|
|
</MobileField>
|
|
<MobileField label={t('events.branding.bodyFont', 'Body Font')}>
|
|
<MobileInput
|
|
value={brandingForm.bodyFont}
|
|
onChange={(event) => setBrandingForm((prev) => ({ ...prev, bodyFont: event.target.value }))}
|
|
placeholder={t('events.branding.bodyFontPlaceholder', 'Montserrat')}
|
|
hasError={false}
|
|
disabled={brandingDisabled}
|
|
/>
|
|
</MobileField>
|
|
</YStack>
|
|
</MobileCard>
|
|
|
|
<CTAButton
|
|
label={brandingSaving ? t('profile.branding.saving', 'Saving...') : t('profile.branding.save', 'Save defaults')}
|
|
onPress={handleBrandingSave}
|
|
disabled={brandingDisabled}
|
|
loading={brandingSaving}
|
|
/>
|
|
</>
|
|
) : (
|
|
<>
|
|
{error ? (
|
|
<MobileCard>
|
|
<Text fontWeight="700" color={danger}>
|
|
{error}
|
|
</Text>
|
|
</MobileCard>
|
|
) : null}
|
|
|
|
<MobileCard space="$3">
|
|
<XStack alignItems="center" space="$3">
|
|
<XStack
|
|
width={48}
|
|
height={48}
|
|
borderRadius={16}
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
backgroundColor={accentSoft}
|
|
>
|
|
<User size={20} color={primary} />
|
|
</XStack>
|
|
<YStack space="$1">
|
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
|
{form.name || profile?.email || t('profile.title', 'Profil')}
|
|
</Text>
|
|
<Text fontSize="$sm" color={muted}>
|
|
{form.email || profile?.email || '—'}
|
|
</Text>
|
|
</YStack>
|
|
</XStack>
|
|
<XStack alignItems="center" space="$2" flexWrap="wrap">
|
|
{profile?.email_verified ? (
|
|
<CheckCircle2 size={14} color={subtle} />
|
|
) : (
|
|
<MailWarning size={14} color={subtle} />
|
|
)}
|
|
<PillBadge tone={profile?.email_verified ? 'success' : 'warning'}>
|
|
{emailStatusLabel}
|
|
</PillBadge>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{emailHint}
|
|
</Text>
|
|
</XStack>
|
|
</MobileCard>
|
|
|
|
<MobileCard space="$3">
|
|
<XStack alignItems="center" space="$2">
|
|
<User size={16} color={text} />
|
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
|
{t('profile.sections.account.heading', 'Account-Informationen')}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$sm" color={muted}>
|
|
{t('profile.sections.account.description', 'Passe Anzeigename, E-Mail-Adresse und Sprache der Admin-Oberfläche an.')}
|
|
</Text>
|
|
{loading ? (
|
|
<Text fontSize="$sm" color={muted}>
|
|
{t('profile.loading', 'Lädt ...')}
|
|
</Text>
|
|
) : (
|
|
<YStack space="$3">
|
|
<MobileField label={t('profile.fields.name', 'Anzeigename')}>
|
|
<MobileInput
|
|
value={form.name}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
|
placeholder={t('profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')}
|
|
hasError={false}
|
|
/>
|
|
</MobileField>
|
|
<MobileField label={t('profile.fields.email', 'E-Mail-Adresse')}>
|
|
<MobileInput
|
|
value={form.email}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, email: event.target.value }))}
|
|
placeholder="mail@beispiel.de"
|
|
type="email"
|
|
hasError={false}
|
|
/>
|
|
</MobileField>
|
|
<MobileField label={t('profile.fields.locale', 'Bevorzugte Sprache')}>
|
|
<MobileSelect
|
|
value={form.preferredLocale}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, preferredLocale: event.target.value }))}
|
|
>
|
|
{LOCALE_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label ?? t(option.labelKey, option.fallback)}
|
|
</option>
|
|
))}
|
|
</MobileSelect>
|
|
</MobileField>
|
|
<CTAButton
|
|
label={t('profile.actions.save', 'Speichern')}
|
|
onPress={handleAccountSave}
|
|
disabled={savingAccount || loading}
|
|
loading={savingAccount}
|
|
/>
|
|
</YStack>
|
|
)}
|
|
</MobileCard>
|
|
|
|
<MobileCard space="$3">
|
|
<XStack alignItems="center" space="$2">
|
|
<Lock size={16} color={text} />
|
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
|
{t('profile.sections.password.heading', 'Passwort ändern')}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$sm" color={muted}>
|
|
{t('profile.sections.password.description', 'Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.')}
|
|
</Text>
|
|
<YStack space="$3">
|
|
<MobileField label={t('profile.fields.currentPassword', 'Aktuelles Passwort')}>
|
|
<MobileInput
|
|
value={form.currentPassword}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, currentPassword: event.target.value }))}
|
|
placeholder="••••••••"
|
|
type="password"
|
|
hasError={false}
|
|
/>
|
|
</MobileField>
|
|
<MobileField
|
|
label={t('profile.fields.newPassword', 'Neues Passwort')}
|
|
hint={t('profile.sections.password.hint', 'Nutze mindestens 8 Zeichen und kombiniere Buchstaben sowie Zahlen für mehr Sicherheit.')}
|
|
>
|
|
<MobileInput
|
|
value={form.password}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, password: event.target.value }))}
|
|
placeholder="••••••••"
|
|
type="password"
|
|
hasError={false}
|
|
/>
|
|
</MobileField>
|
|
<MobileField label={t('profile.fields.passwordConfirmation', 'Passwort bestätigen')}>
|
|
<MobileInput
|
|
value={form.passwordConfirmation}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, passwordConfirmation: event.target.value }))}
|
|
placeholder="••••••••"
|
|
type="password"
|
|
hasError={false}
|
|
/>
|
|
</MobileField>
|
|
<CTAButton
|
|
label={t('profile.actions.updatePassword', 'Passwort aktualisieren')}
|
|
onPress={handlePasswordSave}
|
|
disabled={!passwordReady || savingPassword || loading}
|
|
loading={savingPassword}
|
|
tone="ghost"
|
|
/>
|
|
</YStack>
|
|
</MobileCard>
|
|
</>
|
|
)}
|
|
</MobileShell>
|
|
);
|
|
}
|
|
|
|
function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
|
|
const { primary, surfaceMuted, border, surface, text } = useAdminTheme();
|
|
return (
|
|
<Pressable onPress={onPress} style={{ flex: 1 }}>
|
|
<XStack
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
paddingVertical="$2.5"
|
|
borderRadius={12}
|
|
backgroundColor={active ? primary : surfaceMuted}
|
|
borderWidth={1}
|
|
borderColor={active ? primary : border}
|
|
>
|
|
<Text fontSize="$sm" color={active ? surface : text} fontWeight="700">
|
|
{label}
|
|
</Text>
|
|
</XStack>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function ColorField({
|
|
label,
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
onChange: (next: string) => void;
|
|
disabled?: boolean;
|
|
}) {
|
|
const { text, muted } = useAdminTheme();
|
|
return (
|
|
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
|
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
{label}
|
|
</Text>
|
|
<XStack alignItems="center" space="$2">
|
|
<MobileColorInput
|
|
value={value}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
disabled={disabled}
|
|
/>
|
|
<Text fontSize="$sm" color={muted}>
|
|
{value}
|
|
</Text>
|
|
</XStack>
|
|
</YStack>
|
|
);
|
|
}
|