feat(admin): subscription expiry modals and once-per-session SMS warning
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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