feat: add per-org SMS wallet with adm panel and header credit
Bill all platform SMS (including OTP), allow negative balance with manual/auto cutoffs, and support own-panel bypass without wallet debit.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import type { SmsWalletSummaryDto } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const summary = ref<SmsWalletSummaryDto | null>(null);
|
||||
const loading = ref(false);
|
||||
const chargeOpen = ref(false);
|
||||
const warnOpen = ref(false);
|
||||
const NEGATIVE_KEY = 'mento_sms_negative_warning_shown';
|
||||
|
||||
const visible = computed(() => Boolean(summary.value?.billingEnabled));
|
||||
|
||||
const balanceLabel = computed(() => {
|
||||
const n = summary.value?.balanceRials ?? 0;
|
||||
return toFaDigits(new Intl.NumberFormat('fa-IR').format(n));
|
||||
});
|
||||
|
||||
const toneClass = computed(() => {
|
||||
const level = summary.value?.balanceLevel;
|
||||
if (level === 'critical') return 'critical';
|
||||
if (level === 'warning') return 'warning';
|
||||
if ((summary.value?.balanceRials ?? 0) < 0) return 'negative';
|
||||
return 'ok';
|
||||
});
|
||||
|
||||
async function load() {
|
||||
if (!auth.isAuthenticated || !auth.user?.organizationId) {
|
||||
summary.value = null;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get<SmsWalletSummaryDto>('/sms-wallet/summary');
|
||||
summary.value = data;
|
||||
syncWarn(data);
|
||||
} catch {
|
||||
summary.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function syncWarn(data: SmsWalletSummaryDto) {
|
||||
if (!data.billingEnabled || data.balanceRials >= 0) {
|
||||
warnOpen.value = false;
|
||||
try {
|
||||
sessionStorage.removeItem(NEGATIVE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (sessionStorage.getItem(NEGATIVE_KEY) === '1') return;
|
||||
sessionStorage.setItem(NEGATIVE_KEY, '1');
|
||||
warnOpen.value = true;
|
||||
} catch {
|
||||
warnOpen.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => auth.user?.organizationId,
|
||||
() => {
|
||||
void load();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
defineExpose({ refresh: load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="sms-wallet">
|
||||
<div class="chip" :class="toneClass" :title="loading ? '…' : undefined">
|
||||
<span>اعتبار پیامک: {{ balanceLabel }} ریال</span>
|
||||
<button type="button" class="charge" @click="chargeOpen = true">شارژ</button>
|
||||
</div>
|
||||
|
||||
<div v-if="chargeOpen" class="layer">
|
||||
<div class="backdrop" @click="chargeOpen = false" />
|
||||
<div class="modal" role="dialog" aria-label="شارژ پیامک">
|
||||
<header>
|
||||
<strong>شارژ اعتبار پیامک</strong>
|
||||
<button type="button" class="x" @click="chargeOpen = false">×</button>
|
||||
</header>
|
||||
<p class="muted">
|
||||
برای شارژ با پشتیبانی هماهنگ کنید یا فیش واریز را ارسال کنید.
|
||||
</p>
|
||||
<template v-if="summary?.chargeInfo">
|
||||
<dl>
|
||||
<div v-if="summary.chargeInfo.card">
|
||||
<dt>کارت</dt>
|
||||
<dd dir="ltr">{{ summary.chargeInfo.card }}</dd>
|
||||
</div>
|
||||
<div v-if="summary.chargeInfo.iban">
|
||||
<dt>شبا</dt>
|
||||
<dd dir="ltr">{{ summary.chargeInfo.iban }}</dd>
|
||||
</div>
|
||||
<div v-if="summary.chargeInfo.accountHolder">
|
||||
<dt>صاحب حساب</dt>
|
||||
<dd>{{ summary.chargeInfo.accountHolder }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="instruction">{{ summary.chargeInfo.instruction }}</p>
|
||||
</template>
|
||||
<button type="button" class="ok" @click="chargeOpen = false">متوجه شدم</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="warnOpen" class="layer">
|
||||
<div class="backdrop" @click="warnOpen = false" />
|
||||
<div class="modal warn" role="dialog" aria-label="هشدار موجودی منفی">
|
||||
<header>
|
||||
<strong>موجودی پیامک منفی است</strong>
|
||||
<button type="button" class="x" @click="warnOpen = false">×</button>
|
||||
</header>
|
||||
<p>
|
||||
اعتبار پیامک محیط شما منفی شده
|
||||
({{ balanceLabel }} ریال). ارسال تا رسیدن به آستانه قطع یا مسدودسازی دستی ادامه دارد؛ لطفاً
|
||||
شارژ کنید.
|
||||
</p>
|
||||
<button type="button" class="ok" @click="warnOpen = false">متوجه شدم</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
white-space: nowrap;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #a7f3d0;
|
||||
background: rgba(236, 253, 245, 0.7);
|
||||
padding: 0.35rem 0.65rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #065f46;
|
||||
}
|
||||
.chip.warning {
|
||||
border-color: #fde68a;
|
||||
background: rgba(255, 251, 235, 0.85);
|
||||
color: #92400e;
|
||||
}
|
||||
.chip.critical,
|
||||
.chip.negative {
|
||||
border-color: #fecaca;
|
||||
background: rgba(254, 242, 242, 0.9);
|
||||
color: #991b1b;
|
||||
}
|
||||
.charge {
|
||||
border: 0;
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip.critical .charge,
|
||||
.chip.negative .charge,
|
||||
.chip.warning .charge {
|
||||
background: #b45309;
|
||||
}
|
||||
.layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
width: min(420px, calc(100vw - 2rem));
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 1rem 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
.modal header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.x {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.muted,
|
||||
.instruction {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
dl > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
.ok {
|
||||
border: 0;
|
||||
background: #0f766e;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.9rem;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user