feat(admin): subscription expiry modals and once-per-session SMS warning

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-14 16:37:28 +03:30
co-authored by Cursor
parent 5dd9f7db96
commit 82dc6b82d8
9 changed files with 434 additions and 12 deletions
@@ -41,9 +41,6 @@ function formatNumber(value: number) {
<Icon icon="vuesax-linear:warning-2" :size="22" class="sms-neg-modal__icon" />
<div>
<p class="sms-neg-modal__title">موجودی کیف پول پیامک منفی است</p>
<p class="sms-neg-modal__text">
تا زمان شارژ و مثبت شدن موجودی، این هشدار با هر بار ورود به صفحات ادمین نمایش داده می‌شود.
</p>
</div>
</div>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import SubscriptionExpiryModal from '@/components/SubscriptionExpiryModal.vue'
import {
buildSubscriptionAlertQueue,
getSubscriptionAlertsFromAppConfig,
markAlertShown,
type SubscriptionAlertQueueItem
} from '@/utils/subscriptionAlerts'
const STAGGER_MS = 3000
const visible = ref(false)
const current = ref<SubscriptionAlertQueueItem | null>(null)
let queue: SubscriptionAlertQueueItem[] = []
let timerId: ReturnType<typeof setTimeout> | undefined
let started = false
function showNext(delayMs = 0) {
if (timerId) {
clearTimeout(timerId)
timerId = undefined
}
if (!queue.length) {
visible.value = false
current.value = null
return
}
timerId = setTimeout(() => {
timerId = undefined
current.value = queue.shift() || null
if (!current.value) {
visible.value = false
return
}
markAlertShown(current.value.type, current.value.status)
visible.value = true
}, delayMs)
}
function onVisibleUpdate(value: boolean) {
visible.value = value
if (!value) {
showNext(STAGGER_MS)
}
}
function startQueue() {
if (started) return
started = true
queue = buildSubscriptionAlertQueue(getSubscriptionAlertsFromAppConfig())
showNext(600)
}
function onConfigReady() {
if (timerId) {
clearTimeout(timerId)
timerId = undefined
}
visible.value = false
current.value = null
queue = []
started = false
startQueue()
}
onMounted(() => {
startQueue()
window.addEventListener('configReady', onConfigReady as EventListener)
})
onBeforeUnmount(() => {
if (timerId) clearTimeout(timerId)
window.removeEventListener('configReady', onConfigReady as EventListener)
})
</script>
<template>
<SubscriptionExpiryModal
v-if="current"
:visible="visible"
:type="current.type"
:status="current.status"
:items="current.items"
@update:visible="onVisibleUpdate"
/>
</template>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@core/components/BaseButton'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import {
itemLine,
modalTitle,
type SubscriptionAlertItem,
type SubscriptionAlertStatus,
type SubscriptionAlertType
} from '@/utils/subscriptionAlerts'
defineProps<{
visible: boolean
type: SubscriptionAlertType
status: SubscriptionAlertStatus
items: SubscriptionAlertItem[]
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
}>()
function closeModal() {
emit('update:visible', false)
}
</script>
<template>
<BasicModal
:visible="visible"
width="28rem"
:title="modalTitle(type, status)"
:custom-footer="true"
@close="closeModal"
@update:visible="emit('update:visible', $event)"
>
<div class="sub-expiry-modal">
<div
class="sub-expiry-modal__banner"
:class="status === 'expired' ? 'is-expired' : 'is-expiring'"
>
<Icon icon="vuesax-linear:warning-2" :size="22" class="sub-expiry-modal__icon" />
<ul class="sub-expiry-modal__list">
<li v-for="item in items" :key="item.id">
{{ itemLine(item, status) }}
</li>
</ul>
</div>
</div>
<template #footer>
<BaseButton @click="closeModal">بستن</BaseButton>
</template>
</BasicModal>
</template>
<style scoped lang="less">
.sub-expiry-modal {
display: flex;
flex-direction: column;
gap: 1rem;
}
.sub-expiry-modal__banner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.85rem 1rem;
border-radius: 0.85rem;
}
.sub-expiry-modal__banner.is-expired {
border: 1px solid #fecaca;
background: linear-gradient(135deg, #fef2f2, #fff);
}
.sub-expiry-modal__banner.is-expiring {
border: 1px solid #fde68a;
background: linear-gradient(135deg, #fffbeb, #fff);
}
.sub-expiry-modal__icon {
flex-shrink: 0;
margin-top: 0.15rem;
}
.sub-expiry-modal__banner.is-expired .sub-expiry-modal__icon {
color: #dc2626;
}
.sub-expiry-modal__banner.is-expiring .sub-expiry-modal__icon {
color: #d97706;
}
.sub-expiry-modal__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.35rem;
font-weight: 700;
line-height: 1.7;
font-size: 0.9rem;
}
.sub-expiry-modal__banner.is-expired .sub-expiry-modal__list {
color: #b91c1c;
}
.sub-expiry-modal__banner.is-expiring .sub-expiry-modal__list {
color: #92400e;
}
</style>
+1 -2
View File
@@ -367,8 +367,7 @@
'<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;direction:rtl;font-family:sans-serif;padding:24px;background:#fff;color:#111827;">' +
'<div style="width:min(420px,100%);text-align:center;border:1px solid rgba(15,23,42,0.08);border-radius:24px;padding:28px 22px;">' +
'<div style="font-size:42px;margin-bottom:12px;">⚠️</div>' +
'<h2 style="margin:0 0 12px;font-size:20px;">خطا در برقراری ارتباط با سرور</h2>' +
'<p style="margin:0 0 20px;color:rgba(15,23,42,0.62);line-height:2;font-size:14px;">لطفاً چند لحظه دیگر دوباره تلاش کنید.</p>' +
'<h2 style="margin:0 0 20px;font-size:20px;">خطا در برقراری ارتباط با سرور</h2>' +
'<button type="button" onclick="location.reload()" style="padding:11px 22px;border:0;border-radius:12px;background:#2563eb;color:#fff;cursor:pointer;font-weight:700;">تلاش مجدد</button>' +
'</div></div>';
}
+2
View File
@@ -91,6 +91,7 @@
<BasicMobileBottomNav v-if="isMobile" />
<BaseSettings />
<SmsNegativeBalanceHost />
<SubscriptionExpiryHost />
</ALayout>
</template>
@@ -104,6 +105,7 @@ import BaseSettings from '@core/layouts/BaseSettings.vue'
import BasicMobileHeader from '@/components/BasicMobileHeader.vue'
import BasicMobileBottomNav from '@/components/BasicMobileBottomNav.vue'
import SmsNegativeBalanceHost from '@/components/SmsNegativeBalanceHost.vue'
import SubscriptionExpiryHost from '@/components/SubscriptionExpiryHost.vue'
const { prefixCls } = usePrefix('basic-layout')
const layoutStore = useLayoutStore()
</script>
+1 -1
View File
@@ -319,7 +319,7 @@
/>
</div>
<p class="sms-panel-subtitle text-sm leading-relaxed">
در حالت مسدود، هیچ پیامکی از این نسخه ارسال نمی‌شود.
در حالت مسدود، ارسال پیامک متوقف می‌شود.
</p>
</div>
</div>
+38 -2
View File
@@ -10,6 +10,32 @@ const EMPTY_DEPOSIT: SmsDepositInfo = {
note: ''
}
const NEGATIVE_WARNING_SESSION_KEY = 'khanegi_admin_sms_negative_warning_shown'
function wasNegativeWarningShownThisSession() {
try {
return sessionStorage.getItem(NEGATIVE_WARNING_SESSION_KEY) === '1'
} catch {
return false
}
}
function markNegativeWarningShownThisSession() {
try {
sessionStorage.setItem(NEGATIVE_WARNING_SESSION_KEY, '1')
} catch {
// ignore storage failures
}
}
function clearNegativeWarningShownThisSession() {
try {
sessionStorage.removeItem(NEGATIVE_WARNING_SESSION_KEY)
} catch {
// ignore storage failures
}
}
/** وضعیت کیف پول پیامک برای نمایش «اعتبار پیامک» در هدر (همه ادمین‌ها). */
export const useSmsWalletStore = defineStore('smsWallet', () => {
const userStore = useUserStore()
@@ -19,7 +45,7 @@ export const useSmsWalletStore = defineStore('smsWallet', () => {
const deposit = ref<SmsDepositInfo>({ ...EMPTY_DEPOSIT })
const loaded = ref(false)
const loading = ref(false)
/** هشدار موجودی منفی — با هر refresh اگر هنوز منفی باشد دوباره باز می‌شود */
/** هشدار موجودی منفی — فقط یک‌بار در هر باز شدن نشست ادمین */
const negativeWarningVisible = ref(false)
/** ویجت فقط وقتی billing/پنل پیامک فعال است نمایش داده می‌شود. */
@@ -40,7 +66,16 @@ export const useSmsWalletStore = defineStore('smsWallet', () => {
isBlocked.value = res.is_blocked
deposit.value = res.deposit ?? { ...EMPTY_DEPOSIT }
loaded.value = true
negativeWarningVisible.value = res.balance < 0
if (res.balance >= 0) {
negativeWarningVisible.value = false
clearNegativeWarningShownThisSession()
} else if (!wasNegativeWarningShownThisSession()) {
negativeWarningVisible.value = true
markNegativeWarningShownThisSession()
} else {
negativeWarningVisible.value = false
}
} catch {
// خطای موجودی نباید مزاحم کاربر شود؛ ویجت در صورت نبود داده مخفی می‌ماند
} finally {
@@ -50,6 +85,7 @@ export const useSmsWalletStore = defineStore('smsWallet', () => {
function dismissNegativeWarning() {
negativeWarningVisible.value = false
markNegativeWarningShownThisSession()
}
return {
+1 -4
View File
@@ -27,10 +27,7 @@ export function showServerUnavailableOverlay(message = MESSAGE) {
<div style="position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;direction:rtl;font-family:sans-serif;padding:24px;background:#ffffff;color:#111827;">
<div style="width:min(420px,100%);text-align:center;background:#fff;border:1px solid rgba(15,23,42,0.08);border-radius:24px;padding:28px 22px;">
<div style="font-size:42px;margin-bottom:12px;">⚠️</div>
<h2 style="margin:0 0 12px;font-size:20px;">${message}</h2>
<p style="margin:0 0 20px;color:rgba(15,23,42,0.62);line-height:2;font-size:14px;">
لطفاً چند لحظه دیگر دوباره تلاش کنید.
</p>
<h2 style="margin:0 0 20px;font-size:20px;">${message}</h2>
<button
type="button"
onclick="location.reload()"
+186
View File
@@ -0,0 +1,186 @@
export type SubscriptionAlertStatus = 'expiring' | 'expired'
export type SubscriptionAlertItem = {
id: string
name: string
expires_at: string
days_left: number
status: SubscriptionAlertStatus
}
export type SubscriptionAlertType = 'server' | 'plugin' | 'support'
export type SubscriptionAlerts = {
servers: SubscriptionAlertItem[]
plugins: SubscriptionAlertItem[]
supports: SubscriptionAlertItem[]
}
export type SubscriptionAlertQueueItem = {
type: SubscriptionAlertType
status: SubscriptionAlertStatus
items: SubscriptionAlertItem[]
}
const TYPE_ORDER: SubscriptionAlertType[] = ['server', 'plugin', 'support']
const TYPE_LABEL: Record<SubscriptionAlertType, string> = {
server: 'سرور',
plugin: 'افزونه',
support: 'پشتیبانی'
}
export function typeLabel(type: SubscriptionAlertType) {
return TYPE_LABEL[type]
}
function readAlertsFromConfig(raw: unknown): SubscriptionAlerts {
const empty: SubscriptionAlerts = { servers: [], plugins: [], supports: [] }
if (!raw || typeof raw !== 'object') return empty
const root = raw as Record<string, unknown>
const nested =
root.subscription_alerts && typeof root.subscription_alerts === 'object'
? (root.subscription_alerts as Record<string, unknown>)
: root.user &&
typeof root.user === 'object' &&
(root.user as Record<string, unknown>).subscription_alerts &&
typeof (root.user as Record<string, unknown>).subscription_alerts === 'object'
? ((root.user as Record<string, unknown>).subscription_alerts as Record<string, unknown>)
: null
if (!nested) return empty
return {
servers: normalizeList(nested.servers),
plugins: normalizeList(nested.plugins),
supports: normalizeList(nested.supports)
}
}
function normalizeList(raw: unknown): SubscriptionAlertItem[] {
if (!Array.isArray(raw)) return []
return raw
.map((row) => {
if (!row || typeof row !== 'object') return null
const item = row as Record<string, unknown>
const status = item.status === 'expired' || item.status === 'expiring' ? item.status : null
const id = String(item.id || '').trim()
const name = String(item.name || '').trim()
if (!status || !id || !name) return null
return {
id,
name,
expires_at: String(item.expires_at || ''),
days_left: Number(item.days_left) || 0,
status
} satisfies SubscriptionAlertItem
})
.filter((item): item is SubscriptionAlertItem => item != null)
}
export function getSubscriptionAlertsFromAppConfig(): SubscriptionAlerts {
if (typeof window === 'undefined') {
return { servers: [], plugins: [], supports: [] }
}
return readAlertsFromConfig(window.__APP_CONFIG__)
}
/** Iran calendar day as YYYY-MM-DD for daily dedupe keys. */
export function iranYmd(date = new Date()): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).format(date)
}
function dailyKey(type: SubscriptionAlertType, day: string) {
return `admin_sub_alert_daily_${type}_${day}`
}
function sessionExpiredKey(type: SubscriptionAlertType) {
return `admin_sub_alert_session_expired_${type}`
}
function storageGet(storage: Storage, key: string) {
try {
return storage.getItem(key) === '1'
} catch {
return false
}
}
function storageSet(storage: Storage, key: string) {
try {
storage.setItem(key, '1')
} catch {
// ignore
}
}
export function markAlertShown(type: SubscriptionAlertType, status: SubscriptionAlertStatus) {
if (typeof window === 'undefined') return
if (status === 'expired') {
storageSet(sessionStorage, sessionExpiredKey(type))
} else {
storageSet(localStorage, dailyKey(type, iranYmd()))
}
}
function shouldShow(type: SubscriptionAlertType, status: SubscriptionAlertStatus) {
if (typeof window === 'undefined') return false
if (status === 'expired') {
return !storageGet(sessionStorage, sessionExpiredKey(type))
}
return !storageGet(localStorage, dailyKey(type, iranYmd()))
}
/**
* Build queue: server → plugin → support.
* Per type, expired wins over expiring (one modal).
*/
export function buildSubscriptionAlertQueue(alerts: SubscriptionAlerts): SubscriptionAlertQueueItem[] {
const byType: Record<SubscriptionAlertType, SubscriptionAlertItem[]> = {
server: alerts.servers,
plugin: alerts.plugins,
support: alerts.supports
}
const queue: SubscriptionAlertQueueItem[] = []
for (const type of TYPE_ORDER) {
const items = byType[type]
if (!items.length) continue
const expired = items.filter((item) => item.status === 'expired')
const expiring = items.filter((item) => item.status === 'expiring')
if (expired.length && shouldShow(type, 'expired')) {
queue.push({ type, status: 'expired', items: expired })
continue
}
if (expiring.length && shouldShow(type, 'expiring')) {
queue.push({ type, status: 'expiring', items: expiring })
}
}
return queue
}
export function modalTitle(type: SubscriptionAlertType, status: SubscriptionAlertStatus) {
const label = typeLabel(type)
if (status === 'expired') return `اشتراک ${label} منقضی شده`
return `هشدار اتمام ${label}`
}
export function itemLine(item: SubscriptionAlertItem, status: SubscriptionAlertStatus) {
if (status === 'expired') {
return `${item.name} منقضی شده است`
}
if (item.days_left <= 0) {
return `${item.name} — امروز منقضی می‌شود`
}
return `${item.name} — ${item.days_left.toLocaleString('fa-IR')} روز تا پایان اعتبار`
}