Files
CRM_Montzar/apps/web/src/views/PortalView.vue
T
s.esmaeili 1ae87c97b7 feat(ui): standardize design system, modals, sidebar, and form inputs
- 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
2026-09-12 01:33:07 +03:30

593 lines
14 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. 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 { 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>