fix(home): show quote skeleton while loading and harden auth/images

Prefer server quote status, avoid false no-quote before ready, toast on 401 re-login, and keep product/banner image URLs absolute.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 16:59:29 +03:30
co-authored by Cursor
parent ff3a1cb00b
commit 4675a5b5e3
10 changed files with 128 additions and 17 deletions
+14 -3
View File
@@ -107,9 +107,20 @@ body {
z-index: 5001 !important;
}
//.van-overlay{
// z-index: 2000 !important;
//}
.van-overlay{
z-index: 5000 !important;
background: rgba(0, 0, 0, 0.55) !important;
pointer-events: auto !important;
}
body.van-overflow-hidden {
.bottom-appbar,
.app-credit-footer,
#support-button {
visibility: hidden !important;
pointer-events: none !important;
}
}
.color-transition {
transition: color 0.3s ease;
@@ -16,7 +16,7 @@
</style>-->
<template>
<VanActionSheet :show="show" @update:show="$emit('update:show', $event)" :placement="placement" teleport="body">
<VanActionSheet :show="show" overlay lock-scroll close-on-click-overlay teleport="body" :placement="placement" @update:show="$emit('update:show', $event)">
<div class="justify-center cursor-pointer flex mt-1" @click="$emit('update:show', false)">
<span class="rounded-sm w-12 border-t-2 border-t-solid border-t-gray-500 mt-2"></span>
</div>
+5
View File
@@ -17,6 +17,8 @@ let lastSubscribers = 0
export const price = ref<Price>()
export const lastPrice = ref<Price>()
/** حداقل یک بار تلاش برای گرفتن مظنه تمام شده (موفق یا ناموفق) */
export const lastPriceReady = ref(false)
export const olderPrice = ref<number | null>(null)
let lastPriceSourceAt = 0
@@ -180,6 +182,8 @@ export async function getLastPrices(): Promise<boolean> {
console.log(error)
markPriceSourceFetchFailed()
return false
} finally {
lastPriceReady.value = true
}
}
@@ -194,6 +198,7 @@ export function applyLastPricePayload(dataLast: Price) {
}
lastPrice.value = normalized
lastPriceReady.value = true
price.value = {
...normalized,
buy: nextBuy,
+45 -5
View File
@@ -20,11 +20,51 @@ function isUsableImageSrc(src?: string | null): boolean {
}
function absolutizeImageUrl(url: string): string {
if (/^https?:\/\//i.test(url)) return url
const base = String(getConfigApp('node_server') || '').replace(/\/$/, '')
const path = url.replace(/^\//, '')
if (!base) return url.startsWith('/') ? url : `/${url}`
return `${base}/${path}`
const normalized = normalizePublicFileUrl(url)
return normalized ?? url
}
function projectBase(): string {
return String(getConfigApp('node_server') || '').replace(/\/$/, '')
}
function storageRelativePath(value: string): string | null {
const match = value.match(/(?:^|\/)(storage\/.+)$/i)
return match ? match[1].replace(/^\/+/, '') : null
}
function joinProjectUrl(relative: string): string {
const path = relative.replace(/^\//, '')
const base = projectBase()
if (!base) return `/${path}`
let url = `${base}/${path}`
if (typeof window !== 'undefined' && window.location.protocol === 'https:') {
url = url.replace(/^http:\/\//i, 'https://')
}
return url
}
export function normalizePublicFileUrl(raw?: string | null): string | null {
if (raw == null) return null
const trimmed = String(raw).trim()
if (!trimmed || trimmed.toLowerCase() === 'null' || trimmed.toLowerCase() === 'undefined') {
return null
}
if (/^(data:|blob:)/i.test(trimmed)) return trimmed
const storagePath = storageRelativePath(trimmed)
if (storagePath && projectBase()) {
return joinProjectUrl(storagePath)
}
if (/^https?:\/\//i.test(trimmed)) {
if (typeof window !== 'undefined' && window.location.protocol === 'https:') {
return trimmed.replace(/^http:\/\//i, 'https://')
}
return trimmed
}
return joinProjectUrl(trimmed)
}
export function getProductFallbackImage(product?: ProductImageSource): string {
+17 -1
View File
@@ -1,6 +1,6 @@
import { ref } from 'vue'
import { useSettingsStore } from '@/store/settingsStore'
import { lastPrice } from '@/utils/price'
import { lastPrice, lastPriceReady } from '@/utils/price'
export interface StoreTradingHours {
start: string
@@ -298,6 +298,11 @@ export function getPriceDisplayState(): PriceDisplayState {
void useSettingsStore().settingsRevision
const p = lastPrice.value
const serverStatus = p?.status
if (serverStatus === 'open' || serverStatus === 'closed' || serverStatus === 'no_quote') {
return serverStatus
}
if (isOutsideGlobalStoreHours()) {
if (!shouldShowHomePriceWhenClosed()) return 'closed'
return hasShowableHomePrice(p) ? 'open' : 'no_quote'
@@ -309,6 +314,17 @@ export function getPriceDisplayState(): PriceDisplayState {
return 'no_quote'
}
/** اولین دریافت مظنه / تنظیمات هنوز تمام نشده — اسکلتون، نه «مظنه در دسترس نیست» */
export function isHomeQuotePending(): boolean {
const settings = useSettingsStore()
void lastPriceReady.value
void lastPrice.value
void settings.loaded
void settings.settingsRevision
return !settings.loaded || !lastPriceReady.value
}
export function getPriceDisplayMessage(state: PriceDisplayState = getPriceDisplayState()): string {
if (state === 'closed') {
return getStoreClosedMessage()
+14
View File
@@ -60,6 +60,7 @@ _axios.interceptors.response.use(
if (response?.status === 401) {
removeToken()
showPleaseLoginAgain()
const routeName = router.currentRoute.value.name
const isPublicRoute =
router.currentRoute.value.meta?.public === true ||
@@ -113,6 +114,19 @@ _axios.interceptors.response.use(
const DEFAULT_LOAD_ERROR = 'خطا در بارگذاری اطلاعات'
const SERVER_ERROR = 'خطا در برقراری ارتباط با سرور'
const NETWORK_ERROR = 'لطفا اینترنت خود را بررسی کنید و مجددا تلاش کنید'
const LOGIN_AGAIN = 'لطفا مجددا وارد شوید'
let lastAuthToastAt = 0
function showPleaseLoginAgain() {
const now = Date.now()
if (now - lastAuthToastAt < 2500) return
lastAuthToastAt = now
message.error({
content: LOGIN_AGAIN,
key: 'please-login-again',
duration: 3,
})
}
function withHttpStatus(message: string, status: number): string {
return `${message} (${status})`
+1 -5
View File
@@ -15,11 +15,7 @@ function goHome() {
}
function goBack() {
if (window.history.length > 1) {
router.back()
return
}
goHome()
router.back()
}
</script>
@@ -21,6 +21,14 @@ export function resolveAboutSoftwareBody(
return { kind: 'text', text: getDefaultAboutSoftwareText(projectName) }
}
if (looksLikeHtml(value)) {
const stripped = value
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!stripped) {
return { kind: 'text', text: getDefaultAboutSoftwareText(projectName) }
}
return { kind: 'html', html: value }
}
return { kind: 'text', text: value }
@@ -1,6 +1,7 @@
import { ref } from 'vue'
import { getConstByKey } from '@/api/const'
import { useSettingsStore } from '@/store/settingsStore'
import { normalizePublicFileUrl } from '@/utils/productImage'
function parseJsonArray(raw: unknown): unknown[] {
if (raw == null || raw === '') {
@@ -69,7 +70,14 @@ async function fetchBanners(force = false) {
}
}
banners.value = list.filter((banner) => (banner as { show?: boolean }).show === true)
banners.value = list
.filter((banner) => (banner as { show?: boolean }).show === true)
.map((banner) => {
if (!banner || typeof banner !== 'object') return banner
const row = banner as { icon?: unknown }
if (typeof row.icon !== 'string') return banner
return { ...row, icon: normalizePublicFileUrl(row.icon) ?? '' }
})
bannersLoaded = true
} catch (error) {
console.error('Error loading banners:', error)
@@ -6,7 +6,18 @@
'dhome-hero-price--boursecu-closed': isBoursecu && displayState !== 'open',
}"
>
<template v-if="displayState !== 'open'">
<div
v-if="quotePending"
class="flex flex-col items-center gap-2 py-4"
aria-busy="true"
aria-label="در حال بارگذاری قیمت"
>
<div class="h-3 w-24 rounded bg-gray-200 animate-pulse" />
<div class="h-4 w-32 rounded bg-gray-200 animate-pulse" />
<div class="h-8 w-40 rounded bg-gray-200 animate-pulse" />
</div>
<template v-else-if="displayState !== 'open'">
<div class="dhome-hero-price__closed" :class="{ 'dhome-hero-price__closed--default': !isBoursecu }">
<div class="dhome-hero-price__closed-icon">
<BasicIcon
@@ -61,6 +72,7 @@ import {
NO_QUOTE_MESSAGE,
getPriceDisplayState,
getStoreClosedMessage,
isHomeQuotePending,
} from '@/utils/storeHours'
import { formatSignedPercentChange } from '@/utils/usePrice'
@@ -69,6 +81,7 @@ const userStore = useUserStore()
const isBoursecu = isBoursecuProject()
const displayState = computed(() => getPriceDisplayState())
const quotePending = computed(() => isHomeQuotePending())
const statusTitle = computed(() =>
displayState.value === 'closed' ? 'فروشگاه بسته است' : NO_QUOTE_MESSAGE