Files
DriverVault/Web App/web/src/views/Settings.vue
T
tajniak81andClaude Opus 4.8 b6bb6b1df0 Add a language-switch system with per-language files
Introduce a hand-rolled i18n layer across all three UIs, each reading its
text from per-language JSON files (English base + Polish + Danish). Nothing
in the converted screens hardcodes English any more.

- Web App (Vue): src/i18n/{en,pl,da}.json + index.js exposing t()/tSplit(),
  reactive to the signed-in profile locale. Every view, component, form and
  the status labels in lib/format.js go through t().
- API Server panel (Vue): src/i18n/ with its own localStorage-persisted
  language (the panel has no user profile) and a header language picker.
  Chrome, cards, login and API section titles translated; endpoint reference
  descriptions intentionally kept in English. Rebuilt embedded dist.
- Phone App (Flutter): assets/i18n/ + lib/i18n.dart loaded at startup,
  driven by AppSettings.locale. Nav, login, lock, dashboard, the full
  Settings panel (incl. language picker) and format.dart status labels
  translated; remaining detail screens fall back to English.

Language = the language half of the existing BCP-47 locale; the region half
still drives date/number/currency formatting. Missing keys fall back to
English, and plurals use Intl.PluralRules / Intl.plural so Polish gets the
correct one/few/many forms. Settings flags languages without a translation.

Tests updated to assert the localized (Polish) status wording; all pass.
See TRANSLATIONS.md for the format and how to add a language.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:07:48 +02:00

681 lines
26 KiB
Vue

