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>
This commit is contained in:
1 parent
ee28b522c7
commit
b6bb6b1df0
54 files changed
+4191
-979
No files matched your search
@@ -5,6 +5,7 @@ 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();
|
||||
|
||||
@@ -89,11 +90,11 @@ const passwordMismatch = computed(
|
||||
async function savePassword() {
|
||||
passwordError.value = "";
|
||||
if (newPassword.value.length < 8) {
|
||||
passwordError.value = "New password must be at least 8 characters.";
|
||||
passwordError.value = t("settings.account.tooShort");
|
||||
return;
|
||||
}
|
||||
if (passwordMismatch.value) {
|
||||
passwordError.value = "New password and confirmation don't match.";
|
||||
passwordError.value = t("settings.account.mismatch");
|
||||
return;
|
||||
}
|
||||
passwordSaving.value = true;
|
||||
@@ -190,6 +191,12 @@ 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}` });
|
||||
}
|
||||
@@ -318,18 +325,14 @@ async function onImportFileChosen(e) {
|
||||
try {
|
||||
payload = JSON.parse(await file.text());
|
||||
} catch {
|
||||
importError.value = "That file isn't valid JSON.";
|
||||
importError.value = t("settings.advanced.notJson");
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(payload?.cars) || payload.cars.length === 0) {
|
||||
importError.value = "That file doesn't look like a DriverVault export (missing a \"cars\" list).";
|
||||
importError.value = t("settings.advanced.notExport");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!confirm(
|
||||
`Import ${payload.cars.length} car(s) from this file? This adds new records — it does not merge with or overwrite existing cars.`
|
||||
)
|
||||
) {
|
||||
if (!confirm(t("settings.advanced.confirmImport", { count: payload.cars.length }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -390,7 +393,7 @@ async function cancelDeletion() {
|
||||
}
|
||||
|
||||
async function finalizeDeletion() {
|
||||
if (!confirm("This permanently deletes your account. This cannot be undone. Continue?")) return;
|
||||
if (!confirm(t("settings.danger.confirmFinalize"))) return;
|
||||
deleteError.value = "";
|
||||
try {
|
||||
await api.finalizeAccountDeletion();
|
||||
@@ -414,38 +417,38 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="mb-6">
|
||||
<p class="eyebrow">Account</p>
|
||||
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">Settings</h1>
|
||||
<p class="mt-1 text-sm text-muted">Manage your account, appearance, and data.</p>
|
||||
<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">Loading…</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">Account</h2>
|
||||
<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">Name</label>
|
||||
<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 ? "Saving…" : nameSaved ? "Saved ✓" : "Save" }}
|
||||
{{ 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">Email</label>
|
||||
<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 ? "Verified" : "Not verified" }}
|
||||
{{ profile.verified ? t("settings.account.verified") : t("settings.account.notVerified") }}
|
||||
</span>
|
||||
<button
|
||||
v-if="!profile.verified && !verifySent"
|
||||
@@ -453,101 +456,101 @@ onBeforeUnmount(() => {
|
||||
:disabled="verifySending"
|
||||
@click="sendVerification"
|
||||
>
|
||||
{{ verifySending ? "Sending…" : "Resend verification email" }}
|
||||
{{ verifySending ? t("settings.account.sending") : t("settings.account.resendVerification") }}
|
||||
</button>
|
||||
<span v-if="verifySent" class="text-sm text-muted">Verification email requested.</span>
|
||||
<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">Change password</h3>
|
||||
<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="Current password" autocomplete="current-password" class="dh-input" />
|
||||
<input v-model="newPassword" type="password" placeholder="New password" autocomplete="new-password" class="dh-input" />
|
||||
<input v-model="confirmPassword" type="password" placeholder="Confirm new password" autocomplete="new-password" class="dh-input" />
|
||||
<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">Passwords don't match yet.</p>
|
||||
<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 ? "Updating…" : passwordSaved ? "Password updated ✓" : "Update password" }}
|
||||
{{ 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">Appearance</h2>
|
||||
<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">Theme</label>
|
||||
<label class="dh-label">{{ t("settings.appearance.theme") }}</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="t in ['light', 'dark', 'system']"
|
||||
:key="t"
|
||||
class="rounded-control border px-3 py-1.5 text-sm font-medium capitalize transition-colors"
|
||||
:class="prefs.theme === t
|
||||
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: t })"
|
||||
@click="saveAppearance({ theme: opt })"
|
||||
>
|
||||
{{ t }}
|
||||
{{ 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">Language</label>
|
||||
<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 text-muted">Names of months and days.</p>
|
||||
<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">Region</label>
|
||||
<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">Number and currency layout.</p>
|
||||
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.regionHint") }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Date format</label>
|
||||
<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">Example: <span class="data">{{ dateFormatExample }}</span></p>
|
||||
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.dateExample", { example: dateFormatExample }) }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Currency</label>
|
||||
<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">
|
||||
Example: <span class="data">{{ currencyExample }}</span> — display only, no amounts are converted.
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.currencyExample", { example: currencyExample }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Font size</label>
|
||||
<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 capitalize transition-colors"
|
||||
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 })"
|
||||
>
|
||||
{{ f }}
|
||||
{{ t(`settings.appearance.font${f.charAt(0).toUpperCase() + f.slice(1)}`) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -557,19 +560,19 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- Profile -->
|
||||
<section class="dh-card p-6">
|
||||
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">Profile</h2>
|
||||
<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="Avatar" class="h-16 w-16 rounded-full object-cover ring-1 ring-subtle" />
|
||||
<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 ? "Uploading…" : "Upload photo" }}
|
||||
{{ 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">
|
||||
Remove
|
||||
{{ 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" />
|
||||
@@ -577,16 +580,16 @@ onBeforeUnmount(() => {
|
||||
<p v-if="avatarError" class="mb-4 text-sm text-danger">{{ avatarError }}</p>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Bio</label>
|
||||
<label class="dh-label">{{ t("settings.profile.bio") }}</label>
|
||||
<textarea
|
||||
v-model="bioDraft"
|
||||
rows="3"
|
||||
placeholder="A short note visible to other people in your household."
|
||||
: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 ? "Saving…" : bioSaved ? "Saved ✓" : "Save bio" }}
|
||||
{{ bioSaving ? t("common.saving") : bioSaved ? t("common.saved") : t("settings.profile.saveBio") }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -594,73 +597,64 @@ onBeforeUnmount(() => {
|
||||
<!-- 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">Privacy & security</h2>
|
||||
<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">
|
||||
Sign out
|
||||
{{ t("settings.privacy.signOut") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted">
|
||||
Two-factor authentication isn't available yet. Sessions are held as
|
||||
server-issued tokens that expire on their own, so signing out here ends
|
||||
this device's session only — there's no per-device list to revoke from.
|
||||
To lock out every device, change your password above.
|
||||
</p>
|
||||
<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">Advanced</h2>
|
||||
<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">Export your data</p>
|
||||
<p class="text-xs text-muted">Download your profile and all cars, service records, and parts as JSON.</p>
|
||||
<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 ? "Preparing…" : "Export data" }}
|
||||
{{ 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">Import your data</p>
|
||||
<p class="text-xs text-muted">
|
||||
Add cars from a previously exported JSON file. This creates new records — it doesn't merge with or overwrite anything existing.
|
||||
</p>
|
||||
<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 ? "Importing…" : "Import data" }}
|
||||
{{ 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">
|
||||
Imported {{ importResult.carsImported }} car(s), {{ importResult.servicesImported }} service record(s), {{ importResult.partsImported }} part(s).
|
||||
{{ 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">Danger zone</h2>
|
||||
<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">
|
||||
Deleting your account removes your login and profile. It does not delete your household's shared cars or
|
||||
service history. There's a 3-day cooldown before the deletion is final, and you can cancel any time before then.
|
||||
</p>
|
||||
<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">
|
||||
Delete my account
|
||||
{{ 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">
|
||||
Type <span class="data text-strong">{{ profile.email }}</span> to confirm
|
||||
{{ 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 = ''">Cancel</button>
|
||||
<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 ? "Requesting…" : "Request deletion" }}
|
||||
{{ deleteRequesting ? t("settings.danger.requesting") : t("settings.danger.requestDeletion") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -668,14 +662,13 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template v-else>
|
||||
<p class="mb-3 text-sm text-danger/90">
|
||||
Account deletion requested on {{ formatDate(profile.deletionRequestedAt) }}.
|
||||
<template v-if="!cooldownElapsed">You can still cancel — it becomes permanent after the 3-day cooldown.</template>
|
||||
<template v-else>The cooldown has passed. You can now finalize the deletion.</template>
|
||||
{{ 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">Cancel deletion request</button>
|
||||
<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">
|
||||
Permanently delete my account
|
||||
{{ t("settings.danger.finalize") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in new issue
Block a user