Montify stores integration: multi-store connections, customer/order/transaction sync, webhooks

This commit is contained in:
2026-10-10 15:59:12 +03:30
parent fa3fe0d364
commit e82bd2b2df
18 changed files with 3712 additions and 0 deletions
+509
View File
@@ -0,0 +1,509 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
CreateMontifyConnectionRequest,
MontifyConnectionDto,
MontifyOrderDto,
MontifyTestResultDto,
UpdateMontifyConnectionRequest,
} from '@mento/shared';
import {
UiButton,
UiCard,
UiEmptyState,
UiField,
UiInput,
UiPageHeader,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
const loading = ref(false);
const saving = ref(false);
const busyId = ref('');
const error = ref('');
const ok = ref('');
const items = ref<MontifyConnectionDto[]>([]);
const orders = ref<MontifyOrderDto[]>([]);
const editingId = ref<string | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const emptyForm = () => ({
label: '',
baseUrl: '',
token: '',
storeId: '',
});
const form = reactive(emptyForm());
const anyRunning = computed(() => items.value.some((c) => c.syncStatus === 'running'));
function errMsg(e: unknown, fallback: string): string {
if (axios.isAxiosError(e)) {
const m = (e.response?.data as { message?: string | string[] })?.message;
if (Array.isArray(m)) return m.join('، ');
if (m) return String(m);
}
return fallback;
}
async function load(silent = false) {
if (!silent) loading.value = true;
try {
const [connRes, orderRes] = await Promise.all([
api.get<MontifyConnectionDto[]>('/integrations/montify/connections'),
api.get<MontifyOrderDto[]>('/integrations/montify/orders'),
]);
items.value = connRes.data;
orders.value = orderRes.data;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول فروشگاه‌های مونتیفای برای این محیط فعال نیست.';
} else if (!silent) {
error.value = 'بارگذاری ناموفق بود.';
}
} finally {
loading.value = false;
}
if (anyRunning.value && !pollTimer) {
pollTimer = setInterval(() => void load(true), 4000);
} else if (!anyRunning.value && pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function resetForm() {
Object.assign(form, emptyForm());
editingId.value = null;
}
function startEdit(c: MontifyConnectionDto) {
editingId.value = c.id;
Object.assign(form, {
label: c.label,
baseUrl: c.baseUrl,
token: '',
storeId: c.storeId,
});
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function save() {
error.value = '';
ok.value = '';
if (!form.label.trim() || !form.baseUrl.trim()) {
error.value = 'نام و Base URL فروشگاه الزامی است.';
return;
}
if (!editingId.value && !form.token.trim()) {
error.value = 'توکن فروشگاه الزامی است.';
return;
}
saving.value = true;
try {
if (editingId.value) {
const payload: UpdateMontifyConnectionRequest = {
label: form.label.trim(),
baseUrl: form.baseUrl.trim(),
storeId: form.storeId.trim(),
...(form.token.trim() ? { token: form.token.trim() } : {}),
};
await api.patch(`/integrations/montify/connections/${editingId.value}`, payload);
ok.value = 'تغییرات ذخیره شد.';
} else {
const payload: CreateMontifyConnectionRequest = {
label: form.label.trim(),
baseUrl: form.baseUrl.trim(),
token: form.token.trim(),
storeId: form.storeId.trim() || undefined,
};
await api.post('/integrations/montify/connections', payload);
ok.value = 'فروشگاه اضافه شد. حالا «تست اتصال» را بزنید.';
}
resetForm();
await load();
} catch (e) {
error.value = errMsg(e, 'ذخیره ناموفق بود.');
} finally {
saving.value = false;
}
}
async function run(id: string, fn: () => Promise<void>) {
busyId.value = id;
error.value = '';
ok.value = '';
try {
await fn();
} catch (e) {
error.value = errMsg(e, 'عملیات ناموفق بود.');
} finally {
busyId.value = '';
}
}
function test(c: MontifyConnectionDto) {
return run(c.id, async () => {
const { data } = await api.post<MontifyTestResultDto>(
`/integrations/montify/connections/${c.id}/test`,
);
ok.value =
data.totalCustomers !== null
? `اتصال «${c.label}» موفق بود — ${toFaDigits(data.totalCustomers)} مشتری در فروشگاه.`
: `اتصال «${c.label}» موفق بود.`;
await load(true);
});
}
function sync(c: MontifyConnectionDto, mode: 'incremental' | 'full') {
if (
mode === 'full' &&
!confirm('همگام‌سازی کامل همه مشتریان و سفارش‌ها را دوباره می‌خواند و ممکن است چند دقیقه طول بکشد. ادامه؟')
) {
return;
}
return run(c.id, async () => {
await api.post(`/integrations/montify/connections/${c.id}/sync`, null, { params: { mode } });
ok.value = 'همگام‌سازی در پس‌زمینه شروع شد.';
await load(true);
});
}
function toggleWebhook(c: MontifyConnectionDto) {
return run(c.id, async () => {
if (c.webhookRegistered) {
if (!confirm('وب‌هوک این فروشگاه حذف شود؟')) return;
await api.delete(`/integrations/montify/connections/${c.id}/webhook`);
ok.value = 'وب‌هوک حذف شد.';
} else {
await api.post(`/integrations/montify/connections/${c.id}/webhook`);
ok.value = 'وب‌هوک در فروشگاه ثبت شد؛ تغییرات از این به بعد لحظه‌ای می‌رسد.';
}
await load(true);
});
}
function toggleEnabled(c: MontifyConnectionDto) {
return run(c.id, async () => {
await api.patch(`/integrations/montify/connections/${c.id}`, { enabled: !c.enabled });
await load(true);
});
}
function remove(c: MontifyConnectionDto) {
if (!confirm(`اتصال «${c.label}» حذف شود؟ اشخاص و فاکتورهای واردشده می‌مانند.`)) return;
return run(c.id, async () => {
await api.delete(`/integrations/montify/connections/${c.id}`);
if (editingId.value === c.id) resetForm();
await load(true);
});
}
function formatAmount(v: number | null): string {
return v === null ? '—' : toFaDigits(v.toLocaleString('en-US'));
}
function statusLabel(c: MontifyConnectionDto): string {
if (!c.enabled) return 'غیرفعال';
if (c.syncStatus === 'running') return 'در حال همگام‌سازی…';
if (c.syncStatus === 'error') return 'خطا';
if (c.lastHealthOk === false) return 'اتصال ناموفق';
if (c.lastSyncAt) return 'فعال';
return 'هنوز همگام نشده';
}
function statusClass(c: MontifyConnectionDto): string {
if (!c.enabled) return 'off';
if (c.syncStatus === 'running') return 'run';
if (c.syncStatus === 'error' || c.lastHealthOk === false) return 'bad';
return 'good';
}
onMounted(() => void load());
onBeforeUnmount(() => {
if (pollTimer) clearInterval(pollTimer);
});
</script>
<template>
<div class="page">
<UiPageHeader
title="فروشگاه‌های مونتیفای"
description="اتصال فقط‌خواندنی چند فروشگاه؛ مشتری‌ها به اشخاص وصل می‌شوند و سفارش، آدرس، تراکنش و تعاملات هر مشتری در پرونده‌اش دیده می‌شود"
>
<template #actions>
<UiButton variant="secondary" :disabled="loading" @click="load()">
<UiIcon name="refresh" />
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard :title="editingId ? 'ویرایش فروشگاه' : 'افزودن فروشگاه'">
<p class="hint">
فروشنده در پنل مونتیفای از «تنظیمات ← یکپارچه‌سازی‌ها ← CRM» توکن صادر می‌کند و
Base URL، Token و Store ID را به شما می‌دهد. توکن فقط روی سرور و به‌صورت رمزشده نگهداری می‌شود.
</p>
<div class="form-grid">
<UiField label="نام فروشگاه">
<UiInput v-model="form.label" placeholder="مثلاً فروشگاه شعبه ۱" />
</UiField>
<UiField label="Base URL">
<UiInput v-model="form.baseUrl" dir="ltr" placeholder="https://store1.montify.com/api/v1" />
</UiField>
<UiField :label="editingId ? 'توکن جدید (خالی = بدون تغییر)' : 'Token'">
<UiInput v-model="form.token" type="password" dir="ltr" placeholder="mk_live_…" />
</UiField>
<UiField label="Store ID">
<UiInput v-model="form.storeId" dir="ltr" placeholder="b2f1e4a0-…" />
</UiField>
</div>
<div class="form-actions">
<UiButton :loading="saving" @click="save">
{{ editingId ? 'ذخیره تغییرات' : 'افزودن فروشگاه' }}
</UiButton>
<UiButton v-if="editingId" variant="secondary" @click="resetForm">انصراف</UiButton>
</div>
</UiCard>
<UiCard title="فروشگاه‌های متصل">
<UiEmptyState
v-if="!loading && !items.length"
title="فروشگاهی وصل نشده"
description="اطلاعات اتصال هر فروشگاه را در فرم بالا وارد کنید."
/>
<div v-else class="list">
<article v-for="c in items" :key="c.id" class="item">
<div class="info">
<div class="title-row">
<strong>{{ c.label }}</strong>
<span class="badge" :class="statusClass(c)">{{ statusLabel(c) }}</span>
</div>
<p class="muted ltr">{{ c.baseUrl }}</p>
<p class="muted">
توکن {{ c.tokenMasked }}
<template v-if="c.storeId"> · Store ID {{ c.storeId }}</template>
· {{ toFaDigits(c.linkedCustomers) }} مشتری
· {{ toFaDigits(c.orders) }} سفارش
</p>
<p class="muted">
{{
c.lastSyncAt
? `آخرین همگام‌سازی ${formatJalaliNumeric(new Date(c.lastSyncAt))}`
: 'هنوز همگام نشده'
}}
· وب‌هوک {{ c.webhookRegistered ? 'فعال' : 'ثبت نشده' }}
<template v-if="c.lastWebhookAt">
(آخرین رویداد {{ formatJalaliNumeric(new Date(c.lastWebhookAt)) }})
</template>
</p>
<p v-if="c.lastSyncResult" class="muted">
{{ c.syncStatus === 'running' ? 'پیشرفت' : 'نتیجه' }}:
{{ toFaDigits(c.lastSyncResult.customersFetched) }} مشتری خوانده شد
({{ toFaDigits(c.lastSyncResult.customersCreated) }} شخص جدید)،
{{ toFaDigits(c.lastSyncResult.ordersFetched) }} سفارش،
{{ toFaDigits(c.lastSyncResult.transactionsFetched) }} تراکنش
<template v-if="c.lastSyncResult.errors.length">
· {{ toFaDigits(c.lastSyncResult.errors.length) }} خطا
</template>
</p>
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
</div>
<div class="actions">
<UiButton size="sm" variant="secondary" :loading="busyId === c.id" @click="test(c)">
تست اتصال
</UiButton>
<UiButton
size="sm"
:disabled="!c.enabled || c.syncStatus === 'running'"
@click="sync(c, 'incremental')"
>
همگام‌سازی
</UiButton>
<UiButton
size="sm"
variant="secondary"
:disabled="!c.enabled || c.syncStatus === 'running'"
@click="sync(c, 'full')"
>
همگام‌سازی کامل
</UiButton>
<UiButton size="sm" variant="secondary" :disabled="!c.enabled" @click="toggleWebhook(c)">
{{ c.webhookRegistered ? 'حذف وب‌هوک' : 'ثبت وب‌هوک' }}
</UiButton>
<UiButton size="sm" variant="secondary" @click="startEdit(c)">ویرایش</UiButton>
<UiButton size="sm" variant="secondary" @click="toggleEnabled(c)">
{{ c.enabled ? 'غیرفعال' : 'فعال' }}
</UiButton>
<UiButton size="sm" variant="secondary" @click="remove(c)">حذف</UiButton>
</div>
</article>
</div>
</UiCard>
<UiCard title="آخرین سفارش‌های واردشده">
<UiEmptyState
v-if="!orders.length"
title="سفارشی نیست"
description="همگام‌سازی را بزنید یا وب‌هوک را ثبت کنید."
/>
<div v-else class="table-wrap">
<table>
<thead>
<tr>
<th>فروشگاه</th>
<th>کد سفارش</th>
<th>مشتری</th>
<th>وضعیت سفارش</th>
<th>وضعیت پرداخت</th>
<th>مبلغ (ریال)</th>
<th>تاریخ</th>
</tr>
</thead>
<tbody>
<tr v-for="o in orders" :key="o.id">
<td>{{ o.connectionLabel }}</td>
<td>{{ o.orderReference || `#${o.externalId}` }}</td>
<td>
<RouterLink v-if="o.personId" :to="`/contacts/people/${o.personId}`">
{{ o.personName || 'مشاهده' }}
</RouterLink>
<span v-else>—</span>
</td>
<td>{{ o.orderStatus || '—' }}</td>
<td>{{ o.paymentStatus || '—' }}</td>
<td>{{ formatAmount(o.discountTotalPrice ?? o.totalPrice) }}</td>
<td>{{ o.createdAt ? formatJalaliNumeric(new Date(o.createdAt)) : '—' }}</td>
</tr>
</tbody>
</table>
</div>
</UiCard>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
}
.hint,
.muted {
color: #64748b;
font-size: 0.88rem;
line-height: 1.7;
margin: 0;
}
.ltr {
direction: ltr;
text-align: right;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin: 0.75rem 0;
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.list {
display: grid;
gap: 0.75rem;
}
.item {
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
}
.info {
display: grid;
gap: 0.25rem;
min-width: 0;
}
.title-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.badge {
font-size: 0.75rem;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
}
.badge.good {
background: #dcfce7;
color: #166534;
}
.badge.bad {
background: #fee2e2;
color: #991b1b;
}
.badge.run {
background: #e0f2fe;
color: #075985;
}
.actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
align-content: start;
justify-content: end;
max-width: 340px;
}
.warn {
color: #b45309;
font-size: 0.85rem;
margin: 0;
}
.error {
color: #b91c1c;
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
text-align: right;
padding: 0.55rem;
border-bottom: 1px solid #eef2f7;
}
th {
color: #64748b;
font-size: 0.8rem;
}
@media (max-width: 800px) {
.form-grid {
grid-template-columns: 1fr;
}
.item {
flex-direction: column;
}
.actions {
max-width: none;
justify-content: start;
}
}
</style>