Creating & Edit profile

This commit is contained in:
2026-09-15 14:10:52 +03:30
parent 8dcf4ed5e9
commit 9ae176bec0
13 changed files with 767 additions and 77 deletions
+320 -26
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
import type { AskMentoResponse } from '@mento/shared';
@@ -68,13 +69,24 @@ const tab = ref<TabKey>('summary');
const aiBrief = ref('');
const moreItems: ProfileMoreItem[] = [
{ key: 'info', label: 'ویرایش اطلاعات', to: { name: 'profile', query: { tab: 'info' } } },
{ key: 'info', label: 'ویرایش اطلاعات' },
{ key: 'activity', label: 'فعالیت جدید', to: { name: 'activity-new' } },
{ key: 'deal', label: 'معامله جدید', to: { name: 'deal-new' } },
{ key: 'quote', label: 'پیش‌فاکتور جدید', to: { name: 'quote-new' } },
{ key: 'ticket', label: 'تیکت جدید', to: { name: 'ticket-new' } },
];
const infoFullName = ref('');
const infoMobile = ref('');
const infoEmail = ref('');
const infoSaving = ref(false);
const infoError = ref('');
const infoOk = ref('');
const nameInput = ref<HTMLInputElement | null>(null);
const avatarInput = ref<HTMLInputElement | null>(null);
const avatarBusy = ref(false);
const avatarError = ref('');
const name = computed(() => auth.user?.fullName || 'کاربر');
const roleLabel = computed(() =>
auth.user?.role ? USER_ROLE_LABELS[auth.user.role] : 'کاربر',
@@ -270,7 +282,73 @@ function readTab() {
function setTab(next: TabKey) {
tab.value = next;
moreOpen.value = false;
void router.replace({ query: { ...route.query, tab: next === 'summary' ? undefined : next } });
const query = { ...route.query };
if (next === 'summary') delete query.tab;
else query.tab = next;
void router.replace({ query });
if (next === 'info') revealInfoForm();
}
function onMoreSelect(key: string) {
if (key === 'info') setTab('info');
}
function revealInfoForm() {
void nextTick(() => {
nameInput.value?.scrollIntoView({ block: 'center', behavior: 'smooth' });
nameInput.value?.focus();
});
}
function apiErrorMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
function syncInfoForm() {
infoFullName.value = auth.user?.fullName ?? '';
infoMobile.value = auth.user?.mobile ?? '';
infoEmail.value = auth.user?.email ?? '';
infoError.value = '';
infoOk.value = '';
}
async function saveInfo() {
const fullName = infoFullName.value.trim();
const mobile = infoMobile.value.trim();
const email = infoEmail.value.trim();
if (fullName.length < 2) {
infoError.value = 'نام باید حداقل دو حرف باشد';
infoOk.value = '';
return;
}
if (!/^09\d{9}$|^\+98\d{10}$|^98\d{10}$|^9\d{9}$/.test(mobile.replace(/[\s\-()]/g, ''))) {
infoError.value = 'شماره موبایل معتبر نیست';
infoOk.value = '';
return;
}
if (!email.includes('@')) {
infoError.value = 'ایمیل معتبر نیست';
infoOk.value = '';
return;
}
infoSaving.value = true;
infoError.value = '';
infoOk.value = '';
try {
await auth.updateProfile({ fullName, mobile, email });
infoFullName.value = auth.user?.fullName ?? fullName;
infoMobile.value = auth.user?.mobile ?? mobile;
infoEmail.value = auth.user?.email ?? email;
infoOk.value = 'اطلاعات ذخیره شد';
} catch (e) {
infoError.value = apiErrorMessage(e, 'ذخیره ناموفق بود');
} finally {
infoSaving.value = false;
}
}
function closeMenus() {
@@ -283,6 +361,47 @@ function onSmartCta() {
void router.push({ name: 'activity-new', query: { type: 'call' } });
}
function openAvatarPicker() {
if (tab.value !== 'info' || avatarBusy.value) return;
avatarInput.value?.click();
}
async function onAvatarFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!file.type.startsWith('image/')) {
avatarError.value = 'فقط فایل تصویر انتخاب کنید';
return;
}
if (file.size > 2 * 1024 * 1024) {
avatarError.value = 'حجم تصویر باید کمتر از ۲ مگابایت باشد';
return;
}
avatarBusy.value = true;
avatarError.value = '';
try {
await auth.uploadAvatar(file);
} catch (e) {
avatarError.value = apiErrorMessage(e, 'آپلود عکس ناموفق بود');
} finally {
avatarBusy.value = false;
}
}
async function clearAvatar() {
avatarBusy.value = true;
avatarError.value = '';
try {
await auth.removeAvatar();
} catch (e) {
avatarError.value = apiErrorMessage(e, 'حذف عکس ناموفق بود');
} finally {
avatarBusy.value = false;
}
}
async function load() {
loading.value = true;
error.value = '';
@@ -324,6 +443,24 @@ async function loadAiBrief() {
watch(() => route.query.tab, readTab, { immediate: true });
watch(() => auth.user?.id, () => void load(), { immediate: true });
watch(
() => tab.value,
(next) => {
if (next === 'info') syncInfoForm();
},
{ immediate: true },
);
watch(
() =>
[auth.user?.fullName, auth.user?.mobile, auth.user?.email] as const,
() => {
if (tab.value === 'info' && !infoSaving.value) {
infoFullName.value = auth.user?.fullName ?? '';
infoMobile.value = auth.user?.mobile ?? '';
infoEmail.value = auth.user?.email ?? '';
}
},
);
</script>
<template>
@@ -333,7 +470,7 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
<UiIcon name="chevron" :size="18" />
</RouterLink>
<span class="nav-brand">mento</span>
<ProfileMoreMenu v-model="moreOpen" :items="moreItems" />
<ProfileMoreMenu v-model="moreOpen" :items="moreItems" @select="onMoreSelect" />
</header>
<p v-if="loading" class="muted pad">در حال بارگذاری…</p>
@@ -341,8 +478,25 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
<template v-else>
<div class="identity">
<div class="avatar online">
<img src="/brand/user-avatar.png" :alt="name" width="76" height="76" />
<div
class="avatar-edit"
:class="{ editable: tab === 'info' }"
@click.stop="openAvatarPicker"
>
<span class="avatar">
<img :src="auth.avatarSrc" :alt="name" width="76" height="76" />
</span>
<i class="online-dot" title="آنلاین" aria-hidden="true" />
<span v-if="tab === 'info'" class="camera-badge" aria-hidden="true">
<UiIcon name="camera" :size="13" />
</span>
<input
ref="avatarInput"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
hidden
@change="onAvatarFile"
/>
</div>
<div class="who">
<h1>{{ name }}</h1>
@@ -350,6 +504,16 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
<div class="badges">
<span class="pill active">آنلاین</span>
</div>
<p v-if="avatarError && tab === 'info'" class="error">{{ avatarError }}</p>
<button
v-if="tab === 'info' && auth.user?.hasAvatar"
class="avatar-clear"
type="button"
:disabled="avatarBusy"
@click.stop="clearAvatar"
>
حذف عکس
</button>
</div>
</div>
@@ -531,11 +695,49 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
</div>
<div v-show="tab === 'info'" class="panel info-panel">
<dl>
<div><dt>نام</dt><dd>{{ name }}</dd></div>
<div><dt>ایمیل</dt><dd>{{ auth.user?.email || '—' }}</dd></div>
<div><dt>نقش</dt><dd>{{ roleLabel }}</dd></div>
</dl>
<form class="info-form" @submit.prevent="saveInfo">
<label class="info-row">
<span>نام</span>
<input
ref="nameInput"
v-model="infoFullName"
type="text"
autocomplete="name"
maxlength="80"
/>
</label>
<label class="info-row">
<span>موبایل</span>
<input
v-model="infoMobile"
type="tel"
inputmode="tel"
dir="ltr"
autocomplete="tel"
maxlength="14"
placeholder="09121234567"
/>
</label>
<label class="info-row">
<span>ایمیل</span>
<input
v-model="infoEmail"
type="email"
dir="ltr"
autocomplete="email"
maxlength="120"
/>
</label>
<div class="info-row">
<span>نقش</span>
<b>{{ roleLabel }}</b>
</div>
<p v-if="infoError" class="error">{{ infoError }}</p>
<p v-else-if="infoOk" class="ok">{{ infoOk }}</p>
<button class="save-btn" type="submit" :disabled="infoSaving">
{{ infoSaving ? 'در حال ذخیره…' : 'ذخیره تغییرات' }}
</button>
</form>
</div>
</template>
@@ -680,13 +882,23 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
min-width: 0;
}
.avatar {
.avatar-edit {
position: relative;
width: 76px;
height: 76px;
flex-shrink: 0;
}
.avatar-edit.editable {
cursor: pointer;
}
.avatar {
display: block;
width: 76px;
height: 76px;
border-radius: 999px;
overflow: hidden;
flex-shrink: 0;
box-shadow: 0 8px 18px rgba(13, 148, 136, 0.28);
}
@@ -697,16 +909,49 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
display: block;
}
.avatar.online::after {
content: '';
.online-dot {
position: absolute;
width: 14px;
height: 14px;
bottom: 2px;
inset-inline-start: 2px;
z-index: 2;
width: 16px;
height: 16px;
border-radius: 999px;
background: #22c55e;
border: 3px solid #eef2f6;
box-sizing: border-box;
pointer-events: none;
}
.camera-badge {
position: absolute;
inset-inline-end: 0;
bottom: 0;
z-index: 3;
width: 26px;
height: 26px;
border-radius: 999px;
inset-inline-start: 4px;
bottom: 4px;
background: #0f766e;
color: #fff;
display: grid;
place-items: center;
box-shadow: 0 0 0 3px #eef2f6;
pointer-events: none;
}
.avatar-clear {
margin: 0.15rem 0 0;
border: 0;
background: none;
padding: 0;
color: #64748b;
font: inherit;
font-size: 0.78rem;
cursor: pointer;
}
.avatar-clear:hover {
color: #b91c1c;
}
.who {
@@ -1045,28 +1290,70 @@ h1 {
gap: 0.65rem;
}
.info-panel dl {
.info-form {
margin: 0;
background: #fff;
border-radius: 16px;
padding: 0.4rem 1rem;
padding: 0.4rem 1rem 1rem;
display: grid;
gap: 0;
}
.info-panel dl > div {
.info-row {
display: grid;
grid-template-columns: 7.5rem 1fr;
gap: 0.6rem;
align-items: center;
padding: 0.7rem 0;
border-bottom: 1px solid #f1f5f9;
}
.info-panel dt {
.info-row span {
color: #94a3b8;
font-size: 0.8rem;
}
.info-panel dd {
margin: 0;
.info-row b {
font-weight: 600;
}
.info-row input {
width: 100%;
min-height: 2.5rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0.4rem 0.7rem;
font: inherit;
background: #fff;
}
.info-form .error,
.info-form .ok {
margin: 0.75rem 0 0;
}
.ok {
color: #0f766e;
font-weight: 600;
}
.save-btn {
margin-top: 0.9rem;
justify-self: start;
min-height: 2.6rem;
padding: 0 1.1rem;
border: 0;
border-radius: 12px;
background: #14b8a6;
color: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.save-btn:disabled {
opacity: 0.65;
cursor: wait;
}
.empty,
@@ -1171,11 +1458,18 @@ h1 {
gap: 0.2rem;
}
.avatar-edit,
.avatar {
width: 64px;
height: 64px;
}
.online-dot {
width: 14px;
height: 14px;
border-width: 2px;
}
.qa {
gap: 0.45rem;
}
@@ -1237,7 +1531,7 @@ h1 {
font-size: 0.8rem;
}
.info-panel dl > div {
.info-row {
grid-template-columns: 1fr;
gap: 0.12rem;
}