feat(admin): subscription expiry modals and once-per-session SMS warning
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>';
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -319,7 +319,7 @@
|
||||
/>
|
||||
</div>
|
||||
<p class="sms-panel-subtitle text-sm leading-relaxed">
|
||||
در حالت مسدود، هیچ پیامکی از این نسخه ارسال نمیشود.
|
||||
در حالت مسدود، ارسال پیامک متوقف میشود.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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')} روز تا پایان اعتبار`
|
||||
}
|
||||
Reference in New Issue
Block a user