Merge origin/modular into feature/responsive-layout-refactor.

Keep responsive layout refactor while taking modular store-hours, transaction price, and garoosi login fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-15 11:22:29 +03:30
co-authored by Cursor
14 changed files with 362 additions and 38 deletions
+4 -3
View File
@@ -198,10 +198,11 @@ const {
handlePointerMove: onSupportPointerMove,
handlePointerUp: supportPointerUp,
handlePointerCancel: onSupportPointerCancel
} = useDraggableFloatingButton('khaneghi_support_button_position', {
} = useDraggableFloatingButton('khaneghi_support_button_position_v2', {
width: 118,
height: 42,
defaultBottomOffset: 120
defaultBottomGap: 12,
defaultRightOffset: 16
})
function openSupportLink() {
@@ -329,7 +330,7 @@ function update() {
#support-button {
position: fixed;
z-index: 9999;
z-index: 5100;
cursor: grab;
user-select: none;
border-radius: 9999px;
+1 -1
View File
@@ -1,6 +1,6 @@
import { getStoreCategories } from '@/api/categories'
/** تب‌های فیلتر محصول — فقط از API دسته‌بندی (هماهنگ با پنل ادمین) */
/** تب‌های فیلتر محصول — دسته‌های فعال که محصول دارند (خودکار از API) */
export async function loadProductCategoryTabNames(): Promise<string[]> {
const categories = await getStoreCategories()
return categories.map((item) => item.name)
+107
View File
@@ -0,0 +1,107 @@
import { useSettingsStore } from '@/store/settingsStore'
import { lastPrice } from '@/utils/price'
export interface StoreTradingHours {
start: string
end: string
closePriceEnabled: boolean
}
export interface EffectiveTradingHours {
start: string
end: string
enforced: boolean
}
let productTradingHours: { start: string; end: string } | null = null
function parseTimeToSeconds(time: string): number {
const parts = time.trim().split(':').map(Number)
const [hours = 0, minutes = 0, seconds = 0] = parts
return hours * 3600 + minutes * 60 + seconds
}
export function formatTradingTimeDisplay(time: string): string {
const parts = time.trim().split(':')
if (parts.length >= 3) {
return `${parts[0]}:${parts[1]}:${parts[2]}`
}
if (parts.length >= 2) {
return `${parts[0]}:${parts[1]}:00`
}
return time
}
export function setProductTradingHours(start?: string | null, end?: string | null) {
if (start && end) {
productTradingHours = { start, end }
}
}
export function getStoreTradingHours(): StoreTradingHours {
const settings = useSettingsStore()
const closeRaw = settings.getSettingByKey('close_price')
const closePriceEnabled =
closeRaw === true ||
closeRaw === 1 ||
String(closeRaw ?? 'false').trim().toLowerCase() === 'true'
return {
start: String(settings.getSettingByKey('startDay') ?? '00:00'),
end: String(settings.getSettingByKey('endDay') ?? '23:59'),
closePriceEnabled,
}
}
export function getEffectiveTradingHours(): EffectiveTradingHours {
const settings = getStoreTradingHours()
if (settings.closePriceEnabled) {
return {
start: settings.start,
end: settings.end,
enforced: true,
}
}
if (productTradingHours?.start && productTradingHours?.end) {
return {
start: productTradingHours.start,
end: productTradingHours.end,
enforced: true,
}
}
return {
start: settings.start,
end: settings.end,
enforced: false,
}
}
export function isStoreClosedBySchedule(now = new Date()): boolean {
const { start, end, enforced } = getEffectiveTradingHours()
if (!enforced) {
return false
}
const currentSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds()
const openSeconds = parseTimeToSeconds(start)
const closeSeconds = parseTimeToSeconds(end)
return currentSeconds < openSeconds || currentSeconds > closeSeconds
}
export function isStoreClosed(): boolean {
if (lastPrice.value?.closed === true) {
return true
}
return isStoreClosedBySchedule()
}
export function getStoreClosedMessage(): string {
const { start, end } = getEffectiveTradingHours()
const open = formatTradingTimeDisplay(start)
const close = formatTradingTimeDisplay(end)
return `فروشگاه در حال حاضر بسته است. ساعت کاری از ${open} تا ${close} می‌باشد.`
}
+5 -6
View File
@@ -17,9 +17,6 @@ export interface SupportMessengerSettings {
export const supportMessengerSettings = ref<SupportMessengerSettings>({})
export const DEFAULT_SUPPORT_MESSENGER_TEXT =
'جهت شارژ حساب کاربری خود، کلمه "شماره حساب" را برای پشتیبانی ارسال نمایید.'
function getSupportSettingValue(
settings: Array<{ key?: string; is_active?: boolean | number; value?: unknown }>,
key: string
@@ -39,9 +36,11 @@ export function buildSupportMessengerUrl(messenger?: string, messengerId?: strin
if (/^https?:\/\//i.test(id)) return id
const type = (messenger ?? '').trim().toLowerCase() || 'bale'
const type = (messenger ?? '').trim().toLowerCase()
const username = id.replace(/^@/, '')
if (!type) return ''
switch (type) {
case 'bale':
if (id.includes('ble.ir')) {
@@ -59,7 +58,7 @@ export function buildSupportMessengerUrl(messenger?: string, messengerId?: strin
case 'soroush':
return `https://splus.ir/splus/${username}`
default:
return `https://ble.ir/${username}`
return ''
}
}
@@ -69,7 +68,7 @@ export async function loadSupportMessengerFromSettings() {
if (!settings || !Array.isArray(settings)) return
supportMessengerSettings.value = {
messenger: getSupportSettingValue(settings, 'support_messenger') || 'bale',
messenger: getSupportSettingValue(settings, 'support_messenger'),
messengerId: getSupportSettingValue(settings, 'support_messenger_id'),
messengerText: getSupportSettingValue(settings, 'support_messenger_text'),
}
+90 -15
View File
@@ -1,24 +1,54 @@
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
/** فاصله از پایینِ نوار منو + montzar (px) — مثل تصویر */
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 + 28
}
export function useDraggableFloatingButton(
storageKey: string,
options: DraggableFloatingOptions = {}
) {
const width = options.width ?? 96
const height = options.height ?? 44
const defaultBottomOffset = options.defaultBottomOffset ?? 120
const defaultRightOffset = options.defaultRightOffset ?? 20
const defaultBottomGap = options.defaultBottomGap ?? 12
const defaultRightOffset = options.defaultRightOffset ?? 16
const pos = ref<FabPosition>({ x: 0, y: 0 })
const dragging = ref(false)
@@ -44,26 +74,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 +116,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) {
+55
View File
@@ -24,3 +24,58 @@ export function useTowDecimal(num: string | number): string | number {
export function useFormat(num: string | number, unit = '') {
return usePrice(Math.abs(+useTowDecimal(num)), true, unit)
}
type TransactionPriceSource = {
cash?: number | null
gold?: number | null
order?: {
price?: number | null
total_price?: number | null
amount?: number | null
} | null
}
export function getTransactionTradePrice(item: TransactionPriceSource): number {
const orderPrice = Number(item.order?.price)
if (Number.isFinite(orderPrice) && orderPrice > 0) {
return Math.round(orderPrice)
}
const cashAmount = Math.abs(Number(item.cash) || 0)
const goldAmount =
Math.abs(Number(item.gold) || 0) || Math.abs(Number(item.order?.amount) || 0)
if (goldAmount > 0 && cashAmount > 0) {
return Math.round(cashAmount / goldAmount)
}
const totalPrice = Number(item.order?.total_price)
if (Number.isFinite(totalPrice) && totalPrice > 0) {
return Math.round(totalPrice)
}
return 0
}
export function getTransactionGoldAmount(item: TransactionPriceSource): number {
const gold = Number(item.gold) || 0
if (Math.abs(gold) > 0) {
return gold
}
const orderAmount = Number(item.order?.amount) || 0
if (orderAmount > 0) {
const cash = Number(item.cash) || 0
if (cash < 0) return orderAmount
if (cash > 0) return -orderAmount
return orderAmount
}
return gold
}
export function formatGoldAmount(amount?: number | null): string {
const num = Number(amount)
if (!Number.isFinite(num)) return '0'
return String(Number(num.toFixed(3)))
}
+10
View File
@@ -239,6 +239,7 @@ import {useConfigStore} from "@/store/configStore";
import { calcFeeInGrams } from '@/utils/serviceFee'
import { formatCashWalletBalanceText, formatGoldWalletBalanceText } from '@/utils/productDealMethod'
import { getCashWalletBalance } from '@/utils/productAssets'
import { isStoreClosed, getStoreClosedMessage } from '@/utils/storeHours'
const infoVisible = ref(false)
const walletStore = useWalletStore()
@@ -325,6 +326,7 @@ const equivalentPrice = computed(() => usePriceWithoutType(feeInRial.value.toFix
const disableTransaction = computed(() =>
loading.value ||
isStoreClosed() ||
form.value?.goldValue === undefined ||
form.value?.goldValue === '' ||
form.value?.goldCash === '' ||
@@ -640,10 +642,18 @@ async function initPrice() {
}
function okConfirm() {
if (isStoreClosed()) {
message.warning(getStoreClosedMessage())
return
}
confirmAction.value = true
}
async function ok() {
if (isStoreClosed()) {
message.warning(getStoreClosedMessage())
return
}
try {
loading.value = true
infoVisible.value = true
+10
View File
@@ -296,6 +296,7 @@ import type { Product } from '@/models/products'
import { useFundUserStore } from '@/store/fundStore'
import {useConfigStore} from "@/store/configStore";
import { calcFeeInGrams, loadServiceFeeFromSettings } from '@/utils/serviceFee'
import { isStoreClosed, getStoreClosedMessage } from '@/utils/storeHours'
import { formatCashWalletBalanceText, getWeightUnit } from '@/utils/productDealMethod'
import { formatProductWalletBalanceText, getCashWalletBalance, getProductInventoryQuantity } from '@/utils/productAssets'
import { useWalletAssets } from '@/composables/useWalletAssets'
@@ -406,6 +407,7 @@ const priceUnavailable = computed(() => {
const disableTransaction = computed(() =>
loading.value ||
isStoreClosed() ||
form.value?.goldValue === undefined ||
form.value?.goldValue === '' ||
form.value?.goldCash === '' ||
@@ -695,6 +697,10 @@ async function initPrice() {
}
function okConfirm() {
if (isStoreClosed()) {
message.warning(getStoreClosedMessage())
return
}
if (cashValidationMessage.value) {
message.warning(cashValidationMessage.value)
return
@@ -703,6 +709,10 @@ function okConfirm() {
}
async function ok() {
if (isStoreClosed()) {
message.warning(getStoreClosedMessage())
return
}
try {
loading.value = true
infoVisible.value = true
@@ -297,6 +297,7 @@ import {
} from '@/utils/productOrderLimit'
import { useWalletAssets } from '@/composables/useWalletAssets'
import { formatProductWalletBalanceText, getCashWalletBalance, getProductInventoryQuantity } from '@/utils/productAssets'
import { isStoreClosed, getStoreClosedMessage } from '@/utils/storeHours'
const typeDeal = computed(() => getProductDealMethodOptions(props?.ProductItem?.group_type, isBuy.value))
@@ -438,6 +439,7 @@ const cashValidationMessage = computed(() =>
const disableTransaction = computed(() =>
loading.value ||
isStoreClosed() ||
form.value?.goldValue === undefined ||
form.value?.goldValue === '' ||
form.value?.goldCash === '' ||
@@ -795,11 +797,19 @@ async function initPrice() {
// })
function okConfirm() {
if (isStoreClosed()) {
message.warning(getStoreClosedMessage())
return
}
confirmAction.value = true
refInput.value?.validateFields()
}
async function ok() {
if (isStoreClosed()) {
message.warning(getStoreClosedMessage())
return
}
try {
loading.value = true
infoVisible.value = true
@@ -15,12 +15,12 @@
<p class="dtxn-row__title">{{ props.item.title }}</p>
<div class="dtxn-row__meta">
<span>{{ toJalaliOutOfUTC(props.item.expire_at) }}</span>
<span :class="props.item.gold >= 0 ? 'text-green-600' : 'text-red-600'">
{{ props.item.gold }} {{ props.item.unit }}
<span :class="goldAmount >= 0 ? 'text-green-600' : 'text-red-600'">
{{ formatGoldAmount(goldAmount) }} {{ props.item.unit }}
</span>
<span v-if="isGold">
قیمت معامله:
{{ usePriceWithoutType(parseInt((Math.abs(props.item.cash) / Math.abs(props.item.gold)).toFixed(0))) }}
{{ usePriceWithoutType(tradePrice) }}
ریال
</span>
</div>
@@ -56,10 +56,10 @@
</div>
<div class="flex justify-between mt-2">
<div
:class="props.item.gold >= 0 ? 'text-green-600' : 'text-red-600'"
:class="goldAmount >= 0 ? 'text-green-600' : 'text-red-600'"
class="text-gray-500 text-xs number-ltr"
>
{{ props.item.gold }}
{{ formatGoldAmount(goldAmount) }}
<div>{{ props.item.unit }}</div>
</div>
<div class="text-gray-500 flex gap-1 text-xs items-center number-ltr">
@@ -75,7 +75,7 @@
<div class="text-gray-500">قیمت معامله:</div>
<div class="text-gray-500 flex gap-1 text-xs items-center">
<div class="text-base">
{{ usePriceWithoutType(parseInt((Math.abs(props.item.cash) / Math.abs(props.item.gold)).toFixed(0))) }}
{{ usePriceWithoutType(tradePrice) }}
</div>
ریال
</div>
@@ -114,7 +114,12 @@
<script lang="ts" setup>
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { usePriceWithoutType } from '@/utils/usePrice'
import {
formatGoldAmount,
getTransactionGoldAmount,
getTransactionTradePrice,
usePriceWithoutType,
} from '@/utils/usePrice'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import { computed, ref } from 'vue'
@@ -133,6 +138,9 @@ const props = withDefaults(
{ variant: 'mobile' },
)
const goldAmount = computed(() => getTransactionGoldAmount(props.item))
const tradePrice = computed(() => getTransactionTradePrice(props.item))
const isUp = computed(
() =>
props.item.type === 3
+2 -2
View File
@@ -40,7 +40,7 @@
<div v-if="isGold" class="flex w-full justify-between mt-2">
<div class="text-gray-500">قیمت معامله:</div>
<div class="text-gray-500 flex gap-1 text-xs items-center">
<div class="text-base">{{ usePriceWithoutType((Math.abs(item.cash) / Math.abs(item.gold)).toFixed(0)) }}</div>
<div class="text-base">{{ usePriceWithoutType(getTransactionTradePrice(item)) }}</div>
ریال
</div>
</div>
@@ -138,7 +138,7 @@
<script lang="ts" setup>
import Icon from "@/components/BasicPacks/BasicIcon.vue";
import {usePriceWithoutType} from "../../../../utils/usePrice";
import {getTransactionTradePrice, usePriceWithoutType} from "../../../../utils/usePrice";
import BasicActionSheet from "@/components/BasicPacks/BasicActionSheet.vue";
import {computed, ref} from "vue";
import BasicButton from "@/components/BasicPacks/BasicButton.vue";
+6 -1
View File
@@ -22,7 +22,12 @@
v-else-if="variant !== 'desktop'"
:src="getProjectLoginLogo(config.config?.avatar)"
alt=""
class="z-100 rounded-full aspect-square self-center w-36"
:class="[
'z-100 self-center w-36',
config.config?.name === 'garoosi'
? 'h-auto object-contain'
: 'rounded-full aspect-square',
]"
style="margin-bottom: 60px"
>
+6 -1
View File
@@ -22,7 +22,12 @@
v-else-if="variant !== 'desktop'"
:src="getProjectLoginLogo(config.config?.avatar)"
alt=""
class="z-100 rounded-full aspect-square self-center w-36"
:class="[
'z-100 self-center w-36',
config.config?.name === 'garoosi'
? 'h-auto object-contain mb-[60px]'
: 'rounded-full aspect-square',
]"
>
<div v-if="variant !== 'desktop'" class="font-bold text-center text-xl">ثبت نام</div>
<a-form
+41 -2
View File
@@ -20,7 +20,7 @@
</DesktopAuthShell>
<!-- Mobile: original layout -->
<div v-show="!isDesktop" class="login-page flex items-center justify-center">
<div v-show="!isDesktop" class="login-page flex flex-col items-center">
<template v-if="!isBoursecu">
<div class="fixed top-0 right-0 left-0 h-1/3 w-screen flex">
<img :src="img_topGradiant" alt="" class="absolute z-10 w-screen h-full object-cover" />
@@ -31,18 +31,36 @@
<img :src="img_pattern" alt="" class="absolute w-screen h-full object-none" />
</div>
</template>
<div class="z-1000 w-full p-5">
<div class="login-page__content z-1000 w-full p-5">
<Login v-if="!loginPage.enterType" @register="loginPage.setEnterType" />
<Register v-else @register="loginPage.setEnterType" />
</div>
<div v-if="isGaroosi" class="login-page__enamad z-1000">
<a
referrerpolicy="origin"
target="_blank"
href="https://trustseal.enamad.ir/?id=710578&Code=DdltBYWJgvc7LJs9eM46SEliXRRU0kSW"
>
<img
referrerpolicy="origin"
src="https://trustseal.enamad.ir/logo.aspx?id=710578&Code=DdltBYWJgvc7LJs9eM46SEliXRRU0kSW"
alt="enamad"
style="cursor: pointer"
code="DdltBYWJgvc7LJs9eM46SEliXRRU0kSW"
>
</a>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useLoginPage } from './composables/useLoginPage'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { isBoursecuProject } from '@/utils/config'
import { useConfigStore } from '@/store/configStore'
import Login from './components/Login.vue'
import Register from './components/Register.vue'
import DesktopAuthShell from './desktop/components/DesktopAuthShell.vue'
@@ -53,6 +71,8 @@ import img_pattern from './assets/img/pattern.svg'
const isDesktop = useDesktopBreakpoint()
const isBoursecu = isBoursecuProject()
const loginPage = useLoginPage()
const config = useConfigStore()
const isGaroosi = computed(() => config.config?.name === 'garoosi')
</script>
<style scoped>
@@ -64,4 +84,23 @@ const loginPage = useLoginPage()
.login-page {
min-height: calc(100vh - var(--app-credit-footer-offset, 28px));
}
.login-page__content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.login-page__enamad {
display: flex;
justify-content: center;
padding: 0 20px 16px;
margin-top: auto;
}
.login-page__enamad img {
max-width: 96px;
height: auto;
}
</style>