diff --git a/src/App.vue b/src/App.vue index 08e3eea..2073315 100644 --- a/src/App.vue +++ b/src/App.vue @@ -155,7 +155,7 @@ const { } = useDraggableFloatingButton('ghatreh_support_button_position_v1', { width: 118, height: 42, - defaultBottomOffset: 100, + defaultBottomGap: 12, defaultRightOffset: 16, }) diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts index 46ec19d..d36ec1a 100644 --- a/src/app/bootstrap.ts +++ b/src/app/bootstrap.ts @@ -6,6 +6,7 @@ import { createVueApp } from '@/app/createApp' import { setupRouterGuards } from '@/app/setupRouter' import { useSettingsStore } from '@/store/settingsStore' import { initPriceRealtimeChannel } from '@/utils/priceSocket' +import { initMobileScrollRestoration } from '@/layouts/composables/useMobileScrollReset' type BootWindow = Window & { __BOOT_TIMEOUT__?: number @@ -74,6 +75,7 @@ export async function bootstrapApp() { await withTimeout(settingsStore.fetchSettings(), 45000, 'Settings loading timeout') initPriceRealtimeChannel((key) => settingsStore.getSettingByKey(key)) + initMobileScrollRestoration() setupRouterGuards() app.mount('#app') diff --git a/src/app/setupRouter.ts b/src/app/setupRouter.ts index d72ea4d..7c409b0 100644 --- a/src/app/setupRouter.ts +++ b/src/app/setupRouter.ts @@ -12,6 +12,7 @@ import { setFingerTimeOut, setHasFingerPrint } from '@/app/authBoot' +import { recoverMobileViewportAfterAuth } from '@/layouts/composables/useMobileScrollReset' export function setupRouterGuards() { const userStore = useUserStore() @@ -106,5 +107,12 @@ export function setupRouterGuards() { } }) + router.afterEach((to, from) => { + if (document.documentElement.classList.contains('app-layout--desktop')) return + if (from.name === 'login') { + recoverMobileViewportAfterAuth() + } + }) + watch(currentRoute, async () => (userStore.userInfo ? '' : await start())) } diff --git a/src/assets/css/main.less b/src/assets/css/main.less index e3621cd..fd048d3 100644 --- a/src/assets/css/main.less +++ b/src/assets/css/main.less @@ -2,11 +2,13 @@ @import (less) './fonts.less'; @import (less) '../../layouts/styles/desktop-shell.less'; @import (less) '../../layouts/styles/mobile-frame.less'; +@import (less) '../../layouts/styles/mobile-page-common.less'; @import (less) '../../layouts/desktop/styles/desktop-page-common.less'; @import (less) '../../views/base/fund/desktop/styles/desktop-fund.less'; @import (less) '../../views/base/transactions/desktop/styles/desktop-transactions.less'; @import (less) '../../views/base/wallet/desktop/styles/desktop-wallet.less'; @import (less) '../../views/base/user/desktop/styles/desktop-user.less'; +@import (less) '../../views/base/priceTrigger/desktop/styles/desktop-price-trigger.less'; @import (less) '../../views/base/home/desktop/styles/desktop-home.less'; @import (less) '../../layouts/desktop/styles/desktop-inner-pages.less'; @@ -59,6 +61,21 @@ body { :root { color-scheme: only light; + --app-safe-top: env(safe-area-inset-top, 0px); + --app-safe-bottom: env(safe-area-inset-bottom, 0px); + --app-credit-bar-inner: 0px; + --app-home-header-offset: calc(4.25rem + var(--app-safe-top)); + --app-credit-footer-offset: var(--app-safe-bottom); + --app-nav-height: 80px; + --app-bottom-chrome-height: calc(var(--app-nav-height) + var(--app-safe-bottom)); + --app-add-card-height: 88px; + --wallet-card-dock-overlap: 52px; + --app-z-nav: 5000; + --app-z-wallet-dock: 4490; + --app-z-wallet-add-bar: 4510; + --app-z-action-sheet: 5200; + --app-z-dialog: 5300; + --app-z-modal: 5500; } .ant-form-item { @@ -83,13 +100,13 @@ body { opacity: 0; } -/*.van-action-sheet{ - z-index: 2001 !important; +.van-action-sheet{ + z-index: 5001 !important; } -.van-overlay{ - z-index: 2000 !important; -}*/ +//.van-overlay{ +// z-index: 2000 !important; +//} .color-transition { transition: color 0.3s ease; diff --git a/src/layouts/components/MobilePageHeader.vue b/src/layouts/components/MobilePageHeader.vue new file mode 100644 index 0000000..fbc9c6e --- /dev/null +++ b/src/layouts/components/MobilePageHeader.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/layouts/composables/useMobileScrollReset.ts b/src/layouts/composables/useMobileScrollReset.ts new file mode 100644 index 0000000..aed17b6 --- /dev/null +++ b/src/layouts/composables/useMobileScrollReset.ts @@ -0,0 +1,125 @@ +import { nextTick } from 'vue' +import router from '@/router' + +let recoveryTimers: ReturnType[] = [] + +function clearRecoveryTimers() { + recoveryTimers.forEach(clearTimeout) + recoveryTimers = [] +} + +function blurFocusedField() { + const active = document.activeElement + if (active instanceof HTMLElement && active !== document.body) { + active.blur() + } +} + +function clearBodyScrollLock() { + document.body.classList.remove('van-overflow-hidden') + document.body.style.removeProperty('position') + document.body.style.removeProperty('top') + document.body.style.removeProperty('left') + document.body.style.removeProperty('right') + document.body.style.removeProperty('width') + document.body.style.removeProperty('height') + document.body.style.removeProperty('min-height') + document.body.style.removeProperty('overflow') + document.body.style.removeProperty('transform') + document.documentElement.style.removeProperty('overflow') + document.documentElement.style.removeProperty('height') + document.documentElement.style.removeProperty('transform') + + const appRoot = document.getElementById('app') + if (appRoot) { + appRoot.style.removeProperty('position') + appRoot.style.removeProperty('top') + appRoot.style.removeProperty('height') + appRoot.style.removeProperty('min-height') + appRoot.style.removeProperty('overflow') + appRoot.style.removeProperty('transform') + } +} + +function resetAllScrollPositions() { + window.scrollTo(0, 0) + document.documentElement.scrollTop = 0 + document.body.scrollTop = 0 + + document + .querySelectorAll('.main-with-bottom-chrome-inner, .login-page, .login-page-root') + .forEach((node) => { + if (node instanceof HTMLElement) { + node.scrollTop = 0 + } + }) +} + +function kickViewportReflow() { + window.dispatchEvent(new Event('resize')) + + if (window.visualViewport) { + window.scrollTo(0, 0) + } +} + +function runViewportRecovery() { + clearBodyScrollLock() + resetAllScrollPositions() + kickViewportReflow() +} + +function scheduleViewportRecovery() { + clearRecoveryTimers() + runViewportRecovery() + + nextTick(() => { + requestAnimationFrame(() => { + runViewportRecovery() + + for (const delay of [50, 150, 300, 500]) { + recoveryTimers.push(window.setTimeout(runViewportRecovery, delay)) + } + }) + }) +} + +/** Reset scroll offsets after navigation — fixes iOS safe-area drift. */ +export function resetMobileAppScroll() { + runViewportRecovery() + + nextTick(() => { + requestAnimationFrame(runViewportRecovery) + }) +} + +export async function dismissMobileKeyboard() { + blurFocusedField() + await new Promise((resolve) => window.setTimeout(resolve, 120)) +} + +export async function prepareMobileViewportForAppShell() { + blurFocusedField() + resetAllScrollPositions() + await dismissMobileKeyboard() +} + +/** Full recovery after auth — runs after home shell is mounted. */ +export function recoverMobileViewportAfterAuth() { + scheduleViewportRecovery() +} + +export const resetMobileAppScrollAfterLogin = recoverMobileViewportAfterAuth + +export async function completeLoginNavigation() { + await prepareMobileViewportForAppShell() + await router.push({ name: 'buyProduct' }) + await router.isReady() + recoverMobileViewportAfterAuth() +} + +export function initMobileScrollRestoration() { + if (typeof history !== 'undefined' && 'scrollRestoration' in history) { + history.scrollRestoration = 'manual' + } +} diff --git a/src/layouts/config/navigation.ts b/src/layouts/config/navigation.ts index 1b86cb7..51bc417 100644 --- a/src/layouts/config/navigation.ts +++ b/src/layouts/config/navigation.ts @@ -18,6 +18,7 @@ export const GUEST_PRIMARY_NAV: AppNavItem[] = [ /** منوی FAB موبایل + بخش فرعی سایدبار دسکتاپ — کاربر لاگین‌شده */ export const AUTH_SECONDARY_NAV: AppNavItem[] = [ + { id: 'notifyTrigger', routeName: 'notifyTrigger', label: 'هشدار قیمت', icon: 'vuesax-linear:bill' }, { id: 'buyProduct', routeName: 'buyProduct', label: 'فروشگاه', icon: 'vuesax-linear:coin' }, { id: 'fund', routeName: 'fund', label: 'دارایی ها', icon: 'vuesax-linear:archive-1' }, { id: 'user', routeName: 'user', label: 'حساب کاربری', icon: 'vuesax-linear:user' }, diff --git a/src/layouts/mobile/MobileAppLayout.vue b/src/layouts/mobile/MobileAppLayout.vue index cc433e3..fff6c96 100644 --- a/src/layouts/mobile/MobileAppLayout.vue +++ b/src/layouts/mobile/MobileAppLayout.vue @@ -1,5 +1,5 @@ diff --git a/src/layouts/mobile/MobileBottomNav.vue b/src/layouts/mobile/MobileBottomNav.vue index 29ada63..76655c3 100644 --- a/src/layouts/mobile/MobileBottomNav.vue +++ b/src/layouts/mobile/MobileBottomNav.vue @@ -3,10 +3,12 @@
@@ -21,10 +23,12 @@ @@ -61,3 +65,19 @@ const primaryItems = computed(() => getPrimaryNavItems(isToken())) const leftTabs = computed(() => primaryItems.value.slice(0, 2)) const rightTabs = computed(() => primaryItems.value.slice(2)) + + diff --git a/src/layouts/mobile/MobileOverflowMenu.vue b/src/layouts/mobile/MobileOverflowMenu.vue index 14eda1a..c0951a0 100644 --- a/src/layouts/mobile/MobileOverflowMenu.vue +++ b/src/layouts/mobile/MobileOverflowMenu.vue @@ -2,12 +2,12 @@
-
+
دریافت طلای فیزیکی
+ +
+ +
هشدار قیمت
+
+
خرید محصول
- -
- -
لیست دریافتی ها
-
-
+ + + + + + + + + + + * { + height: 100%; + max-height: 100%; + overflow: hidden; + } + + .app-dock-add-bar, + .wallet-card-slider { + box-sizing: border-box; + } + @media screen and (max-width: 425.98px) { body { display: block !important; @@ -8,6 +46,7 @@ html:not(.app-layout--desktop) { #app, .desktopConfig, + .app-main, .main-with-bottom-chrome, .main-with-bottom-chrome-inner { width: 100% !important; @@ -25,6 +64,16 @@ html:not(.app-layout--desktop) { margin: 0 !important; transform: none !important; } + + .app-dock-add-bar, + .wallet-card-slider { + width: 100% !important; + max-width: 100vw !important; + left: 0 !important; + right: 0 !important; + margin: 0 !important; + transform: none !important; + } } @media screen and (min-width: 426px) and (max-width: 767.98px) { @@ -47,5 +96,15 @@ html:not(.app-layout--desktop) { margin: 0 !important; transform: translateX(-50%) !important; } + + .app-dock-add-bar, + .wallet-card-slider { + width: 426px !important; + max-width: 426px !important; + left: 50% !important; + right: auto !important; + margin: 0 !important; + transform: translateX(-50%) !important; + } } } diff --git a/src/layouts/styles/mobile-page-common.less b/src/layouts/styles/mobile-page-common.less new file mode 100644 index 0000000..6d315df --- /dev/null +++ b/src/layouts/styles/mobile-page-common.less @@ -0,0 +1,208 @@ +/* Shared mobile page chrome (inner pages — not home .app-header) */ + +: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))); +} + +/* 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))); +} + +.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))); +} + +.mpage-header { + display: flex; + align-items: center; + gap: 0.5rem; + top: 0; + box-sizing: border-box; + padding: 0.75rem; + padding-top: calc(0.75rem + var(--app-safe-top, env(safe-area-inset-top, 0px))); + background: #fff; + color: @primary-color; + font-size: 1rem; + font-weight: 700; + line-height: 1.5; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); + z-index: 1999; + + &__back { + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + padding: 0; + color: inherit; + cursor: pointer; + font-family: inherit; + } + + &__title { + flex: 1; + min-width: 0; + } + + &__trailing { + display: inline-flex; + align-items: center; + flex-shrink: 0; + } +} + +.mpage-subheader { + top: var(--mpage-subheader-offset); + z-index: 1999; + padding: 0.75rem; + background: #fff; + color: @primary-color; + font-weight: 700; +} + +.mpage-content { + padding-top: var(--mpage-header-offset); +} + +.mpage-content--with-subheader { + padding-top: var(--mpage-content-with-subheader); +} + +.mpage-scroll { + overflow-y: auto; + max-height: var(--mpage-scroll-max-height); +} + +.mobile-page-body--pt10 { + padding-top: calc(2.5rem + var(--app-safe-top, env(safe-area-inset-top, 0px))); +} + +.mobile-page-body--pt15 { + padding-top: calc(3.75rem + var(--app-safe-top, env(safe-area-inset-top, 0px))); +} + +.mobile-page-body--pt20 { + padding-top: calc(5rem + var(--app-safe-top, env(safe-area-inset-top, 0px))); +} + +/* Info pages (about, educate): fixed shell, scroll inside content box only */ +.mobile-info-page { + position: relative; + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + max-height: 100%; + overflow: hidden; +} + +.mobile-info-page__header { + z-index: 1999; +} + +/* Decorative page background — fixed to viewport, never scrolls on iOS */ +.mobile-page-bg, +.mobile-info-page__bg { + pointer-events: none; + position: fixed; + z-index: 0; + top: 0; + left: 0; + right: 0; + bottom: var(--app-bottom-chrome-height, 80px); + overflow: hidden; + max-width: 100vw; + box-sizing: border-box; + + img { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: auto; + } +} + +.mobile-page-bg--no-nav, +.mobile-info-page__bg { + bottom: var(--app-credit-footer-offset, 0px); +} + +.mobile-page-bg--cover { + background-size: cover; + background-position: center; + background-repeat: no-repeat; +} + +html:not(.app-layout--desktop) { + @media screen and (min-width: 426px) and (max-width: 767.98px) { + .mobile-page-bg, + .mobile-info-page__bg { + width: 426px; + max-width: 426px; + left: 50%; + right: auto; + transform: translateX(-50%); + } + } +} + +.mobile-info-page__main { + position: relative; + z-index: 1; + flex: 1 1 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding-top: calc(var(--mpage-header-offset) + 1rem); + padding-right: 2.5rem; + padding-left: 2.5rem; + padding-bottom: calc( + var(--mobile-info-support-block, 5rem) + var(--app-credit-footer-offset, 0px) + 1.25rem + ); + pointer-events: none; + + > * { + pointer-events: auto; + } +} + +.mobile-info-page__support { + bottom: var(--app-credit-footer-offset, 0px); + box-sizing: border-box; + padding: 1rem 2.5rem; + padding-bottom: calc(1rem + var(--app-safe-bottom, env(safe-area-inset-bottom, 0px))); + background: #fff; + box-shadow: 0 -2px 8px rgba(15, 23, 42, 0.06); + z-index: 1998; +} + +.mobile-content-box { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-height: 0; + overflow: hidden; + margin-bottom: 0.75rem; +} + +.mobile-content-box__scroll { + flex: 1 1 0; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: contain; +} diff --git a/src/router/index.ts b/src/router/index.ts index 1e792c4..a34ff80 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -85,22 +85,12 @@ const router = createRouter({ name: 'address', component: () => import('@/views/base/address/index.vue') }, - /* { - path: 'physicalReceive', - name: 'physicalReceive', - component: () => import('@/views/base/physicalReceive/index.vue'), - },*/ - // { - // path: 'list', - // name: 'list', - // component: () => import('@/views/base/physicalReceive/components/RequestList.vue'), - // }, { path: 'physicalReceive', name: 'physicalReceive', - redirect:{name: 'list'}, - // component: () => import('@/views/base/physicalReceive/index.vue'), - children:[ + redirect: { name: 'list' }, + component: () => import('@/views/base/physicalReceive/index.vue'), + children: [ { path: '', name: 'list', diff --git a/src/utils/useDraggableFloatingButton.ts b/src/utils/useDraggableFloatingButton.ts index af3feb7..6f28c07 100644 --- a/src/utils/useDraggableFloatingButton.ts +++ b/src/utils/useDraggableFloatingButton.ts @@ -1,24 +1,53 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' const DRAG_THRESHOLD = 6 +const POSITION_VERSION = 1 type FabPosition = { x: number; y: number } +type SavedFabOffset = { + version: number + dx: number + dy: number +} + type DraggableFloatingOptions = { width?: number height?: number - defaultBottomOffset?: number + defaultBottomGap?: number defaultRightOffset?: number } +function readBottomChromeHeight(): number { + const styles = getComputedStyle(document.documentElement) + const direct = parseFloat(styles.getPropertyValue('--app-bottom-chrome-height')) + if (!Number.isNaN(direct) && direct > 0) { + return direct + } + + const nav = parseFloat(styles.getPropertyValue('--app-nav-height')) || 80 + const creditOffset = parseFloat(styles.getPropertyValue('--app-credit-footer-offset')) + if (!Number.isNaN(creditOffset) && creditOffset > 0) { + return nav + creditOffset + } + + const probe = document.createElement('div') + probe.style.cssText = 'position:fixed;visibility:hidden;pointer-events:none;height:var(--app-bottom-chrome-height)' + document.body.appendChild(probe) + const measured = probe.getBoundingClientRect().height + document.body.removeChild(probe) + + return measured > 0 ? measured : nav +} + export function useDraggableFloatingButton( storageKey: string, options: DraggableFloatingOptions = {} ) { const width = options.width ?? 96 const height = options.height ?? 44 - const defaultBottomOffset = options.defaultBottomOffset ?? 92 - const defaultRightOffset = options.defaultRightOffset ?? 20 + const defaultBottomGap = options.defaultBottomGap ?? 12 + const defaultRightOffset = options.defaultRightOffset ?? 16 const pos = ref({ x: 0, y: 0 }) const dragging = ref(false) @@ -44,26 +73,40 @@ export function useDraggableFloatingButton( function getHorizontalOffset() { const vw = window.innerWidth const contentWidth = getViewportWidth() + return Math.max(0, (vw - contentWidth) / 2) } - function resetToDefault() { + function getDefaultPosition(): FabPosition { const vh = window.innerHeight const offsetX = getHorizontalOffset() const contentWidth = getViewportWidth() + const bottomChrome = readBottomChromeHeight() - pos.value = { + return { x: offsetX + contentWidth - width - defaultRightOffset, - y: vh - defaultBottomOffset - height + y: vh - bottomChrome - defaultBottomGap - height } } + function applySavedOffset(dx: number, dy: number) { + const base = getDefaultPosition() + pos.value = { + x: base.x + dx, + y: base.y + dy + } + clampToViewport() + } + function clampToViewport() { - const vw = window.innerWidth const vh = window.innerHeight + const offsetX = getHorizontalOffset() + const contentWidth = getViewportWidth() + const minX = offsetX + const maxX = offsetX + contentWidth - width pos.value = { - x: Math.max(0, Math.min(vw - width, pos.value.x)), + x: Math.max(minX, Math.min(maxX, pos.value.x)), y: Math.max(0, Math.min(vh - height, pos.value.y)) } } @@ -72,25 +115,56 @@ export function useDraggableFloatingButton( try { const raw = localStorage.getItem(storageKey) if (!raw) return false - const parsed = JSON.parse(raw) as FabPosition - if (typeof parsed?.x === 'number' && typeof parsed?.y === 'number') { - pos.value = parsed - clampToViewport() + + const parsed = JSON.parse(raw) as SavedFabOffset + if ( + parsed?.version === POSITION_VERSION + && typeof parsed.dx === 'number' + && typeof parsed.dy === 'number' + ) { + applySavedOffset(parsed.dx, parsed.dy) return true } } catch { // ignore } + return false } function savePosition() { - localStorage.setItem(storageKey, JSON.stringify(pos.value)) + const base = getDefaultPosition() + const payload: SavedFabOffset = { + version: POSITION_VERSION, + dx: pos.value.x - base.x, + dy: pos.value.y - base.y + } + localStorage.setItem(storageKey, JSON.stringify(payload)) + } + + function resetToDefault() { + pos.value = getDefaultPosition() } function handleResize() { - clampToViewport() - savePosition() + try { + const raw = localStorage.getItem(storageKey) + if (raw) { + const parsed = JSON.parse(raw) as SavedFabOffset + if ( + parsed?.version === POSITION_VERSION + && typeof parsed.dx === 'number' + && typeof parsed.dy === 'number' + ) { + applySavedOffset(parsed.dx, parsed.dy) + return + } + } + } catch { + // ignore + } + + resetToDefault() } function handlePointerDown(event: PointerEvent) { diff --git a/src/views/base/about/mobile/MobileAboutView.vue b/src/views/base/about/mobile/MobileAboutView.vue index c747a62..b386807 100644 --- a/src/views/base/about/mobile/MobileAboutView.vue +++ b/src/views/base/about/mobile/MobileAboutView.vue @@ -1,12 +1,7 @@ diff --git a/src/views/base/cart/mobile/MobileCartView.vue b/src/views/base/cart/mobile/MobileCartView.vue index 761d043..d12825e 100644 --- a/src/views/base/cart/mobile/MobileCartView.vue +++ b/src/views/base/cart/mobile/MobileCartView.vue @@ -1,8 +1,8 @@