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
+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')} روز تا پایان اعتبار`
}