Files
CRM_Montzar/apps/web/src/views/PortalView.vue
T

593 lines
14 KiB
Vue
Raw Normal View History

<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>