Port modular app notifications, pargar date display, and closed-store product UX.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user