- Implement unified design system tokens, 10px radius, and IRANSans typography - Modernize sidebar with Gemini-style collapsed expander, square 1:1 items, and elevated profile flyout - Standardize all modal dialogs with 16px padding, fixed header/footer, 16px medium title, and sticky bottom actions - Refactor DealFormView and CustomFieldsSection with standard UiField, UiInput, and UiSelect components - Add draggable bottom-left QuickCreate floating action button
593 lines
14 KiB
Vue
593 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue';
|
||
import { useRoute, useRouter } from 'vue-router';
|
||
import axios from 'axios';
|
||
import {
|
||
getPortalToken,
|
||
portalApi,
|
||
setPortalToken,
|
||
} from '@/lib/portalApi';
|
||
import type {
|
||
FaqArticleDto,
|
||
PortalLoginResponse,
|
||
PortalOrgInfoDto,
|
||
PortalPersonDto,
|
||
PortalRequestOtpResponse,
|
||
PortalTicketDto,
|
||
} from '@mento/shared';
|
||
import { brand } from '@mento/shared';
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const slug = computed(() =>
|
||
typeof route.params.slug === 'string' ? route.params.slug : '',
|
||
);
|
||
|
||
const org = ref<PortalOrgInfoDto | null>(null);
|
||
const person = ref<PortalPersonDto | null>(null);
|
||
const loading = ref(true);
|
||
const error = ref('');
|
||
const step = ref<'login' | 'home' | 'new' | 'ticket'>('login');
|
||
|
||
const mobile = ref('09121234567');
|
||
const otpSent = ref(false);
|
||
const code = ref('');
|
||
const devCode = ref('');
|
||
const sending = ref(false);
|
||
|
||
const faq = ref<FaqArticleDto[]>([]);
|
||
const tickets = ref<PortalTicketDto[]>([]);
|
||
const ticket = ref<PortalTicketDto | null>(null);
|
||
|
||
const subject = ref('');
|
||
const description = ref('');
|
||
const reply = ref('');
|
||
const csatScore = ref<number | null>(null);
|
||
const csatComment = ref('');
|
||
|
||
const statusLabel: Record<string, string> = {
|
||
open: 'باز',
|
||
in_progress: 'در حال رسیدگی',
|
||
waiting: 'در انتظار شما',
|
||
resolved: 'حلشده',
|
||
closed: 'بسته',
|
||
};
|
||
|
||
function formatWhen(iso: string) {
|
||
try {
|
||
return new Intl.DateTimeFormat('fa-IR', {
|
||
dateStyle: 'medium',
|
||
timeStyle: 'short',
|
||
}).format(new Date(iso));
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
async function loadOrg() {
|
||
const { data } = await portalApi.get<PortalOrgInfoDto>(
|
||
`/public/portal/${slug.value}`,
|
||
);
|
||
org.value = data;
|
||
}
|
||
|
||
async function loadFaq() {
|
||
const { data } = await portalApi.get<FaqArticleDto[]>(
|
||
`/public/portal/${slug.value}/faq`,
|
||
);
|
||
faq.value = data;
|
||
}
|
||
|
||
async function loadTickets() {
|
||
const { data } = await portalApi.get<PortalTicketDto[]>(
|
||
`/public/portal/${slug.value}/tickets`,
|
||
);
|
||
tickets.value = data;
|
||
}
|
||
|
||
async function requestOtp() {
|
||
sending.value = true;
|
||
error.value = '';
|
||
try {
|
||
const { data } = await portalApi.post<PortalRequestOtpResponse>(
|
||
`/public/portal/${slug.value}/otp/request`,
|
||
{ mobile: mobile.value },
|
||
);
|
||
otpSent.value = true;
|
||
devCode.value = data.devCode ?? '';
|
||
if (data.devCode) code.value = data.devCode;
|
||
} catch (e) {
|
||
error.value = axios.isAxiosError(e)
|
||
? String(
|
||
(e.response?.data as { message?: string | string[] })?.message ??
|
||
'ارسال کد ناموفق بود',
|
||
)
|
||
: 'ارسال کد ناموفق بود';
|
||
} finally {
|
||
sending.value = false;
|
||
}
|
||
}
|
||
|
||
async function verifyOtp() {
|
||
sending.value = true;
|
||
error.value = '';
|
||
try {
|
||
const { data } = await portalApi.post<PortalLoginResponse>(
|
||
`/public/portal/${slug.value}/otp/verify`,
|
||
{ mobile: mobile.value, code: code.value },
|
||
);
|
||
setPortalToken(data.accessToken);
|
||
person.value = data.person;
|
||
org.value = data.organization;
|
||
localStorage.setItem(
|
||
'mento_portal_person',
|
||
JSON.stringify(data.person),
|
||
);
|
||
step.value = 'home';
|
||
await Promise.all([loadFaq(), loadTickets()]);
|
||
} catch (e) {
|
||
error.value = axios.isAxiosError(e)
|
||
? String(
|
||
(e.response?.data as { message?: string | string[] })?.message ??
|
||
'تأیید کد ناموفق بود',
|
||
)
|
||
: 'تأیید کد ناموفق بود';
|
||
} finally {
|
||
sending.value = false;
|
||
}
|
||
}
|
||
|
||
async function openTicket(id: string) {
|
||
error.value = '';
|
||
const { data } = await portalApi.get<PortalTicketDto>(
|
||
`/public/portal/${slug.value}/tickets/${id}`,
|
||
);
|
||
ticket.value = data;
|
||
csatScore.value = data.csatScore;
|
||
csatComment.value = data.csatComment ?? '';
|
||
step.value = 'ticket';
|
||
await router.replace({
|
||
name: 'portal',
|
||
params: { slug: slug.value },
|
||
query: { t: id },
|
||
});
|
||
}
|
||
|
||
async function createTicket() {
|
||
if (!subject.value.trim()) {
|
||
error.value = 'موضوع را وارد کنید';
|
||
return;
|
||
}
|
||
sending.value = true;
|
||
error.value = '';
|
||
try {
|
||
const { data } = await portalApi.post<PortalTicketDto>(
|
||
`/public/portal/${slug.value}/tickets`,
|
||
{
|
||
subject: subject.value.trim(),
|
||
description: description.value || undefined,
|
||
},
|
||
);
|
||
subject.value = '';
|
||
description.value = '';
|
||
await openTicket(data.id);
|
||
} catch (e) {
|
||
error.value = axios.isAxiosError(e)
|
||
? String(
|
||
(e.response?.data as { message?: string | string[] })?.message ??
|
||
'ثبت تیکت ناموفق بود',
|
||
)
|
||
: 'ثبت تیکت ناموفق بود';
|
||
} finally {
|
||
sending.value = false;
|
||
}
|
||
}
|
||
|
||
async function sendReply() {
|
||
if (!ticket.value || !reply.value.trim()) return;
|
||
sending.value = true;
|
||
try {
|
||
const { data } = await portalApi.post<PortalTicketDto>(
|
||
`/public/portal/${slug.value}/tickets/${ticket.value.id}/messages`,
|
||
{ body: reply.value.trim() },
|
||
);
|
||
ticket.value = data;
|
||
reply.value = '';
|
||
await loadTickets();
|
||
} catch (e) {
|
||
error.value = 'ارسال پیام ناموفق بود';
|
||
} finally {
|
||
sending.value = false;
|
||
}
|
||
}
|
||
|
||
async function saveCsat() {
|
||
if (!ticket.value || csatScore.value == null) return;
|
||
sending.value = true;
|
||
try {
|
||
const { data } = await portalApi.patch<PortalTicketDto>(
|
||
`/public/portal/${slug.value}/tickets/${ticket.value.id}/csat`,
|
||
{
|
||
csatScore: csatScore.value,
|
||
csatComment: csatComment.value || null,
|
||
},
|
||
);
|
||
ticket.value = data;
|
||
} catch {
|
||
error.value = 'ثبت رضایت ناموفق بود';
|
||
} finally {
|
||
sending.value = false;
|
||
}
|
||
}
|
||
|
||
function logout() {
|
||
setPortalToken(null);
|
||
localStorage.removeItem('mento_portal_person');
|
||
person.value = null;
|
||
step.value = 'login';
|
||
otpSent.value = false;
|
||
code.value = '';
|
||
ticket.value = null;
|
||
void router.replace({ name: 'portal', params: { slug: slug.value } });
|
||
}
|
||
|
||
function goHome() {
|
||
step.value = 'home';
|
||
ticket.value = null;
|
||
void router.replace({ name: 'portal', params: { slug: slug.value } });
|
||
void loadTickets();
|
||
}
|
||
|
||
onMounted(async () => {
|
||
loading.value = true;
|
||
error.value = '';
|
||
try {
|
||
await loadOrg();
|
||
if (getPortalToken()) {
|
||
const raw = localStorage.getItem('mento_portal_person');
|
||
if (raw) person.value = JSON.parse(raw) as PortalPersonDto;
|
||
await Promise.all([loadFaq(), loadTickets()]);
|
||
const tid = typeof route.query.t === 'string' ? route.query.t : '';
|
||
if (tid) await openTicket(tid);
|
||
else step.value = 'home';
|
||
} else {
|
||
step.value = 'login';
|
||
await loadFaq();
|
||
}
|
||
} catch {
|
||
error.value = 'بارگذاری پرتال ناموفق بود.';
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
});
|
||
|
||
watch(slug, () => {
|
||
void router.go(0);
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="portal">
|
||
<header class="top">
|
||
<div>
|
||
<p class="brand">{{ brand.nameFa }} · پرتال مشتری</p>
|
||
<h1>{{ org?.name || '…' }}</h1>
|
||
</div>
|
||
<button v-if="person" class="linkish" type="button" @click="logout">
|
||
خروج {{ person.fullName }}
|
||
</button>
|
||
</header>
|
||
|
||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||
<p v-if="error" class="error">{{ error }}</p>
|
||
|
||
<section v-if="!loading && step === 'login'" class="card">
|
||
<h2>ورود با موبایل</h2>
|
||
<p class="muted">کد تأیید با پیامک ارسال میشود. در محیط توسعه ممکن است کد روی صفحه هم دیده شود.</p>
|
||
<label>موبایل</label>
|
||
<input v-model="mobile" inputmode="tel" placeholder="0912…" />
|
||
<template v-if="otpSent">
|
||
<label>کد تأیید</label>
|
||
<input v-model="code" inputmode="numeric" maxlength="8" />
|
||
<p v-if="devCode" class="dev">کد دمو: {{ devCode }}</p>
|
||
</template>
|
||
<div class="actions">
|
||
<button
|
||
v-if="!otpSent"
|
||
class="btn"
|
||
type="button"
|
||
:disabled="sending"
|
||
@click="requestOtp"
|
||
>
|
||
دریافت کد
|
||
</button>
|
||
<button
|
||
v-else
|
||
class="btn"
|
||
type="button"
|
||
:disabled="sending"
|
||
@click="verifyOtp"
|
||
>
|
||
ورود
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="faq.length" class="faq">
|
||
<h3>قبل از تیکت، اینها را بخوانید</h3>
|
||
<details v-for="f in faq" :key="f.id">
|
||
<summary>{{ f.question }}</summary>
|
||
<p>{{ f.answer }}</p>
|
||
</details>
|
||
</div>
|
||
</section>
|
||
|
||
<template v-else-if="!loading && step === 'home'">
|
||
<section class="card">
|
||
<div class="row-head">
|
||
<h2>تیکتهای من</h2>
|
||
<button class="btn" type="button" @click="step = 'new'">تیکت جدید</button>
|
||
</div>
|
||
<button
|
||
v-for="t in tickets"
|
||
:key="t.id"
|
||
type="button"
|
||
class="ticket-row"
|
||
@click="openTicket(t.id)"
|
||
>
|
||
<strong>{{ t.subject }}</strong>
|
||
<span class="muted"
|
||
>{{ t.number }} · {{ statusLabel[t.status] || t.status }}</span
|
||
>
|
||
</button>
|
||
<p v-if="!tickets.length" class="muted">هنوز تیکتی ندارید.</p>
|
||
</section>
|
||
|
||
<section v-if="faq.length" class="card">
|
||
<h2>سؤالات پرتکرار</h2>
|
||
<details v-for="f in faq" :key="f.id">
|
||
<summary>{{ f.question }}</summary>
|
||
<p>{{ f.answer }}</p>
|
||
</details>
|
||
</section>
|
||
</template>
|
||
|
||
<section v-else-if="!loading && step === 'new'" class="card">
|
||
<button class="linkish" type="button" @click="goHome">← بازگشت</button>
|
||
<h2>تیکت جدید</h2>
|
||
<label>موضوع</label>
|
||
<input v-model="subject" maxlength="200" />
|
||
<label>شرح</label>
|
||
<textarea v-model="description" rows="4" />
|
||
<button class="btn" type="button" :disabled="sending" @click="createTicket">
|
||
ثبت تیکت
|
||
</button>
|
||
</section>
|
||
|
||
<section v-else-if="!loading && step === 'ticket' && ticket" class="card">
|
||
<button class="linkish" type="button" @click="goHome">← بازگشت</button>
|
||
<h2>{{ ticket.subject }}</h2>
|
||
<p class="muted">
|
||
{{ ticket.number }} · {{ statusLabel[ticket.status] || ticket.status }}
|
||
</p>
|
||
|
||
<div class="msgs">
|
||
<article
|
||
v-for="m in ticket.messages"
|
||
:key="m.id"
|
||
class="msg"
|
||
:class="{ staff: m.fromStaff }"
|
||
>
|
||
<header>
|
||
<strong>{{ m.fromStaff ? 'پشتیبانی' : 'شما' }}</strong>
|
||
<span class="muted">{{ formatWhen(m.createdAt) }}</span>
|
||
</header>
|
||
<p>{{ m.body }}</p>
|
||
</article>
|
||
</div>
|
||
|
||
<form class="reply" @submit.prevent="sendReply">
|
||
<textarea v-model="reply" rows="3" placeholder="پیام شما…" required />
|
||
<button class="btn" type="submit" :disabled="sending">ارسال</button>
|
||
</form>
|
||
|
||
<div
|
||
v-if="ticket.status === 'resolved' || ticket.status === 'closed'"
|
||
class="csat"
|
||
>
|
||
<h3>رضایت از پاسخ</h3>
|
||
<div class="scores">
|
||
<button
|
||
v-for="n in 5"
|
||
:key="n"
|
||
type="button"
|
||
class="score"
|
||
:class="{ active: csatScore === n }"
|
||
@click="csatScore = n"
|
||
>
|
||
{{ n }}
|
||
</button>
|
||
</div>
|
||
<textarea v-model="csatComment" rows="2" placeholder="نظر اختیاری" />
|
||
<button class="btn" type="button" :disabled="sending" @click="saveCsat">
|
||
ثبت رضایت
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.portal {
|
||
min-height: 100vh;
|
||
padding: 1.25rem;
|
||
max-width: 640px;
|
||
margin: 0 auto;
|
||
display: grid;
|
||
gap: 1rem;
|
||
background:
|
||
radial-gradient(1200px 400px at 10% -10%, #dceee8, transparent),
|
||
linear-gradient(180deg, #f4f7f5, #eef1ef);
|
||
}
|
||
|
||
.top {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.brand {
|
||
margin: 0;
|
||
color: #0f6b54;
|
||
font-weight: 700;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
h1 {
|
||
margin: 0.2rem 0 0;
|
||
font-size: 1.45rem;
|
||
}
|
||
|
||
h2 {
|
||
margin: 0 0 0.75rem;
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
h3 {
|
||
margin: 0 0 0.5rem;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.card {
|
||
background: #fff;
|
||
border: 1px solid #d7e0db;
|
||
border-radius: var(--radius);
|
||
padding: 1rem;
|
||
display: grid;
|
||
gap: 0.65rem;
|
||
}
|
||
|
||
label {
|
||
font-size: 0.9rem;
|
||
color: #5b6b63;
|
||
}
|
||
|
||
input,
|
||
textarea {
|
||
border: 1px solid #cfd9d3;
|
||
border-radius: var(--radius);
|
||
padding: 0.7rem 0.85rem;
|
||
font: inherit;
|
||
width: 100%;
|
||
background: #fff;
|
||
}
|
||
|
||
.btn {
|
||
border: 0;
|
||
border-radius: var(--radius);
|
||
padding: 0.7rem 1rem;
|
||
background: #0f6b54;
|
||
color: #fff;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
justify-self: start;
|
||
}
|
||
|
||
.linkish {
|
||
border: 0;
|
||
background: none;
|
||
color: #0f6b54;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
padding: 0;
|
||
justify-self: start;
|
||
}
|
||
|
||
.row-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.ticket-row {
|
||
display: grid;
|
||
gap: 0.2rem;
|
||
padding: 0.75rem 0;
|
||
border: 0;
|
||
border-top: 1px solid #e6eeea;
|
||
background: none;
|
||
text-align: inherit;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
width: 100%;
|
||
color: inherit;
|
||
}
|
||
|
||
.msgs {
|
||
display: grid;
|
||
gap: 0.55rem;
|
||
}
|
||
|
||
.msg {
|
||
border: 1px solid #e0e8e3;
|
||
border-radius: var(--radius);
|
||
padding: 0.7rem 0.85rem;
|
||
}
|
||
|
||
.msg.staff {
|
||
background: #f2faf6;
|
||
}
|
||
|
||
.msg header {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin-bottom: 0.3rem;
|
||
}
|
||
|
||
.msg p {
|
||
margin: 0;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.csat .scores {
|
||
display: flex;
|
||
gap: 0.35rem;
|
||
}
|
||
|
||
.score {
|
||
width: 2.3rem;
|
||
height: 2.3rem;
|
||
border-radius: var(--radius);
|
||
border: 1px solid #cfd9d3;
|
||
background: #fff;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
}
|
||
|
||
.score.active {
|
||
border-color: #0f6b54;
|
||
background: #e7f5ef;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.dev {
|
||
color: #0f6b54;
|
||
font-weight: 700;
|
||
margin: 0;
|
||
}
|
||
|
||
.muted {
|
||
color: #6a7a72;
|
||
}
|
||
|
||
.error {
|
||
color: #b42318;
|
||
}
|
||
|
||
details p {
|
||
color: #5b6b63;
|
||
white-space: pre-wrap;
|
||
}
|
||
</style>
|