Files
thebet365/apps/player/src/views/MobileProfileEditView.vue

565 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { playerAvatarUrl, randomAvatarKey } from '@thebet365/shared';
import api from '../api';
import PlayerAvatarModal from '../components/PlayerAvatarModal.vue';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePlayerProfile } from '../composables/usePlayerProfile';
import { useAuthStore } from '../stores/auth';
import { usePullToRefresh } from '../composables/usePullToRefresh';
const { t } = useI18n();
const router = useRouter();
const auth = useAuthStore();
const { loadProfile, setAvatarKey, profileRaw, avatarUrl, avatarKey } = usePlayerProfile();
const username = ref('');
const viewablePassword = ref('');
const passwordVisible = ref(false);
const phone = ref('');
const email = ref('');
const avatarModalOpen = ref(false);
const passwordChangeOpen = ref(false);
const oldPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const message = ref('');
const error = ref('');
const saving = ref(false);
const allowPasswordChange = computed(
() => profileRaw.value?.preferences?.allowPasswordChange ?? true,
);
const allowUsernameChange = computed(
() => profileRaw.value?.preferences?.allowUsernameChange ?? false,
);
const passwordDisplay = computed(() => viewablePassword.value || '');
const canTogglePassword = computed(() => !!viewablePassword.value);
const passwordInputType = computed(() =>
passwordVisible.value && canTogglePassword.value ? 'text' : 'password',
);
const displayAvatarUrl = computed(() => {
if (avatarUrl.value) return avatarUrl.value;
const seed = profileRaw.value?.username ?? auth.user?.username;
return seed ? playerAvatarUrl(randomAvatarKey(seed)) : null;
});
function syncFromProfile() {
const user = profileRaw.value;
username.value = user?.username ?? auth.user?.username ?? '';
viewablePassword.value = user?.preferences?.viewablePassword ?? '';
phone.value = user?.preferences?.phone ?? '';
email.value = user?.preferences?.email ?? '';
}
function togglePasswordVisible() {
if (!canTogglePassword.value) return;
passwordVisible.value = !passwordVisible.value;
}
async function loadEditData() {
await loadProfile(true);
syncFromProfile();
}
onMounted(loadEditData);
const { pullDistance, spinning, progress } = usePullToRefresh({
onRefresh: loadEditData,
});
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
opacity: Math.min(pullDistance.value / 48, 1),
});
function openAvatarModal() {
avatarModalOpen.value = true;
}
async function confirmAvatar(key: string | null) {
avatarModalOpen.value = false;
try {
await api.patch('/player/profile', { avatarKey: key });
setAvatarKey(key);
} catch (e: unknown) {
setAvatarKey(key);
error.value =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ||
t('profile.save_failed');
}
}
function wantsPasswordChange() {
return !!(oldPassword.value || newPassword.value || confirmPassword.value);
}
async function saveAll() {
error.value = '';
message.value = '';
if (wantsPasswordChange()) {
if (!oldPassword.value || !newPassword.value || !confirmPassword.value) {
error.value = t('profile.password_incomplete');
return;
}
if (newPassword.value !== confirmPassword.value) {
error.value = t('profile.password_mismatch');
return;
}
}
saving.value = true;
const parts: string[] = [];
try {
const profilePayload: { phone?: string; email?: string; username?: string } = {
phone: phone.value.trim() || undefined,
email: email.value.trim() || undefined,
};
if (allowUsernameChange.value) {
profilePayload.username = username.value.trim();
}
await api.patch('/player/profile', profilePayload);
if (allowUsernameChange.value && username.value.trim() && auth.user) {
auth.user.username = username.value.trim();
}
parts.push(t('profile.saved'));
if (allowUsernameChange.value && profilePayload.username) {
parts.push(t('profile.username_updated'));
}
} catch (e: unknown) {
error.value =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ||
t('profile.save_failed');
saving.value = false;
return;
}
if (wantsPasswordChange()) {
if (!allowPasswordChange.value) {
error.value = t('profile.password_disabled');
saving.value = false;
return;
}
try {
await api.post('/player/auth/change-password', {
oldPassword: oldPassword.value,
newPassword: newPassword.value,
});
viewablePassword.value = newPassword.value;
passwordVisible.value = false;
oldPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
passwordChangeOpen.value = false;
parts.push(t('profile.password_changed'));
} catch (e: unknown) {
error.value =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ||
t('profile.password_failed');
saving.value = false;
return;
}
}
message.value = parts.join(' · ');
saving.value = false;
}
function back() {
router.push('/profile');
}
</script>
<template>
<div class="edit-page">
<div
class="pull-indicator"
:style="pullIndicatorStyle()"
>
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<header class="page-head sub-toolbar">
<button type="button" class="back-btn" @click="back"> {{ t('profile.back') }}</button>
<h2 class="page-title">{{ t('profile.edit') }}</h2>
</header>
<section class="avatar-card">
<div class="avatar-circle">
<img v-if="displayAvatarUrl" :src="displayAvatarUrl" alt="" class="avatar-img" />
</div>
<button type="button" class="avatar-change-btn" @click="openAvatarModal">
{{ t('profile.avatar_change') }}
</button>
</section>
<form class="form-card" @submit.prevent="saveAll">
<h3 class="section-title">{{ t('profile.section_account') }}</h3>
<div class="field">
<label>{{ t('auth.username') }}</label>
<input
v-model="username"
class="field-input"
:class="{ readonly: !allowUsernameChange }"
:disabled="!allowUsernameChange"
:placeholder="t('profile.username_placeholder')"
/>
<p v-if="!allowUsernameChange" class="field-hint inline-hint">{{ t('profile.username_readonly_hint') }}</p>
</div>
<div class="field">
<label>{{ t('auth.password') }}</label>
<div class="input-eye-wrap">
<input
:type="passwordInputType"
:value="passwordDisplay"
class="field-input input-with-eye"
readonly
:placeholder="canTogglePassword ? '' : t('profile.password_unavailable')"
/>
<button
type="button"
class="eye-btn"
:disabled="!canTogglePassword"
:aria-label="passwordVisible ? t('profile.hide_password') : t('profile.show_password')"
@click="togglePasswordVisible"
>
{{ passwordVisible ? t('profile.hide_password') : t('profile.show_password') }}
</button>
</div>
<p v-if="!canTogglePassword" class="field-hint inline-hint">{{ t('profile.password_unavailable_hint') }}</p>
</div>
<template v-if="allowPasswordChange">
<button type="button" class="section-toggle compact-toggle" @click="passwordChangeOpen = !passwordChangeOpen">
<span>{{ t('profile.change_password') }}</span>
<span class="chevron" :class="{ open: passwordChangeOpen }"></span>
</button>
<div v-show="passwordChangeOpen" class="password-block">
<div class="field">
<label>{{ t('profile.old_password') }}</label>
<input
v-model="oldPassword"
type="password"
class="field-input"
autocomplete="current-password"
:placeholder="t('profile.old_password_placeholder')"
/>
</div>
<div class="field">
<label>{{ t('profile.new_password') }}</label>
<input
v-model="newPassword"
type="password"
class="field-input"
autocomplete="new-password"
:placeholder="t('profile.new_password_placeholder')"
/>
</div>
<div class="field">
<label>{{ t('profile.confirm_password') }}</label>
<input
v-model="confirmPassword"
type="password"
class="field-input"
autocomplete="new-password"
:placeholder="t('profile.confirm_password_placeholder')"
/>
</div>
</div>
</template>
<div class="section-divider" />
<h3 class="section-title">{{ t('profile.section_contact') }}</h3>
<div class="field">
<label>{{ t('profile.phone') }}</label>
<input v-model="phone" type="tel" class="field-input" :placeholder="t('profile.phone_placeholder')" />
</div>
<div class="field field-last">
<label>{{ t('profile.email') }}</label>
<input v-model="email" type="email" class="field-input" :placeholder="t('profile.email_placeholder')" />
</div>
<button type="submit" class="btn-action btn-gold-outline" :disabled="saving">
{{ t('profile.save') }}
</button>
</form>
<PlayerAvatarModal
:open="avatarModalOpen"
:model-value="avatarKey"
@close="avatarModalOpen = false"
@confirm="confirmAvatar"
/>
<p v-if="message" class="msg ok">{{ message }}</p>
<p v-if="error" class="msg err">{{ error }}</p>
</div>
</template>
<style scoped>
.edit-page {
padding: 0 0 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.pull-indicator {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: height 0.15s ease;
}
.page-head {
margin-bottom: 0;
}
.back-btn {
background: none;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
padding: 0;
}
.page-title {
font-size: 16px;
font-weight: 800;
color: var(--primary-light);
letter-spacing: 0.04em;
}
.avatar-card,
.form-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.avatar-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 16px;
}
.avatar-circle {
width: 80px;
height: 80px;
border-radius: 50%;
border: 2px solid var(--border-gold-soft);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: var(--gradient-card);
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
}
.avatar-change-btn {
padding: 7px 18px;
border-radius: 999px;
border: 1px solid var(--border-gold-soft);
background: var(--surface-accent);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
}
.form-card {
padding: var(--space-card);
}
.section-title {
margin: 0 0 14px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
}
.section-divider {
height: 1px;
background: var(--border);
margin: 6px 0 12px;
}
.section-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 0 12px;
background: none;
color: var(--text);
font-size: 14px;
font-weight: 700;
}
.chevron {
color: var(--text-muted);
font-size: 20px;
line-height: 1;
transition: transform 0.15s ease;
}
.chevron.open {
transform: rotate(90deg);
}
.password-block {
padding-bottom: 4px;
}
.field {
margin-bottom: 14px;
}
.field-last {
margin-bottom: 18px;
}
.field-hint {
font-size: 11px;
color: var(--text-muted);
margin: 6px 0 0;
line-height: 1.45;
}
.inline-hint {
margin-bottom: 0;
}
.input-eye-wrap {
position: relative;
display: flex;
align-items: stretch;
}
.input-with-eye {
padding-right: 52px;
}
.eye-btn {
position: absolute;
right: 0;
top: 0;
bottom: 0;
min-width: 48px;
padding: 0 10px;
border: none;
border-left: 1px solid var(--border);
border-radius: 0 6px 6px 0;
background: var(--surface-accent);
color: var(--primary-light);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
}
.eye-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.compact-toggle {
padding: 2px 0 10px;
font-size: 13px;
}
label {
display: block;
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
margin-bottom: 6px;
}
.field-input,
.readonly {
display: block;
width: 100%;
padding: 10px 12px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--text);
transition: border-color 0.2s;
}
.field-input:focus {
outline: none;
border-color: var(--border-gold-soft);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15);
}
.field-input:-webkit-autofill,
.field-input:-webkit-autofill:hover,
.field-input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--text);
box-shadow: 0 0 0 1000px var(--bg-input) inset;
border: 1px solid var(--border);
}
.field-input::placeholder {
color: var(--neutral);
}
.readonly {
opacity: 0.55;
cursor: not-allowed;
}
.btn-action {
width: 100%;
margin-top: 4px;
padding: 12px 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 800;
letter-spacing: 0.04em;
}
.btn-action:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.msg {
margin-top: 0;
font-size: 13px;
font-weight: 600;
}
.msg.ok {
color: var(--primary-light);
}
.msg.err {
color: var(--danger);
}
</style>