Port modular app notifications, pargar date display, and closed-store product UX.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-25 16:29:33 +02:00
co-authored by Cursor
parent 4fd12277e7
commit e6dea15752
33 changed files with 1253 additions and 47 deletions
+2
View File
@@ -12,6 +12,8 @@
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>در حال بارگذاری...</title>
<!---->
+14
View File
@@ -209,6 +209,9 @@ watch(
return
}
resetAppSurfaceBackground()
void import('@/utils/safeAreaInsets').then(({ syncSafeAreaInsets }) => {
syncSafeAreaInsets()
})
},
{ immediate: true },
)
@@ -230,7 +233,12 @@ watch(
/* ارتفاع نوار اعتبار + safe-area؛ منوی پایین با bottom: var(--app-credit-footer-offset) بالاتر از این نوار می‌نشیند */
:root {
--app-credit-bar-inner: 28px;
/* constant برای iOS قدیمی؛ env باید مستقیم هم در مصرف‌کننده‌ها تکرار شود */
--app-safe-top: 0px;
--app-safe-top: constant(safe-area-inset-top);
--app-safe-top: env(safe-area-inset-top, 0px);
--app-safe-bottom: 0px;
--app-safe-bottom: constant(safe-area-inset-bottom);
--app-safe-bottom: env(safe-area-inset-bottom, 0px);
--app-home-header-offset: calc(4.25rem + var(--app-safe-top));
--app-credit-footer-offset: calc(var(--app-credit-bar-inner) + var(--app-safe-bottom));
@@ -270,6 +278,12 @@ watch(
box-sizing: content-box;
}
/* toast اعلان‌های اپ باید بالای نوار اعتبار باشد */
.ant-notification .app-notif-toast,
.app-notif-toast {
z-index: 1000001 !important;
}
.app-credit-footer--mobile {
width: 100%;
max-width: 426px;
+24
View File
@@ -0,0 +1,24 @@
import useAxios from '@/utils/useAxios'
export type AppNotificationSeverity = 'info' | 'success' | 'warning' | 'danger'
export interface CustomerAppNotification {
id: number
title: string
body: string
severity: AppNotificationSeverity
created_at?: string
is_read: boolean
}
export const getAppNotifications = async () =>
await useAxios.get<CustomerAppNotification[]>(`/customer/app_notifications`)
export const getAppNotificationsUnreadCount = async () =>
await useAxios.get<{ unread_count: number }>(`/customer/app_notifications/unread-count`)
export const markAppNotificationRead = async (id: number) =>
await useAxios.post(`/customer/app_notifications/${id}/read`)
export const markAllAppNotificationsRead = async () =>
await useAxios.post(`/customer/app_notifications/read-all`)
+22
View File
@@ -76,6 +76,28 @@ export function setupRouterGuards() {
return goToLogin(next)
}
if (to.name === 'notifyTrigger') {
const { useSettingsStore } = await import('@/store/settingsStore')
const settingsStore = useSettingsStore()
if (!settingsStore.loaded) {
await settingsStore.fetchSettings()
}
if (!settingsStore.isSettingTruthy('has_page_price_trigger')) {
return next({ name: (from.name as string) || 'user' })
}
}
if (to.name === 'appNotifications') {
const { useSettingsStore } = await import('@/store/settingsStore')
const settingsStore = useSettingsStore()
if (!settingsStore.loaded) {
await settingsStore.fetchSettings()
}
if (!settingsStore.isSettingTruthy('has_page_notification')) {
return next({ name: (from.name as string) || 'home' })
}
}
hasFingerPrint = !!userStore.userInfo?.finger_print
if (isApple && userStore.userInfo?.finger_print) {
+160
View File
@@ -0,0 +1,160 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { notification } from 'ant-design-vue'
import { getAppNotifications, getAppNotificationsUnreadCount } from '@/api/appNotifications'
import { useSettingsStore } from '@/store/settingsStore'
import { getToken } from '@/utils'
import { showBrowserNotification } from '@/utils/browserNotification'
const unreadCount = ref(0)
let pollTimer: ReturnType<typeof setInterval> | null = null
let started = false
let lastStreamNotifId = 0
let hydratedUnread = false
let lastShownNotifId = 0
async function refreshUnreadCount() {
const settingsStore = useSettingsStore()
if (!getToken() || !settingsStore.isSettingTruthy('has_page_notification')) {
unreadCount.value = 0
hydratedUnread = false
return
}
try {
const data = await getAppNotificationsUnreadCount()
const next = Number(data?.unread_count ?? 0) || 0
const prev = unreadCount.value
// اگر SSE نرسید ولی تعداد خوانده‌نشده زیاد شد → نوتیف بساز
if (hydratedUnread && next > prev) {
void notifyFromLatestUnread()
}
unreadCount.value = next
hydratedUnread = true
} catch {
// silent — badge optional
}
}
function clearUnreadLocally() {
unreadCount.value = 0
}
function showInAppToast(title: string, body: string) {
try {
notification.open({
message: title || 'اعلان جدید',
description: body || undefined,
placement: 'topRight',
duration: 5,
class: 'app-notif-toast',
onClick: () => {
void import('@/router').then((mod) => {
mod.default.push({ name: 'appNotifications' }).catch(() => undefined)
})
},
})
} catch {
// ignore
}
}
async function presentNotification(payload: {
id?: number
title?: string
body?: string
}) {
const id = Number(payload.id ?? 0)
if (id > 0 && id === lastShownNotifId) {
return
}
if (id > 0) {
lastShownNotifId = id
}
const title = String(payload.title ?? '').trim() || 'اعلان جدید'
const body = String(payload.body ?? '').trim()
showInAppToast(title, body)
await showBrowserNotification({
title,
body,
tag: id > 0 ? `app-notification-${id}` : undefined,
url: '/appNotifications',
})
}
async function notifyFromLatestUnread() {
try {
const rows = await getAppNotifications()
const latest = (Array.isArray(rows) ? rows : []).find((row) => !row.is_read)
if (!latest) return
await presentNotification({
id: latest.id,
title: latest.title,
body: latest.body,
})
} catch {
// ignore
}
}
/** از SSE زنده — بدون Firebase / subscription */
function bumpUnreadFromStream(payload: Record<string, unknown>) {
const settingsStore = useSettingsStore()
if (!getToken() || !settingsStore.isSettingTruthy('has_page_notification')) {
return
}
const id = Number(payload.id ?? 0)
if (id > 0 && id === lastStreamNotifId) {
return
}
if (id > 0) {
lastStreamNotifId = id
}
unreadCount.value = Math.max(0, unreadCount.value) + 1
void presentNotification({
id,
title: String(payload.title ?? ''),
body: String(payload.body ?? ''),
})
void refreshUnreadCount()
}
export function useAppNotificationBadge() {
const settingsStore = useSettingsStore()
const enabled = computed(() => settingsStore.isSettingTruthy('has_page_notification'))
onMounted(() => {
if (started) {
void refreshUnreadCount()
return
}
started = true
void refreshUnreadCount()
// هر ۲۰ثانیه — اگر SSE قطع بود هم نوتیف دیرتر می‌آید
pollTimer = setInterval(() => {
void refreshUnreadCount()
}, 20000)
})
onUnmounted(() => {
// keep poll for other mounted headers
})
watch(enabled, (on) => {
if (!on) unreadCount.value = 0
else void refreshUnreadCount()
})
return {
enabled,
unreadCount,
refreshUnreadCount,
clearUnreadLocally,
}
}
export { refreshUnreadCount, clearUnreadLocally, bumpUnreadFromStream }
+10 -1
View File
@@ -45,6 +45,7 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useConfigStore } from '@/store/configStore'
import { useSettingsStore } from '@/store/settingsStore'
import { getProjectSplashLogo } from '@/utils/config'
import { getPrimaryNavItems, SECONDARY_NAV } from '../config/navigation'
import { useAppNavigation } from '../composables/useAppNavigation'
@@ -52,9 +53,17 @@ import DesktopNavItem from './DesktopNavItem.vue'
import AppCreditLink from '@/components/AppCreditLink.vue'
const config = useConfigStore()
const settingsStore = useSettingsStore()
const navLogo = computed(() => getProjectSplashLogo(config.config?.avatar))
const primaryItems = getPrimaryNavItems()
const secondaryItems = SECONDARY_NAV
const secondaryItems = computed(() =>
SECONDARY_NAV.filter((item) => {
if (item.id === 'notifyTrigger') {
return settingsStore.isSettingTruthy('has_page_price_trigger')
}
return true
}),
)
const { isNavActive, navigateTo } = useAppNavigation()
</script>
+13 -11
View File
@@ -2,24 +2,25 @@
:root {
--mpage-header-bar: 2.75rem;
--mpage-header-offset: calc(4rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
--mpage-subheader-offset: calc(var(--mpage-header-bar) + var(--app-safe-top, env(safe-area-inset-top, 0px)));
--mpage-content-with-subheader: calc(6.5rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
--mpage-scroll-max-height: calc(100dvh - 50px - var(--app-safe-top, env(safe-area-inset-top, 0px)));
--mobile-info-support-block: calc(5rem + var(--app-safe-bottom, env(safe-area-inset-bottom, 0px)));
/* وابسته به --app-safe-top تا با sync JS روی iOS درست بماند */
--mpage-header-offset: calc(4rem + var(--app-safe-top, 0px));
--mpage-subheader-offset: calc(var(--mpage-header-bar) + var(--app-safe-top, 0px));
--mpage-content-with-subheader: calc(6.5rem + var(--app-safe-top, 0px));
--mpage-scroll-max-height: calc(100dvh - 50px - var(--app-safe-top, 0px));
--mobile-info-support-block: calc(5rem + var(--app-safe-bottom, 0px));
}
/* Fixed top bar: background fills under status bar; content sits below safe area */
.mobile-top-chrome {
top: 0;
box-sizing: border-box;
padding-top: calc(0.75rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
padding-top: calc(0.75rem + var(--app-safe-top, 0px));
}
.mobile-top-chrome.h-12,
.mobile-top-chrome.min-h-12 {
height: auto;
min-height: calc(3rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
min-height: calc(3rem + var(--app-safe-top, 0px));
}
.mpage-header {
@@ -29,7 +30,8 @@
top: 0;
box-sizing: border-box;
padding: 0.75rem;
padding-top: calc(0.75rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
padding-top: calc(0.75rem + var(--app-safe-top, 0px));
min-height: calc(2.75rem + var(--app-safe-top, 0px));
background: #fff;
color: @App-primary-color;
font-size: 1rem;
@@ -85,15 +87,15 @@
}
.mobile-page-body--pt10 {
padding-top: calc(2.5rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
padding-top: calc(2.5rem + var(--app-safe-top, 0px));
}
.mobile-page-body--pt15 {
padding-top: calc(3.75rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
padding-top: calc(3.75rem + var(--app-safe-top, 0px));
}
.mobile-page-body--pt20 {
padding-top: calc(5rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
padding-top: calc(5rem + var(--app-safe-top, 0px));
}
/* Info pages (about, educate): fixed shell, scroll inside content box only */
+2
View File
@@ -4,6 +4,7 @@ import 'virtual:uno.css'
import '@unocss/reset/tailwind-compat.css'
import { initPwaInstallCapture } from '@/modules/pwa/manifest'
import { initSafeAreaInsets } from '@/utils/safeAreaInsets'
import { bootstrapApp } from '@/app/bootstrap'
export {
@@ -12,5 +13,6 @@ export {
setFingerPosition,
} from '@/app/authBoot'
initSafeAreaInsets()
initPwaInstallCapture()
void bootstrapApp()
+1
View File
@@ -401,6 +401,7 @@ export async function applyPreparedManifestToDocument(
upsertMeta('apple-mobile-web-app-title', title)
upsertMeta('mobile-web-app-capable', 'yes')
upsertMeta('apple-mobile-web-app-capable', 'yes')
upsertMeta('apple-mobile-web-app-status-bar-style', 'black-translucent')
applyAppleTouchIconsFromManifest(normalized)
+5
View File
@@ -61,6 +61,11 @@ const router = createRouter({
name: 'notifyTrigger',
component: () => import('@/views/base/priceTrigger/index.vue')
},
{
path: 'appNotifications',
name: 'appNotifications',
component: () => import('@/views/base/appNotifications/index.vue')
},
{
path: 'address',
name: 'address',
+31 -1
View File
@@ -1,7 +1,37 @@
import { precacheAndRoute } from 'workbox-precaching'
declare let self: ServiceWorkerGlobalScope
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting()
})
// self.__WB_MANIFEST is default injection point
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const targetUrl = String(event.notification.data?.url || '/appNotifications')
event.waitUntil(
(async () => {
const allClients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
})
for (const client of allClients) {
if ('focus' in client) {
await client.focus()
if ('navigate' in client) {
try {
await (client as WindowClient).navigate(targetUrl)
} catch {
// ignore
}
}
return
}
}
await self.clients.openWindow(targetUrl)
})(),
)
})
precacheAndRoute(self.__WB_MANIFEST)
+150
View File
@@ -0,0 +1,150 @@
/** نوتیف مرورگر بدون Firebase — فقط با کلیک کاربر (gesture) قابل درخواست است. */
export type BrowserNotifState =
| 'unsupported'
| 'needs-pwa'
| 'default'
| 'granted'
| 'denied'
function isStandalonePwa() {
if (typeof window === 'undefined') return false
return (
window.matchMedia('(display-mode: standalone)').matches ||
Boolean((window.navigator as Navigator & { standalone?: boolean }).standalone)
)
}
/** فقط دستگاه‌های واقعی اپل موبایل — نه مک با ترک‌پد */
function isAppleMobileDevice() {
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent || ''
if (/iPhone|iPod/i.test(ua)) return true
if (/iPad/i.test(ua)) return true
const isTouchMac = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1
if (!isTouchMac) return false
if (/CriOS|FxiOS|EdgiOS/i.test(ua)) return true
if (/Chrome|Edg|Firefox|OPR/i.test(ua) && !/Mobile/i.test(ua)) return false
return true
}
export function getBrowserNotificationState(): BrowserNotifState {
if (typeof window === 'undefined') return 'unsupported'
if (typeof window.isSecureContext === 'boolean' && !window.isSecureContext) {
return 'unsupported'
}
const hasApi = 'Notification' in window
if (isAppleMobileDevice() && !isStandalonePwa() && !hasApi) {
return 'needs-pwa'
}
if (!hasApi) {
return 'unsupported'
}
const permission = Notification.permission
if (permission === 'granted') return 'granted'
if (permission === 'denied') return 'denied'
return 'default'
}
export function canRequestBrowserNotification() {
return getBrowserNotificationState() === 'default'
}
export async function requestBrowserNotificationPermission(): Promise<BrowserNotifState> {
const before = getBrowserNotificationState()
if (before === 'granted' || before === 'denied' || before === 'unsupported' || before === 'needs-pwa') {
return before
}
if (!('Notification' in window)) {
return getBrowserNotificationState()
}
try {
const result = await Notification.requestPermission()
if (result === 'granted') return 'granted'
if (result === 'denied') return 'denied'
return 'default'
} catch {
return getBrowserNotificationState()
}
}
function resolveNotifIcon() {
try {
return new URL('/favicon.ico', window.location.origin).href
} catch {
return undefined
}
}
export type ShowBrowserNotificationOptions = {
title: string
body?: string
tag?: string
url?: string
}
/** نمایش نوتیف سیستم؛ ترجیح با Service Worker (معتبرتر روی Chrome/Android). */
export async function showBrowserNotification(options: ShowBrowserNotificationOptions): Promise<boolean> {
if (getBrowserNotificationState() !== 'granted') {
return false
}
const title = options.title || 'اعلان جدید'
const body = options.body || ''
const tag = options.tag || `app-notification-${Date.now()}`
const url = options.url || '/appNotifications'
const icon = resolveNotifIcon()
// ۱) Service Worker — روی Chrome/Android پایدارتر است
try {
if ('serviceWorker' in navigator) {
const reg = await navigator.serviceWorker.ready
if (reg?.showNotification) {
await reg.showNotification(title, {
body,
tag,
icon,
badge: icon,
data: { url },
renotify: true,
})
return true
}
}
} catch (error) {
console.warn('SW showNotification failed, falling back', error)
}
// ۲) Notification constructor
try {
const n = new Notification(title, {
body,
tag,
icon,
dir: 'rtl',
lang: 'fa',
})
n.onclick = () => {
window.focus()
n.close()
if (url) {
void import('@/router').then((mod) => {
mod.default.push(url).catch(() => undefined)
})
}
}
return true
} catch (error) {
console.warn('Notification constructor failed', error)
return false
}
}
+26
View File
@@ -375,6 +375,24 @@ function handleAssetsPayload(payload: unknown) {
void applyAssetsFromStream(payload)
}
function handleAppNotificationPayload(payload: unknown) {
if (!payload || typeof payload !== 'object') {
return
}
touchStreamActivity()
void applyAppNotificationFromStream(payload as Record<string, unknown>)
}
async function applyAppNotificationFromStream(payload: Record<string, unknown>) {
try {
const { bumpUnreadFromStream } = await import('@/composables/useAppNotificationBadge')
bumpUnreadFromStream(payload)
} catch (error) {
console.warn('App notification SSE apply failed', error)
}
}
function bindStreamEvents(source: EventSource) {
source.addEventListener('last', (event) => {
try {
@@ -400,6 +418,14 @@ function bindStreamEvents(source: EventSource) {
}
})
source.addEventListener('app_notification', (event) => {
try {
handleAppNotificationPayload(JSON.parse(event.data))
} catch (error) {
console.warn('App notification SSE parse failed', error)
}
})
source.addEventListener('ping', () => {
touchStreamActivity()
})
+59
View File
@@ -0,0 +1,59 @@
/**
* روی بعضی buildهای iOS PWA، env(safe-area-inset-*) داخل CSS var صفر می‌ماند
* در حالی که محتوا واقعاً زیر نوار وضعیت رسم می‌شود. این تابع مقدار واقعی را
* با یک probe می‌خواند و روی :root می‌نشاند.
*/
export function syncSafeAreaInsets() {
if (typeof document === 'undefined' || typeof window === 'undefined') {
return
}
const probe = document.createElement('div')
probe.setAttribute('aria-hidden', 'true')
probe.style.cssText = [
'position:fixed',
'top:0',
'left:0',
'width:0',
'height:0',
'padding:env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px)',
'visibility:hidden',
'pointer-events:none',
'z-index:-1',
].join(';')
document.documentElement.appendChild(probe)
const style = getComputedStyle(probe)
const top = style.paddingTop || '0px'
const right = style.paddingRight || '0px'
const bottom = style.paddingBottom || '0px'
const left = style.paddingLeft || '0px'
probe.remove()
const root = document.documentElement
root.style.setProperty('--app-safe-top', top)
root.style.setProperty('--app-safe-bottom', bottom)
root.style.setProperty('--app-safe-right', right)
root.style.setProperty('--app-safe-left', left)
// اگر env صفر بود ولی PWA standalone هستیم، حداقل فضای نوار وضعیت را جبران کن
const topPx = Number.parseFloat(top) || 0
const isStandalone =
window.matchMedia('(display-mode: standalone)').matches ||
// iOS Safari legacy
Boolean((window.navigator as Navigator & { standalone?: boolean }).standalone)
if (isStandalone && topPx < 1) {
// Dynamic Island ≈ ۵۹px؛ بدون env حداقل این را جبران می‌کنیم
root.style.setProperty('--app-safe-top', '59px')
}
}
export function initSafeAreaInsets() {
syncSafeAreaInsets()
window.addEventListener('orientationchange', () => {
window.setTimeout(syncSafeAreaInsets, 100)
})
window.visualViewport?.addEventListener('resize', syncSafeAreaInsets)
}
+15
View File
@@ -197,20 +197,35 @@ export type PriceDisplayState = 'open' | 'closed' | 'no_quote'
export const NO_QUOTE_MESSAGE = 'مظنه در دسترس نیست'
/** سوییچ: خارج از ساعت کاری هم قیمت صفحه اصلی نشان داده شود */
export function shouldShowHomePriceWhenClosed(): boolean {
const settings = useSettingsStore()
return settings.isSettingTruthy('show_home_price_when_closed')
}
/**
* وضعیت نمایش قیمت:
* - closed → فروشگاه بسته است (ساعت کاری، سیگنال سرور، یا نبود مظنه)
* - open → نمایش عادی قیمت
*
* اگر show_home_price_when_closed روشن باشد و قیمت موجود باشد، به‌جای closed قیمت نشان داده می‌شود.
*/
export function getPriceDisplayState(): PriceDisplayState {
void productTradingHoursVersion.value
const p = lastPrice.value
const showWhenClosed = shouldShowHomePriceWhenClosed()
if (p?.status === 'closed' || p?.closed === true) {
if (showWhenClosed && hasLivePriceQuote()) {
return 'open'
}
return 'closed'
}
if (isStoreClosed()) {
if (showWhenClosed && hasLivePriceQuote()) {
return 'open'
}
return 'closed'
}
@@ -0,0 +1,96 @@
<template>
<article
class="app-notif-card"
:class="[
`app-notif-card--${item.severity}`,
{ 'app-notif-card--unread': !item.is_read },
{ 'app-notif-card--compact': compact },
]"
@click="$emit('open', item.id)"
>
<div class="flex items-start justify-between gap-2">
<div class="flex min-w-0 flex-1 items-center gap-2">
<h3 class="app-notif-card__title m-0 font-bold text-gray-900">{{ item.title }}</h3>
<span v-if="!item.is_read" class="app-notif-card__new">جدید</span>
</div>
<span class="app-notif-card__badge">{{ severityLabel(item.severity) }}</span>
</div>
<p class="app-notif-card__body app-notif-card__body--preview m-0 mt-2 text-sm text-gray-600">
{{ previewText(item.body) }}
</p>
<div class="app-notif-card__footer">
<span class="app-notif-card__more">مشاهده کامل</span>
<BasicIcon icon="vuesax-linear:arrow-left-1" size="16" class="text-gray-500" />
</div>
</article>
</template>
<script setup lang="ts">
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import type { CustomerAppNotification } from '@/api/appNotifications'
import { previewText, severityLabel } from '../composables/notificationDisplay'
withDefaults(
defineProps<{
item: CustomerAppNotification
compact?: boolean
}>(),
{ compact: false },
)
defineEmits<{
open: [id: number]
}>()
</script>
<style scoped>
.app-notif-card {
@apply rounded-xl bg-white p-3 border border-gray-100 cursor-pointer transition-shadow;
}
.app-notif-card--compact {
@apply p-4;
}
.app-notif-card--unread {
border-color: #ef4444;
box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.25);
}
.app-notif-card__title {
@apply text-sm;
}
.app-notif-card--compact .app-notif-card__title {
@apply text-base;
}
.app-notif-card__new {
@apply shrink-0 rounded-md bg-red-50 px-1.5 py-0.5 text-[10px] font-bold text-red-600;
}
.app-notif-card__badge {
@apply shrink-0 rounded-md px-2 py-0.5 text-xs font-bold;
}
.app-notif-card__body--preview {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
white-space: normal;
}
.app-notif-card__footer {
@apply mt-3 flex items-center justify-between gap-2 border-0 border-t border-solid border-gray-100 pt-2;
}
.app-notif-card__more {
@apply text-xs font-bold text-gray-600;
}
.app-notif-card--info .app-notif-card__badge {
@apply bg-blue-50 text-blue-700;
}
.app-notif-card--success .app-notif-card__badge {
@apply bg-green-50 text-green-700;
}
.app-notif-card--warning .app-notif-card__badge {
@apply bg-orange-50 text-orange-700;
}
.app-notif-card--danger .app-notif-card__badge {
@apply bg-red-50 text-red-700;
}
</style>
@@ -0,0 +1,132 @@
<template>
<div v-if="visible" class="notif-perm">
<div class="notif-perm__text">
<p class="notif-perm__title">{{ title }}</p>
<p class="notif-perm__desc">{{ description }}</p>
</div>
<button
v-if="canEnable"
type="button"
class="notif-perm__btn"
:disabled="loading"
@click="onEnable"
>
{{ loading ? '…' : 'فعال‌سازی' }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
getBrowserNotificationState,
requestBrowserNotificationPermission,
type BrowserNotifState,
} from '@/utils/browserNotification'
const emit = defineEmits<{
changed: [state: BrowserNotifState]
}>()
const state = ref<BrowserNotifState>('unsupported')
const loading = ref(false)
const dismissed = ref(false)
const visible = computed(() => {
if (dismissed.value) return false
return state.value === 'default' || state.value === 'needs-pwa' || state.value === 'denied'
})
const canEnable = computed(() => state.value === 'default')
const title = computed(() => {
if (state.value === 'needs-pwa') return 'اعلان سیستم در دسترس نیست'
if (state.value === 'denied') return 'اعلان سیستم غیرفعال است'
return 'اعلان‌های فوری'
})
const description = computed(() => {
if (state.value === 'needs-pwa') {
return 'برای دریافت نوتیف روی آیفون، اپ را به صفحه اصلی اضافه کنید و از همان‌جا باز کنید.'
}
if (state.value === 'denied') {
return 'مجوز اعلان در تنظیمات مرورگر مسدود است. از قفل کنار آدرس → Site settings → Notifications اجازه بدهید.'
}
return 'روی «فعال‌سازی» بزنید تا مرورگر اجازه نوتیف را بپرسد.'
})
function refreshState() {
state.value = getBrowserNotificationState()
emit('changed', state.value)
}
async function onEnable() {
loading.value = true
try {
const next = await requestBrowserNotificationPermission()
state.value = next
emit('changed', next)
if (next === 'granted') {
dismissed.value = true
}
} finally {
loading.value = false
}
}
onMounted(() => {
refreshState()
})
defineExpose({ refreshState })
</script>
<style scoped>
.notif-perm {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0.9rem;
border-radius: 0.85rem;
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.notif-perm__text {
flex: 1 1 auto;
min-width: 0;
}
.notif-perm__title {
margin: 0;
font-size: 0.8125rem;
font-weight: 700;
color: #0f172a;
}
.notif-perm__desc {
margin: 0.2rem 0 0;
font-size: 0.72rem;
line-height: 1.45;
color: #64748b;
}
.notif-perm__btn {
flex-shrink: 0;
border: none;
border-radius: 999px;
padding: 0.45rem 0.85rem;
font: inherit;
font-size: 0.75rem;
font-weight: 700;
color: #fff;
background: var(--App-primary, #0b5fff);
cursor: pointer;
}
.notif-perm__btn:disabled {
opacity: 0.6;
cursor: default;
}
</style>
@@ -0,0 +1,24 @@
const PREVIEW_MAX = 90
export function severityLabel(severity: string) {
switch (severity) {
case 'success':
return 'موفق'
case 'warning':
return 'هشدار'
case 'danger':
return 'مهم'
default:
return 'اطلاعات'
}
}
export function previewText(body: string, max = PREVIEW_MAX): string {
const text = String(body ?? '').replace(/\s+/g, ' ').trim()
if (text.length <= max) return text
return `${text.slice(0, max).trim()}…`
}
export function needsPreview(body: string, max = PREVIEW_MAX): boolean {
return String(body ?? '').replace(/\s+/g, ' ').trim().length > max
}
@@ -0,0 +1,120 @@
import { computed, inject, onMounted, provide, ref, type InjectionKey } from 'vue'
import {
getAppNotifications,
markAllAppNotificationsRead,
markAppNotificationRead,
type CustomerAppNotification,
} from '@/api/appNotifications'
import { useSettingsStore } from '@/store/settingsStore'
export interface AppNotificationsPageContext {
loading: ReturnType<typeof ref<boolean>>
items: ReturnType<typeof ref<CustomerAppNotification[]>>
detailVisible: ReturnType<typeof ref<boolean>>
selectedItem: ReturnType<typeof ref<CustomerAppNotification | null>>
enabled: ReturnType<typeof computed<boolean>>
refresh: () => Promise<void>
openItem: (id: number) => Promise<void>
closeDetail: () => void
markRead: (id: number) => Promise<void>
markAllRead: () => Promise<void>
}
export const APP_NOTIFICATIONS_PAGE_KEY: InjectionKey<AppNotificationsPageContext> =
Symbol('appNotificationsPage')
export function useAppNotificationsPage() {
const settingsStore = useSettingsStore()
const loading = ref(false)
const items = ref<CustomerAppNotification[]>([])
const detailVisible = ref(false)
const selectedItem = ref<CustomerAppNotification | null>(null)
const enabled = computed(() => settingsStore.isSettingTruthy('has_page_notification'))
async function refresh() {
if (!enabled.value) {
items.value = []
return
}
loading.value = true
try {
const data = await getAppNotifications()
items.value = Array.isArray(data) ? data : []
} catch (e) {
console.error(e)
items.value = []
} finally {
loading.value = false
}
}
async function markRead(id: number) {
const row = items.value.find((item) => item.id === id)
if (!row || row.is_read) return
try {
await markAppNotificationRead(id)
row.is_read = true
if (selectedItem.value?.id === id) {
selectedItem.value = { ...selectedItem.value, is_read: true }
}
const { refreshUnreadCount } = await import('@/composables/useAppNotificationBadge')
void refreshUnreadCount()
} catch (e) {
console.error(e)
}
}
/** باز کردن جزئیات در مدال/اکشن‌شیت + سین */
async function openItem(id: number) {
const row = items.value.find((item) => item.id === id)
if (!row) return
selectedItem.value = row
detailVisible.value = true
await markRead(id)
}
function closeDetail() {
detailVisible.value = false
selectedItem.value = null
}
async function markAllRead() {
try {
await markAllAppNotificationsRead()
items.value = items.value.map((item) => ({ ...item, is_read: true }))
const { clearUnreadLocally } = await import('@/composables/useAppNotificationBadge')
clearUnreadLocally()
} catch (e) {
console.error(e)
}
}
onMounted(() => {
void refresh()
})
const ctx: AppNotificationsPageContext = {
loading,
items,
detailVisible,
selectedItem,
enabled,
refresh,
openItem,
closeDetail,
markRead,
markAllRead,
}
provide(APP_NOTIFICATIONS_PAGE_KEY, ctx)
return ctx
}
export function useAppNotificationsPageContext() {
const ctx = inject(APP_NOTIFICATIONS_PAGE_KEY)
if (!ctx) {
throw new Error('useAppNotificationsPageContext must be used within useAppNotificationsPage')
}
return ctx
}
@@ -0,0 +1,55 @@
<template>
<div class="dapp-notif">
<header class="dapp-notif__top">
<div>
<p class="dapp-notif__eyebrow">اعلان‌ها</p>
<h1 class="dapp-notif__title">پیام‌های مدیریت</h1>
</div>
</header>
<BrowserNotificationPermissionBanner class="mb-4 max-w-3xl" />
<a-spin :spinning="loading">
<div v-if="!items.length" class="dapp-notif__empty">
<BasicIcon icon="vuesax-linear:notification-bing" size="36" />
<p>اعلانی برای نمایش نیست.</p>
</div>
<div v-else class="dapp-notif__list">
<AppNotificationCard
v-for="item in items"
:key="item.id"
:item="item"
compact
@open="openItem"
/>
</div>
</a-spin>
</div>
</template>
<script setup lang="ts">
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import AppNotificationCard from '../components/AppNotificationCard.vue'
import BrowserNotificationPermissionBanner from '../components/BrowserNotificationPermissionBanner.vue'
import { useAppNotificationsPageContext } from '../composables/useAppNotificationsPage'
const { loading, items, openItem } = useAppNotificationsPageContext()
</script>
<style scoped>
.dapp-notif {
@apply p-6 min-h-full;
}
.dapp-notif__eyebrow {
@apply m-0 text-xs text-gray-500;
}
.dapp-notif__title {
@apply m-0 mt-1 text-xl font-bold;
}
.dapp-notif__empty {
@apply mt-10 flex flex-col items-center gap-2 text-gray-500;
}
.dapp-notif__list {
@apply mt-6 flex flex-col gap-3 max-w-3xl;
}
</style>
+73
View File
@@ -0,0 +1,73 @@
<template>
<div class="app-notifications-page-root">
<DesktopAppNotificationsView v-show="isDesktop" />
<MobileAppNotificationsView v-show="!isDesktop" />
<BasicActionSheet
v-model:show="detailVisible"
:title="selectedItem?.title || 'جزئیات اعلان'"
width="32rem"
:show-button="true"
:show-cancel="false"
:show-ok="true"
ok-text="بستن"
@ok="closeDetail"
@close="closeDetail"
@cancel="closeDetail"
>
<div v-if="selectedItem" class="app-notif-detail">
<div class="app-notif-detail__meta">
<span
class="app-notif-detail__badge"
:class="`app-notif-detail__badge--${selectedItem.severity}`"
>
{{ severityLabel(selectedItem.severity) }}
</span>
</div>
<p class="app-notif-detail__body">{{ selectedItem.body }}</p>
</div>
</BasicActionSheet>
</div>
</template>
<script lang="ts" setup>
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useAppNotificationsPage } from './composables/useAppNotificationsPage'
import { severityLabel } from './composables/notificationDisplay'
import DesktopAppNotificationsView from './desktop/DesktopAppNotificationsView.vue'
import MobileAppNotificationsView from './mobile/MobileAppNotificationsView.vue'
const isDesktop = useDesktopBreakpoint()
const { detailVisible, selectedItem, closeDetail } = useAppNotificationsPage()
</script>
<style scoped>
.app-notifications-page-root {
min-height: 100%;
height: 100%;
}
.app-notif-detail__meta {
@apply mb-3 flex items-center;
}
.app-notif-detail__badge {
@apply rounded-md px-2 py-0.5 text-xs font-bold;
}
.app-notif-detail__badge--info {
@apply bg-blue-50 text-blue-700;
}
.app-notif-detail__badge--success {
@apply bg-green-50 text-green-700;
}
.app-notif-detail__badge--warning {
@apply bg-orange-50 text-orange-700;
}
.app-notif-detail__badge--danger {
@apply bg-red-50 text-red-700;
}
.app-notif-detail__body {
@apply m-0 whitespace-pre-wrap text-sm leading-7 text-gray-700;
max-height: min(60vh, 28rem);
overflow-y: auto;
}
</style>
@@ -0,0 +1,45 @@
<template>
<div class="app-notif-page">
<MobilePageHeader title="اعلان‌ها" show-back />
<div class="app-notif-page__body mpage-content">
<BrowserNotificationPermissionBanner class="mb-3" />
<a-spin :spinning="loading">
<div v-if="!items.length" class="app-notif-page__empty">
<BasicIcon icon="vuesax-linear:notification-bing" size="40" class="text-gray-400" />
<p class="m-0 mt-3 font-bold text-gray-700">اعلانی نیست</p>
<p class="m-0 mt-1 text-sm text-gray-500">هنوز اعلانی از طرف مدیریت ارسال نشده است.</p>
</div>
<div v-else class="flex flex-col gap-3">
<AppNotificationCard
v-for="item in items"
:key="item.id"
:item="item"
@open="openItem"
/>
</div>
</a-spin>
</div>
</div>
</template>
<script setup lang="ts">
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import MobilePageHeader from '@/layouts/components/MobilePageHeader.vue'
import AppNotificationCard from '../components/AppNotificationCard.vue'
import BrowserNotificationPermissionBanner from '../components/BrowserNotificationPermissionBanner.vue'
import { useAppNotificationsPageContext } from '../composables/useAppNotificationsPage'
const { loading, items, openItem } = useAppNotificationsPageContext()
</script>
<style scoped>
.app-notif-page {
@apply min-h-full flex flex-col bg-gray-50;
}
.app-notif-page__body {
@apply px-4 pb-8;
}
.app-notif-page__empty {
@apply flex flex-col items-center justify-center rounded-xl bg-white p-8 text-center;
}
</style>
+3 -7
View File
@@ -10,7 +10,7 @@
<div class="w-full mr-3">
<div class="flex justify-between w-full">
<div>{{ item.title }}</div>
<div>{{ toJalaliNotime(item.expire_at) }}</div>
<div>{{ toJalali(item.expire_at) }}</div>
</div>
<div class="flex justify-between mt-2">
<div :class="item.gold >= 0? 'text-green-600':'text-red-600'" class="text-gray-500 text-xs number-ltr">
@@ -69,11 +69,7 @@
</div>
<div class="flex justify-between mb-3">
<div class="text-primary">تاریخ:</div>
<div>{{ toJalaliNotime(item.expire_at) }}</div>
</div>
<div class="flex justify-between mb-3">
<div class="text-primary">زمان:</div>
<div>{{ toTime(item.expire_at) }}</div>
<div>{{ toJalali(item.expire_at) }}</div>
</div>
<BasicButton class="w-full mt-5" @click="infoVisible = false">بازگشت</BasicButton>
</div>
@@ -89,7 +85,7 @@ import {computed, ref} from "vue";
import BasicButton from "@/components/BasicPacks/BasicButton.vue";
import type {Transaction} from "@/models/transaction";
import {formatGoldAmount, usePriceWithoutType} from "../../../../../utils/usePrice";
import {toJalaliNotime, toTime} from "../../../../../utils/useDateTime";
import {toJalali} from "../../../../../utils/useDateTime";
const infoVisible = ref<boolean>(false)
+56 -1
View File
@@ -10,6 +10,20 @@
</div>
<div class="flex items-center gap-2">
<button
v-if="notificationEnabled"
type="button"
class="mhome-header-btn mhome-header-btn--notify"
aria-label="اعلان"
@click.stop="goNotifications"
>
<span class="mhome-header-btn__icon-wrap">
<Icon icon="vuesax-linear:notification-bing" size="18" />
<span v-if="unreadCount > 0" class="mhome-header-btn__badge">
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
</span>
</button>
<button
type="button"
class="mhome-header-btn"
@@ -36,12 +50,27 @@ import { useRoute } from 'vue-router'
import router from '@/router'
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { getProjectMobileNavLogo } from '@/utils/config'
import { useAppNotificationBadge } from '@/composables/useAppNotificationBadge'
import { canRequestBrowserNotification, requestBrowserNotificationPermission } from '@/utils/browserNotification'
const headerRef = ref<HTMLElement | null>(null)
const isHeaderScrolled = ref(false)
const route = useRoute()
const projectLogo = computed(() => getProjectMobileNavLogo())
const {
enabled: notificationEnabled,
unreadCount,
clearUnreadLocally,
} = useAppNotificationBadge()
async function goNotifications() {
clearUnreadLocally()
if (canRequestBrowserNotification()) {
await requestBrowserNotificationPermission()
}
void router.push({ name: 'appNotifications' })
}
let intersectionObserver: IntersectionObserver | null = null
let scrollTarget: HTMLElement | Window | null = null
@@ -177,7 +206,7 @@ defineEmits<{
.app-header {
z-index: 1999;
box-sizing: border-box;
padding-top: calc(0.5rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
padding-top: calc(0.5rem + var(--app-safe-top, 0px));
background-color: transparent;
}
@@ -209,4 +238,30 @@ defineEmits<{
border-color: #fecaca;
background: #fef2f2;
}
.mhome-header-btn--notify {
color: #0a1b37;
border-color: #e5e7eb;
background: #fff;
}
.mhome-header-btn__icon-wrap {
@apply relative inline-flex;
}
.mhome-header-btn__badge {
position: absolute;
top: -8px;
left: -8px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: #dc2626;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
}
</style>
@@ -10,6 +10,24 @@
<BasicIcon icon="vuesax-linear:user" size="16" />
<span>حساب کاربری</span>
</button>
<button
v-if="notificationEnabled"
type="button"
class="dhome-btn-ghost dhome-btn-ghost--notify"
aria-label="اعلان"
@click="goNotifications"
>
<span class="dhome-notify-wrap">
<BasicIcon icon="vuesax-linear:notification-bing" size="16" />
<span v-if="unreadCount > 0" class="dhome-notify-badge">
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
</span>
<span>اعلان</span>
</button>
<button type="button" class="dhome-btn-ghost" aria-label="بروزرسانی" @click="reloadApp">
<BasicIcon icon="vuesax-linear:refresh-2" size="16" />
</button>
<button
v-if="showLogout"
type="button"
@@ -26,6 +44,8 @@
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useUserStore } from '@/store/userStore'
import { useAppNotificationBadge } from '@/composables/useAppNotificationBadge'
import { canRequestBrowserNotification, requestBrowserNotificationPermission } from '@/utils/browserNotification'
import router from '@/router'
withDefaults(
@@ -40,4 +60,43 @@ defineEmits<{
}>()
const userStore = useUserStore()
const {
enabled: notificationEnabled,
unreadCount,
clearUnreadLocally,
} = useAppNotificationBadge()
async function goNotifications() {
clearUnreadLocally()
if (canRequestBrowserNotification()) {
await requestBrowserNotificationPermission()
}
void router.push({ name: 'appNotifications' })
}
function reloadApp() {
window.location.reload()
}
</script>
<style scoped>
.dhome-notify-wrap {
position: relative;
display: inline-flex;
}
.dhome-notify-badge {
position: absolute;
top: -8px;
left: -8px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: #dc2626;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
}
</style>
@@ -1,6 +1,13 @@
<template>
<div class="flex bg-white border-1 border-gray-200 rounded-lg"
:class="isClickable ? 'bg-white opacity-100' : 'bg-gray-200 opacity-50 cursor-not-allowed'">
<div
class="flex bg-white border-1 border-gray-200 rounded-lg"
:class="{
'bg-white opacity-100': isClickable,
'bg-gray-200 opacity-50 cursor-not-allowed': !isClickable && !locked,
'product-item--locked': locked,
}"
@click="handleClick"
>
<div class="bg-gray-100 m-1.5 rounded-lg w-24 h-24 flex justify-center p-1.5 shrink-0">
<img :src="displayImage" class="object-contain w-full h-full" alt="">
</div>
@@ -24,20 +31,36 @@ import { getProductDisplayImage } from '@/utils/productAssets'
interface Prop {
ProductItem: Product
inactive?: boolean
/** فروشگاه بسته — خاکستری و غیرقابل کلیک (قیمت همچنان نمایش داده می‌شود) */
locked?: boolean
}
const props = withDefaults(defineProps<Prop>(), {
inactive: false,
locked: false,
})
const isClickable = computed(() => props.ProductItem.show && !props.inactive)
const emit = defineEmits<{
click: []
}>()
const isClickable = computed(() => props.ProductItem.show && !props.inactive && !props.locked)
const displayImage = computed(() => getProductDisplayImage(props.ProductItem))
function handleClick() {
if (!isClickable.value) return
emit('click')
}
</script>
<style scoped>
.product-item--locked {
filter: grayscale(0.85);
opacity: 0.55;
background: #f3f4f6;
cursor: not-allowed;
}
</style>
@@ -42,6 +42,7 @@
:key="item.id"
:product="item"
:inactive="isProductPriceInactive(item)"
:locked="storeClosed"
@click="clickProduct(item)"
/>
<div v-if="!loading && !items?.length" class="dproducts-empty">
@@ -57,7 +58,7 @@
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
import DesktopProductCard from './components/DesktopProductCard.vue'
import { useProductsPageContext } from '../composables/useProductsPage'
import { isMarketClosedForDisplay, STORE_CLOSED_TITLE } from '@/utils/storeHours'
import { isStoreClosed, STORE_CLOSED_TITLE } from '@/utils/storeHours'
import { computed } from 'vue'
const {
@@ -71,7 +72,7 @@ const {
storeClosedMessage,
} = useProductsPageContext()
const storeClosed = computed(() => isMarketClosedForDisplay())
const storeClosed = computed(() => isStoreClosed())
</script>
<style lang="less" src="./styles/desktop-products.less"></style>
@@ -2,7 +2,10 @@
<button
type="button"
class="dproducts-card"
:class="{ 'dproducts-card--disabled': !isClickable }"
:class="{
'dproducts-card--disabled': !isClickable,
'dproducts-card--locked': locked,
}"
:disabled="!isClickable"
@click="$emit('click')"
>
@@ -39,15 +42,17 @@ const props = withDefaults(
defineProps<{
product: Product
inactive?: boolean
/** فروشگاه بسته — خاکستری و غیرقابل کلیک */
locked?: boolean
}>(),
{ inactive: false },
{ inactive: false, locked: false },
)
defineEmits<{
click: []
}>()
const isClickable = computed(() => props.product.show && !props.inactive)
const isClickable = computed(() => props.product.show && !props.inactive && !props.locked)
const displayImage = computed(() => getProductDisplayImage(props.product))
@@ -61,7 +61,7 @@
font-family: inherit;
transition: box-shadow 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
&:hover:not(&--disabled) {
&:hover:not(&--disabled):not(&--locked) {
border-color: #e5e7eb;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
transform: translateY(-1px);
@@ -72,6 +72,12 @@
opacity: 0.55;
}
&--locked {
filter: grayscale(0.85);
opacity: 0.55;
background: #f3f4f6;
}
&__image-wrap {
display: flex;
align-items: center;
@@ -45,6 +45,7 @@
<ProductItem
:ProductItem="item"
:inactive="isProductPriceInactive(item)"
:locked="storeClosed"
@click="clickProduct(item)"
/>
</div>
@@ -59,7 +60,7 @@ import img from '@/assets/img/user_ly1.svg'
import MobilePageHeader from '@/layouts/components/MobilePageHeader.vue'
import ProductItem from '@/views/base/products/components/ProductItem.vue'
import { useProductsPageContext } from '../composables/useProductsPage'
import { isMarketClosedForDisplay, STORE_CLOSED_TITLE } from '@/utils/storeHours'
import { isStoreClosed, STORE_CLOSED_TITLE } from '@/utils/storeHours'
import { computed } from 'vue'
const {
@@ -73,7 +74,7 @@ const {
storeClosedMessage,
} = useProductsPageContext()
const storeClosed = computed(() => isMarketClosedForDisplay())
const storeClosed = computed(() => isStoreClosed())
</script>
<style scoped>
@@ -100,11 +100,7 @@
</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">تاریخ:</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__value' : ''">{{ toJalaliNotime(item.expire_at) }}</div>
</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">زمان:</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__value' : ''">{{ toTime(item.expire_at) }}</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__value' : ''">{{ toJalali(item.expire_at) }}</div>
</div>
<div v-if="item.eaj" :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">مهلت ارسال فیش:</div>
@@ -129,7 +125,7 @@ import {
resolveTransactionTradeProduct,
shouldShowTransactionTotalWeightLine,
} from '@/utils/productDealMethod'
import { toJalaliNotime, toTime } from '@/utils/useDateTime'
import { toJalali } from '@/utils/useDateTime'
const props = withDefaults(
defineProps<{
@@ -17,6 +17,7 @@ import { useConfigStore } from '@/store/configStore'
import { getBoursecuActionIconSrc } from '@/utils/config'
import { getReferralShareText, getReferralShareTitle, shareReferralText } from '@/utils/referralShare'
import { normalizeAuthFormFields } from '@/utils/persianDigits'
import { useSettingsStore } from '@/store/settingsStore'
export interface UserMenuAction {
id: string
@@ -64,6 +65,7 @@ export function useUserPage() {
const formRef = ref<FormInstance>()
const formPassRef = ref<FormInstance>()
const userStore = useUserStore()
const settingsStore = useSettingsStore()
const nameProject = useConfigStore().config.name_fa
const editPassVisible = ref(false)
const editPasswordVisible = ref(false)
@@ -117,7 +119,7 @@ export function useUserPage() {
label: 'هشدار قیمت',
icon: 'vuesax-linear:bill',
onClick: () => router.push({ name: 'notifyTrigger' }),
visible: true,
visible: settingsStore.isSettingTruthy('has_page_price_trigger'),
},
{
id: 'about',
+3 -7
View File
@@ -20,7 +20,7 @@
<div class="w-full mr-3">
<div class="flex justify-between w-full">
<div>{{ item.type_str }}</div>
<div>{{ toJalaliNotime(item.created_at) }}</div>
<div>{{ toJalali(item.created_at) }}</div>
</div>
<div class="flex justify-between mt-2">
<div :class="item.gold >= 0? 'text-green-600':'text-red-600'" class="text-gray-500 text-xs number-ltr">
@@ -111,11 +111,7 @@
</div>
<div class="flex justify-between mb-3">
<div class="text-black">تاریخ:</div>
<div>{{ toJalaliNotime(item.expire_at) }}</div>
</div>
<div class="flex justify-between mb-3">
<div class="text-black">زمان:</div>
<div>{{ toTime(item.expire_at) }}</div>
<div>{{ toJalali(item.expire_at) }}</div>
</div>
<!-- <div class="flex justify-between mb-3">
<div class="text-black">وضعیت معامله:</div>
@@ -144,7 +140,7 @@ import {computed, ref} from "vue";
import BasicButton from "@/components/BasicPacks/BasicButton.vue";
import type {Transaction} from "@/models/transaction";
import {getCashWalletLabel, getGoldWalletLabel, getMetalTypeTradeLabel, getTradePriceUnitLabel, getWeightUnit} from '@/utils/productDealMethod'
import {toJalaliNotime, toJalaliOutOfUTC, toTime} from "../../../../utils/useDateTime";
import {toJalali, toJalaliOutOfUTC} from "../../../../utils/useDateTime";
const infoVisible = ref<boolean>(false)