Port modular features into boursecu and fix desktop login UX.

Add support chat, piggy funds, PWA banner, notification/SSE sync, and messenger legacy guards while keeping boursecu theme; also balance login panels and unblock login navigation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-06 20:14:46 +02:00
co-authored by Cursor
parent be896fe247
commit 42aef88064
55 changed files with 7488 additions and 434 deletions
+20 -5
View File
@@ -1,4 +1,4 @@
/** نوتیف مرورگر بدون Firebase — فقط با کلیک کاربر (gesture) قابل درخواست است. */
/** نوتیف مرورگر بدون Firebase — ترجیحاً با gesture کاربر؛ بعد از لاگین هم تلاش می‌شود. */
export type BrowserNotifState =
| 'unsupported'
@@ -77,11 +77,28 @@ export async function requestBrowserNotificationPermission(): Promise<BrowserNot
}
}
/** لوگوی برند از کانفیگ؛ fallback به آیکون‌های PWA */
function resolveNotifIcon() {
try {
return new URL('/favicon.ico', window.location.origin).href
const avatar = String(
(window as Window & { __APP_CONFIG__?: { user?: { avatar?: string } } }).__APP_CONFIG__?.user
?.avatar ?? '',
).trim()
if (avatar) {
return avatar
}
} catch {
return undefined
// ignore
}
try {
return new URL('/192.png', window.location.origin).href
} catch {
try {
return new URL('/favicon.png', window.location.origin).href
} catch {
return undefined
}
}
}
@@ -104,7 +121,6 @@ export async function showBrowserNotification(options: ShowBrowserNotificationOp
const url = options.url || '/appNotifications'
const icon = resolveNotifIcon()
// ۱) Service Worker — روی Chrome/Android پایدارتر است
try {
if ('serviceWorker' in navigator) {
const reg = await navigator.serviceWorker.ready
@@ -124,7 +140,6 @@ export async function showBrowserNotification(options: ShowBrowserNotificationOp
console.warn('SW showNotification failed, falling back', error)
}
// ۲) Notification constructor
try {
const n = new Notification(title, {
body,
+1576
View File
File diff suppressed because it is too large Load Diff
+23 -1
View File
@@ -122,6 +122,27 @@ function isIphone() {
return /iPhone/.test(userAgent)
}
function createUuid() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join('-')
}
return `${Date.now()}-${Math.floor(Math.random() * 1e9)}`
}
export {
getToken,
setToken,
@@ -131,5 +152,6 @@ export {
isIphone,
removeFingerPrint,
getIsActiveFingerprint,
setFingerPrint
setFingerPrint,
createUuid
}
+26
View File
@@ -384,6 +384,15 @@ function handleAppNotificationPayload(payload: unknown) {
void applyAppNotificationFromStream(payload as Record<string, unknown>)
}
function handleUnreadCountPayload(payload: unknown) {
if (!payload || typeof payload !== 'object') {
return
}
touchStreamActivity()
void applyUnreadCountFromStreamPayload(payload as Record<string, unknown>)
}
async function applyAppNotificationFromStream(payload: Record<string, unknown>) {
try {
const { bumpUnreadFromStream } = await import('@/composables/useAppNotificationBadge')
@@ -393,6 +402,15 @@ async function applyAppNotificationFromStream(payload: Record<string, unknown>)
}
}
async function applyUnreadCountFromStreamPayload(payload: Record<string, unknown>) {
try {
const { applyUnreadCountFromStream } = await import('@/composables/useAppNotificationBadge')
applyUnreadCountFromStream(payload)
} catch (error) {
console.warn('Unread-count SSE apply failed', error)
}
}
function bindStreamEvents(source: EventSource) {
source.addEventListener('last', (event) => {
try {
@@ -426,6 +444,14 @@ function bindStreamEvents(source: EventSource) {
}
})
source.addEventListener('unread-count', (event) => {
try {
handleUnreadCountPayload(JSON.parse(event.data))
} catch (error) {
console.warn('Unread-count SSE parse failed', error)
}
})
source.addEventListener('ping', () => {
touchStreamActivity()
})
+14 -3
View File
@@ -28,7 +28,18 @@ export function formatTradeGramDisplay(grams?: number | null): number {
}
function feeRate() {
return Number(serviceFeePercentage.value)
return normalizeServiceFeeRatio(Number(serviceFeePercentage.value))
}
/** نسبت کارمزد (۰٫۰۱ = ۱٪). مقدار ≥۱ یعنی درصد انسانی اشتباه وارد شده. */
export function normalizeServiceFeeRatio(value: number): number {
if (!Number.isFinite(value) || value <= 0) {
return 0
}
if (value >= 1) {
return value / 100
}
return value
}
export async function loadServiceFeeFromSettings() {
@@ -39,7 +50,7 @@ export async function loadServiceFeeFromSettings() {
(s) => s?.key === 'fee_service_charge_percentage' && s?.is_active === true
)
if (feeSetting?.value !== undefined && feeSetting?.value !== null) {
serviceFeePercentage.value = Number(feeSetting.value)
serviceFeePercentage.value = normalizeServiceFeeRatio(Number(feeSetting.value))
}
const feeModeSetting = settings.find(
@@ -58,7 +69,7 @@ export async function loadServiceFeeFromSettings() {
}
export function calcFeeInGrams(goldValue?: number | null) {
return parseFloat(((goldValue ?? 0) * Number(serviceFeePercentage.value)).toFixed(4))
return parseFloat(((goldValue ?? 0) * feeRate()).toFixed(4))
}
/** گرم معادل برای دریافت دقیق مبلغ ریالی در فروش */
+7 -13
View File
@@ -88,8 +88,11 @@ export function parseSupportMessengersJson(raw?: string | null): SupportMessenge
export function resolveSupportMessengersFromSettings(
settings: Array<{ key?: string; value?: unknown }>,
): SupportMessengerEntry[] {
const fromJson = parseSupportMessengersJson(getSupportSettingValue(settings, 'support_messengers'))
if (fromJson.length > 0) return fromJson
// اگر کلید جدید در تنظیمات هست (حتی خالی/null)، فقط همان لیست معتبر است — legacy بله را برنگردان
const hasMessengersKey = settings.some((row) => row?.key === 'support_messengers')
if (hasMessengersKey) {
return parseSupportMessengersJson(getSupportSettingValue(settings, 'support_messengers'))
}
const legacyMessenger = getSupportSettingValue(settings, 'support_messenger').toLowerCase()
const legacyId = getSupportSettingValue(settings, 'support_messenger_id')
@@ -108,9 +111,8 @@ export function resolveSupportMessengerEntries(
): ResolvedSupportMessengerEntry[] {
const resolved = settings ?? supportMessengerSettings.value
const text = (messageText ?? '').trim()
const source = resolved.messengers?.length
? resolved.messengers
: resolveLegacySingleMessenger(resolved)
// فقط لیست resolve‌شده؛ قبل از load خالی است تا legacy دوباره دکمه نسازد
const source = Array.isArray(resolved.messengers) ? resolved.messengers : []
return source
.map((entry) => {
@@ -126,14 +128,6 @@ export function resolveSupportMessengerEntries(
.filter((entry): entry is ResolvedSupportMessengerEntry => entry !== null)
}
function resolveLegacySingleMessenger(settings: SupportMessengerSettings): SupportMessengerEntry[] {
const messenger = (settings.messenger ?? '').trim().toLowerCase()
const id = (settings.messengerId ?? '').trim()
if (!messenger || !id) return []
if (!VALID_SUPPORT_MESSENGERS.has(messenger)) return []
return [{ messenger, id }]
}
/** @deprecated از depositGuideText استفاده کنید */
export const DEFAULT_SUPPORT_MESSENGER_TEXT = DEFAULT_DEPOSIT_GUIDE_TEXT
+3
View File
@@ -1,3 +1,6 @@
/** toast اعلان درون‌برنامه — بالای هدر (1999) و ناوبری (5000)، زیر اکشن‌شیت */
export const APP_Z_TOAST = 5_050
/**
* z-index مدال/اکشن‌شیت/دیالوگ.
* بالای ناوبری (5000)، هدر (1999) و دکمه پشتیبانی (5100) می‌نشیند تا این کروم‌ها