<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
import { useRouter } from "vue-router";
import { api } from "../api";
import { state, logout, refreshProfile } from "../auth";
import { prefs, applyProfilePrefs } from "../prefs";
import { formatDate, formatMoney } from "../lib/format.js";
import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n";
const router = useRouter();
const loading = ref(true);
const loadError = ref("");
const profile = ref(null);
async function load() {
loading.value = true;
loadError.value = "";
try {
profile.value = await refreshProfile();
} catch (e) {
loadError.value = e.message;
} finally {
loading.value = false;
}
}
// --- Account: name ---
const nameDraft = ref("");
const nameSaving = ref(false);
const nameSaved = ref(false);
const nameError = ref("");
function initDrafts() {
nameDraft.value = profile.value.name || "";
bioDraft.value = profile.value.bio || "";
}
async function saveName() {
nameSaving.value = true;
nameError.value = "";
nameSaved.value = false;
try {
profile.value = await api.updateMe({ name: nameDraft.value.trim() });
// Keep the header's displayed name in sync.
state.user = { ...state.user, name: profile.value.name };
localStorage.setItem("cc_user", JSON.stringify(state.user));
nameSaved.value = true;
setTimeout(() => (nameSaved.value = false), 2000);
} catch (e) {
nameError.value = e.message;
} finally {
nameSaving.value = false;
}
}
// --- Account: email verification ---
const verifySending = ref(false);
const verifySent = ref(false);
const verifyError = ref("");
async function sendVerification() {
verifySending.value = true;
verifyError.value = "";
try {
await api.requestVerification();
verifySent.value = true;
} catch (e) {
verifyError.value = e.message;
} finally {
verifySending.value = false;
}
}
// --- Account: password change ---
const oldPassword = ref("");
const newPassword = ref("");
const confirmPassword = ref("");
const passwordSaving = ref(false);
const passwordSaved = ref(false);
const passwordError = ref("");
const passwordMismatch = computed(
() => confirmPassword.value.length > 0 && newPassword.value !== confirmPassword.value
);
async function savePassword() {
passwordError.value = "";
if (newPassword.value.length < 8) {
passwordError.value = t("settings.account.tooShort");
return;
}
if (passwordMismatch.value) {
passwordError.value = t("settings.account.mismatch");
return;
}
passwordSaving.value = true;
try {
await api.changePassword(oldPassword.value, newPassword.value);
oldPassword.value = "";
newPassword.value = "";
confirmPassword.value = "";
passwordSaved.value = true;
setTimeout(() => (passwordSaved.value = false), 2500);
} catch (e) {
passwordError.value = e.message;
} finally {
passwordSaving.value = false;
}
}
// --- Appearance (auto-saves on change) ---
const appearanceError = ref("");
const savingAppearance = ref(false);
async function saveAppearance(patch) {
appearanceError.value = "";
savingAppearance.value = true;
// Apply immediately for a responsive feel; roll back on failure.
const previous = { ...prefs };
applyProfilePrefs({ ...prefs, ...patch });
try {
profile.value = await api.updateMe(patch);
} catch (e) {
applyProfilePrefs(previous);
appearanceError.value = e.message;
} finally {
savingAppearance.value = false;
}
}
const dateFormatExample = computed(() => formatDate(new Date().toISOString()));
const currencyExample = computed(() => formatMoney(1234.5));
// Language and region are two controls over the one stored BCP-47 locale, so
// the pair can be mixed freely (English in Poland, say) rather than being
// limited to the handful of combinations a single list could offer.
//
// Europe here means the sovereign states of the Council of Europe, plus Belarus,
// Russia, Vatican City and Kosovo — geographically European but not members.
// Dependencies (Gibraltar, Faroes, Åland) are left out: they are not countries,
// and the languages/currencies they would add are already covered. US stays on
// for the region list because it was there before this became a Europe list.
const LANGUAGE_CODES = [
"sq", "hy", "az", "eu", "be", "bs", "bg", "ca", "hr", "cs", "da", "nl", "en",
"et", "fi", "fr", "gl", "ka", "de", "el", "hu", "is", "ga", "it", "lv", "lt",
"lb", "mk", "mt", "no", "pl", "pt", "ro", "rm", "ru", "sr", "sk", "sl", "es",
"sv", "tr", "uk", "cy",
];
const REGION_CODES = [
"AD", "AL", "AM", "AT", "AZ", "BA", "BE", "BG", "BY", "CH", "CY", "CZ", "DE",
"DK", "EE", "ES", "FI", "FR", "GB", "GE", "GR", "HR", "HU", "IE", "IS", "IT",
"LI", "LT", "LU", "LV", "MC", "MD", "ME", "MK", "MT", "NL", "NO", "PL", "PT",
"RO", "RS", "RU", "SE", "SI", "SK", "SM", "TR", "UA", "VA", "XK", "US",
];
// Mirrors validCurrencies in the API's me.go and the users.currency select in
// setup-pocketbase.mjs — all three have to list the same codes.
const CURRENCY_CODES = [
"EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK",
"ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB",
"TRY", "UAH", "USD", "CAD", "AUD", "JPY",
];
// Labels come from Intl rather than a hand-kept translation table, so the lists
// read in the user's own language ("Deutschland" once German is picked) and
// sort by what is actually on screen. If a runtime cannot name a code it falls
// back to the code itself, which is still selectable.
function named(codes, type, withCode = false) {
let dn = null;
try {
dn = new Intl.DisplayNames([prefs.locale || "en-US"], { type });
} catch {
dn = null;
}
return codes
.map((code) => {
const name = dn?.of(code) || code;
return { code, label: withCode && name !== code ? `${name} (${code})` : name };
})
.sort((a, b) => a.label.localeCompare(b.label, prefs.locale || undefined));
}
const LANGUAGES = computed(() => named(LANGUAGE_CODES, "language"));
const REGIONS = computed(() => named(REGION_CODES, "region"));
const CURRENCIES = computed(() => named(CURRENCY_CODES, "currency", true));
const language = computed(() => (prefs.locale || "en-US").split("-")[0]);
const region = computed(() => (prefs.locale || "en-US").split("-")[1] || "US");
// The picker offers every European language because the choice also drives date
// and number formatting, which Intl handles for all of them. Only a few have a
// translation file, though, so say so rather than letting someone pick Georgian
// and wonder why the buttons are still English.
const languageTranslated = computed(() => TRANSLATED_LANGUAGES.includes(language.value));
function saveLocale({ lang = language.value, reg = region.value }) {
return saveAppearance({ locale: `${lang}-${reg}` });
}
// --- Profile: avatar + bio ---
const avatarUrl = ref("");
const avatarUploading = ref(false);
const avatarError = ref("");
const fileInput = ref(null);
async function loadAvatar() {
if (!profile.value?.hasAvatar) {
avatarUrl.value = "";
return;
}
try {
const { blob } = await api.getAvatarBlob();
avatarUrl.value = URL.createObjectURL(blob);
} catch {
avatarUrl.value = "";
}
}
function pickAvatar() {
fileInput.value?.click();
}
async function onAvatarChosen(e) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
avatarUploading.value = true;
avatarError.value = "";
try {
profile.value = await api.uploadAvatar(file);
await loadAvatar();
} catch (err) {
avatarError.value = err.message;
} finally {
avatarUploading.value = false;
}
}
async function removeAvatar() {
avatarUploading.value = true;
avatarError.value = "";
try {
await api.deleteAvatar();
profile.value = { ...profile.value, hasAvatar: false };
avatarUrl.value = "";
} catch (err) {
avatarError.value = err.message;
} finally {
avatarUploading.value = false;
}
}
const bioDraft = ref("");
const bioSaving = ref(false);
const bioSaved = ref(false);
const bioError = ref("");
async function saveBio() {
bioSaving.value = true;
bioError.value = "";
try {
profile.value = await api.updateMe({ bio: bioDraft.value });
bioSaved.value = true;
setTimeout(() => (bioSaved.value = false), 2000);
} catch (e) {
bioError.value = e.message;
} finally {
bioSaving.value = false;
}
}
function onLogout() {
logout();
router.replace({ name: "login" });
}
// --- Advanced: export ---
const exporting = ref(false);
const exportError = ref("");
async function exportData() {
exporting.value = true;
exportError.value = "";
try {
const { blob, filename } = await api.exportData();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename || "drivervault-export.json";
a.click();
URL.revokeObjectURL(url);
} catch (e) {
exportError.value = e.message;
} finally {
exporting.value = false;
}
}
// --- Advanced: import ---
const importing = ref(false);
const importError = ref("");
const importResult = ref(null);
const importFileInput = ref(null);
function pickImportFile() {
importFileInput.value?.click();
}
async function onImportFileChosen(e) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
importError.value = "";
importResult.value = null;
let payload;
try {
payload = JSON.parse(await file.text());
} catch {
importError.value = t("settings.advanced.notJson");
return;
}
if (!Array.isArray(payload?.cars) || payload.cars.length === 0) {
importError.value = t("settings.advanced.notExport");
return;
}
if (!confirm(t("settings.advanced.confirmImport", { count: payload.cars.length }))) {
return;
}
importing.value = true;
try {
importResult.value = await api.importData(payload);
} catch (err) {
importError.value = err.message;
} finally {
importing.value = false;
}
}
// --- Danger zone: delete account (typed confirmation + cooldown) ---
const showDeleteConfirm = ref(false);
const deleteConfirmEmail = ref("");
const deleteRequesting = ref(false);
const deleteError = ref("");
const eligibleAt = ref(null); // set once a deletion request succeeds this session
const deletionPending = computed(() => !!profile.value?.deletionRequestedAt);
const cooldownElapsed = computed(() => {
if (!deletionPending.value) return false;
const eligible = eligibleAt.value || new Date(new Date(profile.value.deletionRequestedAt).getTime() + 3 * 24 * 60 * 60 * 1000);
return new Date() >= eligible;
});
const canRequestDelete = computed(
() => deleteConfirmEmail.value.trim().toLowerCase() === (profile.value?.email || "").toLowerCase()
);
async function requestDeletion() {
if (!canRequestDelete.value) return;
deleteRequesting.value = true;
deleteError.value = "";
try {
const res = await api.requestAccountDeletion(deleteConfirmEmail.value.trim());
eligibleAt.value = new Date(res.eligibleAt);
profile.value = { ...profile.value, deletionRequestedAt: new Date().toISOString() };
showDeleteConfirm.value = false;
deleteConfirmEmail.value = "";
} catch (e) {
deleteError.value = e.message;
} finally {
deleteRequesting.value = false;
}
}
async function cancelDeletion() {
deleteError.value = "";
try {
await api.cancelAccountDeletion();
profile.value = { ...profile.value, deletionRequestedAt: null };
eligibleAt.value = null;
} catch (e) {
deleteError.value = e.message;
}
}
async function finalizeDeletion() {
if (!confirm(t("settings.danger.confirmFinalize"))) return;
deleteError.value = "";
try {
await api.finalizeAccountDeletion();
onLogout();
} catch (e) {
deleteError.value = e.message;
}
}
onMounted(async () => {
await load();
initDrafts();
await loadAvatar();
});
onBeforeUnmount(() => {
if (avatarUrl.value) URL.revokeObjectURL(avatarUrl.value);
});
</script>
<template>
<div class="mx-auto max-w-3xl">
<div class="mb-6">
<p class="eyebrow">{{ t("settings.eyebrow") }}</p>
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">{{ t("settings.title") }}</h1>
<p class="mt-1 text-sm text-muted">{{ t("settings.subtitle") }}</p>
</div>
<p v-if="loadError" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ loadError }}</p>
<p v-if="loading" class="text-muted">{{ t("common.loading") }}</p>
<div v-else-if="profile" class="space-y-6">
<!-- Account -->
<section class="dh-card p-6">
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.account.title") }}</h2>
<div class="mb-5">
<label class="dh-label">{{ t("settings.account.name") }}</label>
<div class="flex gap-2">
<input v-model="nameDraft" class="dh-input max-w-sm" />
<button class="dh-btn dh-btn-primary" :disabled="nameSaving || !nameDraft.trim()" @click="saveName">
{{ nameSaving ? t("common.saving") : nameSaved ? t("common.saved") : t("common.save") }}
</button>
</div>
<p v-if="nameError" class="mt-1 text-sm text-danger">{{ nameError }}</p>
</div>
<div class="mb-5">
<label class="dh-label">{{ t("settings.account.email") }}</label>
<div class="flex flex-wrap items-center gap-2">
<span class="data rounded-control border border-subtle bg-sunken px-3 py-2 text-sm text-body">
{{ profile.email }}
</span>
<span class="dh-badge" :class="profile.verified ? 'dh-badge-success' : 'dh-badge-warning'">
{{ profile.verified ? t("settings.account.verified") : t("settings.account.notVerified") }}
</span>
<button
v-if="!profile.verified && !verifySent"
class="text-sm font-medium text-brandtext hover:underline disabled:opacity-50"
:disabled="verifySending"
@click="sendVerification"
>
{{ verifySending ? t("settings.account.sending") : t("settings.account.resendVerification") }}
</button>
<span v-if="verifySent" class="text-sm text-muted">{{ t("settings.account.verificationRequested") }}</span>
</div>
<p v-if="verifyError" class="mt-1 text-sm text-danger">{{ verifyError }}</p>
</div>
<div>
<h3 class="mb-2 text-sm font-semibold text-strong">{{ t("settings.account.changePassword") }}</h3>
<div class="grid max-w-sm gap-2">
<input v-model="oldPassword" type="password" :placeholder="t('settings.account.currentPassword')" autocomplete="current-password" class="dh-input" />
<input v-model="newPassword" type="password" :placeholder="t('settings.account.newPassword')" autocomplete="new-password" class="dh-input" />
<input v-model="confirmPassword" type="password" :placeholder="t('settings.account.confirmNewPassword')" autocomplete="new-password" class="dh-input" />
</div>
<p v-if="passwordMismatch" class="mt-1 text-sm text-warning">{{ t("settings.account.mismatchYet") }}</p>
<p v-if="passwordError" class="mt-1 text-sm text-danger">{{ passwordError }}</p>
<button class="dh-btn dh-btn-ghost mt-2" :disabled="passwordSaving || !oldPassword || !newPassword" @click="savePassword">
{{ passwordSaving ? t("settings.account.updating") : passwordSaved ? t("settings.account.passwordUpdated") : t("settings.account.updatePassword") }}
</button>
</div>
</section>
<!-- Appearance -->
<section class="dh-card p-6">
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.appearance.title") }}</h2>
<div class="mb-5">
<label class="dh-label">{{ t("settings.appearance.theme") }}</label>
<div class="flex gap-2">
<button
v-for="opt in ['light', 'dark', 'system']"
:key="opt"
class="rounded-control border px-3 py-1.5 text-sm font-medium transition-colors"
:class="prefs.theme === opt
? 'border-accent bg-accent text-white'
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
@click="saveAppearance({ theme: opt })"
>
{{ t(`settings.appearance.theme${opt.charAt(0).toUpperCase() + opt.slice(1)}`) }}
</button>
</div>
</div>
<div class="mb-5 grid gap-4 sm:grid-cols-2">
<div>
<label class="dh-label">{{ t("settings.appearance.language") }}</label>
<select :value="language" class="dh-input" @change="saveLocale({ lang: $event.target.value })">
<option v-for="l in LANGUAGES" :key="l.code" :value="l.code">{{ l.label }}</option>
</select>
<p class="mt-1 text-xs" :class="languageTranslated ? 'text-muted' : 'text-warning'">
{{ languageTranslated ? t("settings.appearance.languageHint") : t("settings.appearance.languageFallbackHint") }}
</p>
</div>
<div>
<label class="dh-label">{{ t("settings.appearance.region") }}</label>
<select :value="region" class="dh-input" @change="saveLocale({ reg: $event.target.value })">
<option v-for="r in REGIONS" :key="r.code" :value="r.code">{{ r.label }}</option>
</select>
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.regionHint") }}</p>
</div>
<div>
<label class="dh-label">{{ t("settings.appearance.dateFormat") }}</label>
<select :value="prefs.dateFormat" class="dh-input" @change="saveAppearance({ dateFormat: $event.target.value })">
<option value="YMD">YYYY-MM-DD</option>
<option value="DMY_NUM">DD-MM-YYYY</option>
<option value="DMY">DD Mon YYYY</option>
<option value="MDY">Mon DD, YYYY</option>
</select>
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.dateExample", { example: dateFormatExample }) }}</p>
</div>
<div>
<label class="dh-label">{{ t("settings.appearance.currency") }}</label>
<select :value="prefs.currency" class="dh-input" @change="saveAppearance({ currency: $event.target.value })">
<option v-for="c in CURRENCIES" :key="c.code" :value="c.code">{{ c.label }}</option>
</select>
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.currencyExample", { example: currencyExample }) }}</p>
</div>
</div>
<div>
<label class="dh-label">{{ t("settings.appearance.fontSize") }}</label>
<div class="flex gap-2">
<button
v-for="f in ['small', 'medium', 'large']"
:key="f"
class="rounded-control border px-3 py-1.5 text-sm font-medium transition-colors"
:class="prefs.fontSize === f
? 'border-accent bg-accent text-white'
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
@click="saveAppearance({ fontSize: f })"
>
{{ t(`settings.appearance.font${f.charAt(0).toUpperCase() + f.slice(1)}`) }}
</button>
</div>
</div>
<p v-if="appearanceError" class="mt-3 text-sm text-danger">{{ appearanceError }}</p>
</section>
<!-- Profile -->
<section class="dh-card p-6">
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.profile.title") }}</h2>
<div class="mb-5 flex items-center gap-4">
<img v-if="avatarUrl" :src="avatarUrl" :alt="t('settings.profile.avatarAlt')" class="h-16 w-16 rounded-full object-cover ring-1 ring-subtle" />
<div v-else class="grid h-16 w-16 place-items-center rounded-full bg-brand-100 text-xl font-bold text-brandtext">
{{ (profile.name || profile.email || "?").charAt(0).toUpperCase() }}
</div>
<div class="flex gap-2">
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5" :disabled="avatarUploading" @click="pickAvatar">
{{ avatarUploading ? t("settings.profile.uploading") : t("settings.profile.uploadPhoto") }}
</button>
<button v-if="profile.hasAvatar" class="dh-btn dh-btn-ghost !px-3 !py-1.5" :disabled="avatarUploading" @click="removeAvatar">
{{ t("common.remove") }}
</button>
</div>
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" class="hidden" @change="onAvatarChosen" />
</div>
<p v-if="avatarError" class="mb-4 text-sm text-danger">{{ avatarError }}</p>
<div>
<label class="dh-label">{{ t("settings.profile.bio") }}</label>
<textarea
v-model="bioDraft"
rows="3"
:placeholder="t('settings.profile.bioPlaceholder')"
class="dh-input"
/>
<p v-if="bioError" class="mt-1 text-sm text-danger">{{ bioError }}</p>
<button class="dh-btn dh-btn-ghost mt-2" :disabled="bioSaving" @click="saveBio">
{{ bioSaving ? t("common.saving") : bioSaved ? t("common.saved") : t("settings.profile.saveBio") }}
</button>
</div>
</section>
<!-- Privacy & Security -->
<section class="dh-card p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.privacy.title") }}</h2>
<button class="text-sm font-medium text-danger hover:underline" @click="onLogout">
{{ t("settings.privacy.signOut") }}
</button>
</div>
<p class="text-sm text-muted">{{ t("settings.privacy.body") }}</p>
</section>
<!-- Advanced / Danger Zone -->
<section class="dh-card p-6">
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.advanced.title") }}</h2>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-strong">{{ t("settings.advanced.exportTitle") }}</p>
<p class="text-xs text-muted">{{ t("settings.advanced.exportBody") }}</p>
</div>
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5 shrink-0" :disabled="exporting" @click="exportData">
{{ exporting ? t("settings.advanced.preparing") : t("settings.advanced.exportAction") }}
</button>
</div>
<p v-if="exportError" class="mt-2 text-sm text-danger">{{ exportError }}</p>
<div class="mt-4 flex items-center justify-between gap-3 border-t border-subtle pt-4">
<div>
<p class="text-sm font-medium text-strong">{{ t("settings.advanced.importTitle") }}</p>
<p class="text-xs text-muted">{{ t("settings.advanced.importBody") }}</p>
</div>
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5 shrink-0" :disabled="importing" @click="pickImportFile">
{{ importing ? t("settings.advanced.importing") : t("settings.advanced.importAction") }}
</button>
<input ref="importFileInput" type="file" accept="application/json,.json" class="hidden" @change="onImportFileChosen" />
</div>
<p v-if="importResult" class="mt-2 text-sm font-medium text-success">
{{ t("settings.advanced.imported", { cars: importResult.carsImported, services: importResult.servicesImported, parts: importResult.partsImported }) }}
</p>
<p v-if="importError" class="mt-2 text-sm text-danger">{{ importError }}</p>
</section>
<section class="rounded-card border border-danger/30 bg-danger-soft p-6">
<h2 class="mb-2 text-lg font-bold tracking-[-0.02em] text-danger">{{ t("settings.danger.title") }}</h2>
<template v-if="!deletionPending">
<p class="mb-3 text-sm text-danger/90">{{ t("settings.danger.body") }}</p>
<button class="dh-btn !border !border-danger/40 !bg-transparent !text-danger hover:!bg-danger/10" @click="showDeleteConfirm = true">
{{ t("settings.danger.deleteAccount") }}
</button>
<div v-if="showDeleteConfirm" class="mt-4 rounded-control border border-danger/30 bg-card p-4">
<label class="dh-label">
{{ tSplit("settings.danger.typeToConfirm", "email").before
}}<span class="data text-strong">{{ profile.email }}</span>{{ tSplit("settings.danger.typeToConfirm", "email").after }}
</label>
<input v-model="deleteConfirmEmail" :placeholder="profile.email" class="dh-input mb-3 max-w-sm" />
<div class="flex gap-2">
<button class="dh-btn dh-btn-ghost" @click="showDeleteConfirm = false; deleteConfirmEmail = ''">{{ t("common.cancel") }}</button>
<button :disabled="!canRequestDelete || deleteRequesting" class="dh-btn dh-btn-danger" @click="requestDeletion">
{{ deleteRequesting ? t("settings.danger.requesting") : t("settings.danger.requestDeletion") }}
</button>
</div>
</div>
</template>
<template v-else>
<p class="mb-3 text-sm text-danger/90">
{{ t("settings.danger.requestedOn", { date: formatDate(profile.deletionRequestedAt) }) }}
{{ cooldownElapsed ? t("settings.danger.cooldownPassed") : t("settings.danger.canStillCancel") }}
</p>
<div class="flex gap-2">
<button class="dh-btn dh-btn-ghost !bg-card" @click="cancelDeletion">{{ t("settings.danger.cancelRequest") }}</button>
<button v-if="cooldownElapsed" class="dh-btn dh-btn-danger" @click="finalizeDeletion">
{{ t("settings.danger.finalize") }}
</button>
</div>
</template>
<p v-if="deleteError" class="mt-3 text-sm font-medium text-danger">{{ deleteError }}</p>
</section>
</div>
</div>
</template>