fix some bugs

This commit is contained in:
2026-08-06 17:12:57 +03:30
parent 7eef5e64b3
commit 72ff454c90
103 changed files with 6506 additions and 1573 deletions
+13 -1
View File
@@ -14,14 +14,20 @@ const {
filteredOrdersMobile,
filteredOrdersDesktop,
paginatedOrders,
paginatedOrdersMobile,
totalPages,
mobileTotalPages,
paginationRange,
mobilePaginationRange,
visiblePages,
mobileVisiblePages,
currentPage,
mobilePage,
companySlug,
iconVariant,
goToOrder,
setPage,
setMobilePage,
setDesktopFilter,
viewStatFilter,
} = useOrderList()
@@ -47,12 +53,18 @@ const desktopFilterTabs: { id: DesktopOrderFilterId; label: string }[] = [
class="mobile-only"
:tab="tab"
:tabs="mobileTabs"
:orders="filteredOrdersMobile"
:orders="paginatedOrdersMobile"
:total-count="filteredOrdersMobile.length"
:current-page="mobilePage"
:total-pages="mobileTotalPages"
:pagination-range="mobilePaginationRange"
:visible-pages="mobileVisiblePages"
:company-slug="companySlug"
:icon-variant="iconVariant"
@update:tab="tab = $event"
@select="goToOrder"
@back="router.push('/')"
@set-page="setMobilePage"
/>
<DesktopOrdersPage
+48 -47
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import BaseInput from '@/components/ui/BaseInput.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import { normalizePhone } from '@/utils/format'
import { normalizePhone, toPersianDigits } from '@/utils/format'
import { validatePhone } from '@/utils/validation'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
@@ -20,24 +21,42 @@ const phone = ref('')
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
const displayPhone = computed(() => (phone.value ? toPersianDigits(phone.value) : ''))
function phoneErrorMessage(value: string): string {
if (!value) return 'شماره موبایل را وارد کنید.'
if (!validatePhone(value)) return 'شماره موبایل معتبر نیست. (مثال: ۰۹۱۲۱۲۳۴۵۶۷)'
return ''
}
function clearPhoneError() {
fieldErrors.value = clearFieldError(fieldErrors.value, 'phone')
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
}
function onPhoneInput(value: string) {
phone.value = normalizePhone(value)
clearPhoneError()
}
function onPhoneBlur() {
if (!phone.value) return
const message = phoneErrorMessage(phone.value)
if (message) {
fieldErrors.value = { ...fieldErrors.value, phone: message }
}
}
async function submit() {
fieldErrors.value = {}
formError.value = ''
const normalized = normalizePhone(phone.value)
const errors: FieldErrors = {}
phone.value = normalized
if (!/^09\d{9}$/.test(normalized)) {
errors.phone = 'شماره موبایل معتبر نیست.'
}
if (hasFieldErrors(errors)) {
fieldErrors.value = errors
const message = phoneErrorMessage(normalized)
if (message) {
fieldErrors.value = { phone: message }
formError.value = FORM_VALIDATION_SUMMARY
return
}
@@ -52,59 +71,41 @@ async function submit() {
</script>
<template>
<div class="page auth-page">
<div class="page__scroll auth-page__scroll">
<div class="page page-shell auth-page">
<div class="page__scroll flex flex-col justify-center py-6">
<div class="auth-header">
<div class="logo">🧵</div>
<div class="text-5xl mb-3">🧵</div>
<h1 class="page-title">ورود به سامانه</h1>
<p class="page-subtitle">سامانه سفارش عمده پارچه</p>
</div>
<div class="card">
<form class="card" @submit.prevent="submit">
<FormErrorBanner :message="formError" />
<BaseInput
v-model="phone"
:model-value="displayPhone"
label="شماره موبایل"
type="tel"
inputmode="numeric"
autocomplete="tel"
dir="ltr"
align="center"
maxlength="11"
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
:error="fieldErrors.phone"
hint="کد تأیید به این شماره ارسال می‌شود"
@update:model-value="clearPhoneError"
@update:model-value="onPhoneInput"
@blur="onPhoneBlur"
/>
<BaseButton block :loading="auth.loading" @click="submit">دریافت کد تأیید</BaseButton>
</div>
<BaseButton block type="submit" :loading="auth.loading">دریافت کد تأیید</BaseButton>
</form>
<p class="auth-footer text-center text-sm">
حساب کاربری ندارید؟
<router-link to="/register">ثبت‌نام</router-link>
</p>
<div class="mt-6 w-full max-w-[var(--form-max-width)]">
<p class="m-0 mb-2.5 text-center text-sm text-muted">حساب کاربری ندارید؟</p>
<RouterLink v-slot="{ navigate }" to="/register" custom>
<BaseButton block variant="outline" type="button" @click="navigate">ثبت‌نام</BaseButton>
</RouterLink>
</div>
</div>
</div>
</template>
<style scoped>
.auth-page {
overflow: hidden;
}
.auth-page__scroll {
padding-top: 36px;
padding-bottom: 24px;
}
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.logo {
font-size: 3rem;
margin-bottom: 12px;
}
.auth-footer {
margin-top: 24px;
color: var(--color-text-muted);
}
</style>
+237 -34
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useIntervalFn } from '@vueuse/core'
import { useAuthStore } from '@/stores/auth'
import BaseInput from '@/components/ui/BaseInput.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import { toPersianDigits, extractNumericString } from '@/utils/format'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
@@ -12,27 +13,155 @@ import {
type FieldErrors,
} from '@/utils/formErrors'
const OTP_LENGTH = 6
const RESEND_SECONDS = 60
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const phone = computed(() => (route.query.phone as string) ?? '')
const code = ref('')
const digits = ref<string[]>(Array.from({ length: OTP_LENGTH }, () => ''))
const inputRefs = ref<(HTMLInputElement | null)[]>([])
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
const secondsLeft = ref(RESEND_SECONDS)
const code = computed(() => digits.value.join(''))
const canResend = computed(() => secondsLeft.value <= 0 && !auth.loading)
const timerLabel = computed(() => {
const minutes = Math.floor(secondsLeft.value / 60)
const seconds = secondsLeft.value % 60
const formatted = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
return toPersianDigits(formatted)
})
const { pause, resume } = useIntervalFn(
() => {
if (secondsLeft.value <= 1) {
secondsLeft.value = 0
pause()
return
}
secondsLeft.value -= 1
},
1000,
{ immediate: false },
)
function clearCodeError() {
fieldErrors.value = clearFieldError(fieldErrors.value, 'code')
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
}
function setInputRef(el: unknown, index: number) {
inputRefs.value[index] = el as HTMLInputElement | null
}
function focusInput(index: number) {
const clamped = Math.max(0, Math.min(OTP_LENGTH - 1, index))
void nextTick(() => inputRefs.value[clamped]?.focus())
}
function startTimer() {
secondsLeft.value = RESEND_SECONDS
resume()
}
function fillDigits(value: string, startIndex = 0) {
const numeric = extractNumericString(value).slice(0, OTP_LENGTH - startIndex)
if (!numeric) return startIndex
const next = [...digits.value]
let cursor = startIndex
for (const digit of numeric) {
if (cursor >= OTP_LENGTH) break
next[cursor] = digit
cursor += 1
}
digits.value = next
clearCodeError()
return Math.min(cursor, OTP_LENGTH - 1)
}
function tryAutoSubmit() {
if (code.value.length === OTP_LENGTH && !auth.loading) {
void submit()
}
}
function onDigitInput(index: number, event: Event) {
const target = event.target as HTMLInputElement
const numeric = extractNumericString(target.value)
if (numeric.length > 1) {
const focusAt = fillDigits(numeric, index)
target.value = digits.value[index] ?? ''
focusInput(focusAt)
tryAutoSubmit()
return
}
const digit = numeric.slice(-1)
digits.value[index] = digit
target.value = digit
clearCodeError()
if (digit && index < OTP_LENGTH - 1) {
focusInput(index + 1)
} else if (digit) {
tryAutoSubmit()
}
}
function onDigitKeydown(index: number, event: KeyboardEvent) {
if (event.key === 'Backspace') {
if (digits.value[index]) {
digits.value[index] = ''
clearCodeError()
return
}
if (index > 0) {
digits.value[index - 1] = ''
clearCodeError()
focusInput(index - 1)
}
return
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
focusInput(index - 1)
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
focusInput(index + 1)
}
}
function onDigitPaste(index: number, event: ClipboardEvent) {
event.preventDefault()
const pasted = event.clipboardData?.getData('text') ?? ''
const focusAt = fillDigits(pasted, index)
focusInput(focusAt)
tryAutoSubmit()
}
function displayDigit(value: string) {
return value ? toPersianDigits(value) : ''
}
async function submit() {
if (auth.loading) return
fieldErrors.value = {}
formError.value = ''
const errors: FieldErrors = {}
if (code.value.length < 4) {
errors.code = 'کد تأیید را وارد کنید.'
if (code.value.length < OTP_LENGTH) {
errors.code = 'کد تأیید را کامل وارد کنید.'
}
if (hasFieldErrors(errors)) {
@@ -46,55 +175,129 @@ async function submit() {
router.replace('/')
} else {
formError.value = result.message ?? 'کد تأیید نامعتبر است.'
digits.value = Array.from({ length: OTP_LENGTH }, () => '')
focusInput(0)
}
}
async function resend() {
if (!canResend.value) return
const isRegister = route.query.mode === 'register'
await auth.requestOtp(phone.value, { isRegister })
const result = await auth.requestOtp(phone.value, { isRegister })
if (result.success) {
digits.value = Array.from({ length: OTP_LENGTH }, () => '')
formError.value = ''
fieldErrors.value = {}
startTimer()
focusInput(0)
} else {
formError.value = result.message ?? 'خطا در ارسال مجدد کد.'
}
}
function changePhone() {
const isRegister = route.query.mode === 'register'
router.replace({ name: isRegister ? 'register' : 'login' })
}
onMounted(() => {
startTimer()
focusInput(0)
})
</script>
<template>
<div class="page auth-page">
<div class="page__scroll auth-page__scroll">
<div class="page page-shell auth-page">
<div class="page__scroll flex flex-col justify-center py-6">
<div class="auth-header">
<h1 class="page-title">تأیید شماره موبایل</h1>
<p class="page-subtitle">کد ارسال‌شده به {{ phone }} را وارد کنید</p>
<p class="page-subtitle">کد ارسال‌شده به {{ toPersianDigits(phone) }} را وارد کنید</p>
</div>
<div class="card">
<form class="card" @submit.prevent="submit">
<FormErrorBanner :message="formError" />
<BaseInput
v-model="code"
label="کد تأیید"
type="text"
placeholder="۱۲۳۴۵۶"
maxlength="6"
:error="fieldErrors.code"
hint="در نسخه آزمایشی: 123456"
@update:model-value="clearCodeError"
/>
<BaseButton block :loading="auth.loading" @click="submit">تأیید و ورود</BaseButton>
<BaseButton block variant="ghost" class="mt-2" @click="resend">ارسال مجدد کد</BaseButton>
</div>
<div class="form-group">
<label class="form-label" for="otp-0">کد تأیید</label>
<div class="flex justify-center gap-2.5 ltr" dir="ltr" role="group" aria-label="کد تأیید">
<input
v-for="(digit, index) in digits"
:id="`otp-${index}`"
:key="index"
:ref="(el) => setInputRef(el, index)"
class="otp-box"
:class="{ 'otp-box--error': fieldErrors.code }"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="1"
:value="displayDigit(digit)"
:aria-label="`رقم ${toPersianDigits(String(index + 1))}`"
@input="onDigitInput(index, $event)"
@keydown="onDigitKeydown(index, $event)"
@paste="onDigitPaste(index, $event)"
/>
</div>
<p v-if="fieldErrors.code" class="input-error-text">{{ fieldErrors.code }}</p>
</div>
<BaseButton block type="submit" :loading="auth.loading">تأیید و ورود</BaseButton>
<BaseButton
block
variant="outline"
type="button"
class="mt-2"
:disabled="!canResend"
@click="resend"
>
<template v-if="canResend">ارسال مجدد کد</template>
<template v-else>ارسال مجدد کد ({{ timerLabel }})</template>
</BaseButton>
<BaseButton block variant="ghost" type="button" class="mt-2" @click="changePhone">
تغییر شماره موبایل
</BaseButton>
</form>
</div>
</div>
</template>
<style scoped>
.auth-page {
overflow: hidden;
}
.auth-page__scroll {
padding-top: 36px;
padding-bottom: 24px;
}
.auth-header {
.otp-box {
width: 48px;
height: 52px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font-size: 1.25rem;
font-weight: 600;
text-align: center;
margin-bottom: 32px;
caret-color: var(--color-primary);
transition: border-color 0.15s;
}
.otp-box:focus {
outline: none;
border-color: var(--color-primary);
}
.otp-box--error {
border-color: var(--color-danger);
}
.input-error-text {
color: var(--color-danger);
font-size: 0.8125rem;
margin: 4px 0 0;
}
@media (max-width: 360px) {
.otp-box {
width: 42px;
height: 48px;
font-size: 1.125rem;
}
}
</style>
+11 -34
View File
@@ -6,6 +6,7 @@ import BaseInput from '@/components/ui/BaseInput.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import { normalizePhone } from '@/utils/format'
import { validatePhone } from '@/utils/validation'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
@@ -40,7 +41,7 @@ async function submit() {
if (!lastName.value.trim()) {
errors.lastName = 'نام خانوادگی الزامی است.'
}
if (!/^09\d{9}$/.test(normalized)) {
if (!validatePhone(normalized)) {
errors.phone = 'شماره موبایل معتبر نیست.'
}
@@ -64,10 +65,10 @@ async function submit() {
</script>
<template>
<div class="page auth-page">
<div class="page__scroll auth-page__scroll">
<div class="page page-shell auth-page">
<div class="page__scroll flex flex-col justify-center py-6">
<div class="auth-header">
<div class="logo">🧵</div>
<div class="text-5xl mb-3">🧵</div>
<h1 class="page-title">ثبت‌نام</h1>
<p class="page-subtitle">ایجاد حساب کاربری جدید</p>
</div>
@@ -100,36 +101,12 @@ async function submit() {
<BaseButton block :loading="auth.loading" @click="submit">دریافت کد تأیید</BaseButton>
</div>
<p class="auth-footer text-center text-sm">
قبلاً ثبت‌نام کرده‌اید؟
<router-link to="/login">ورود</router-link>
</p>
<div class="mt-6 w-full max-w-[var(--form-max-width)]">
<p class="m-0 mb-2.5 text-center text-sm text-muted">قبلاً ثبت‌نام کرده‌اید؟</p>
<RouterLink v-slot="{ navigate }" to="/login" custom>
<BaseButton block variant="outline" type="button" @click="navigate">ورود</BaseButton>
</RouterLink>
</div>
</div>
</div>
</template>
<style scoped>
.auth-page {
overflow: hidden;
}
.auth-page__scroll {
padding-top: 36px;
padding-bottom: 24px;
}
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.logo {
font-size: 3rem;
margin-bottom: 12px;
}
.auth-footer {
margin-top: 24px;
color: var(--color-text-muted);
}
</style>
+45 -35
View File
@@ -30,49 +30,59 @@ function goToOrdering() {
</script>
<template>
<MobileCartPage
class="mobile-only"
:items="items"
:company-name="companyName"
:total-meterage="cartStore.totalMeterage"
:max-meterage="cartStore.maxMeterage"
:remaining-capacity="cartStore.remainingCapacity"
:is-empty="isEmpty"
@back="router.push('/')"
@remove="removeItem"
@checkout="proceedToCheckout"
@select-company="router.push('/')"
/>
<div class="cart-view">
<MobileCartPage
class="mobile-only"
:items="items"
:company-name="companyName"
:total-meterage="cartStore.totalMeterage"
:max-meterage="cartStore.maxMeterage"
:remaining-capacity="cartStore.remainingCapacity"
:is-empty="isEmpty"
@back="router.push('/')"
@remove="removeItem"
@checkout="proceedToCheckout"
@select-company="router.push('/')"
/>
<DesktopCartPage
class="desktop-only"
:items="items"
:company-name="companyName"
:total-meterage="cartStore.totalMeterage"
:max-meterage="cartStore.maxMeterage"
:remaining-capacity="cartStore.remainingCapacity"
:is-empty="isEmpty"
@remove="removeItem"
@checkout="proceedToCheckout"
@select-company="router.push('/')"
@back="goToOrdering"
/>
<DesktopCartPage
class="desktop-only"
:items="items"
:company-name="companyName"
:total-meterage="cartStore.totalMeterage"
:max-meterage="cartStore.maxMeterage"
:remaining-capacity="cartStore.remainingCapacity"
:is-empty="isEmpty"
@remove="removeItem"
@checkout="proceedToCheckout"
@select-company="router.push('/')"
@back="goToOrdering"
/>
<div v-if="showFullDialog" class="dialog-overlay" @click.self="showFullDialog = false">
<div class="dialog card">
<h3>سقف متراژ تکمیل شد</h3>
<p class="text-sm">
سقف متراژ این فاکتور تکمیل شده است. آیا مایلید ادامه محصولات را در فاکتور جدید ثبت کنید؟
</p>
<div class="dialog-actions">
<BaseButton variant="outline" @click="proceedAnyway">تسویه همین فاکتور</BaseButton>
<BaseButton @click="createNewInvoice">فاکتور جدید</BaseButton>
<div v-if="showFullDialog" class="dialog-overlay" @click.self="showFullDialog = false">
<div class="dialog card">
<h3>سقف متراژ تکمیل شد</h3>
<p class="text-sm">
سقف متراژ این فاکتور تکمیل شده است. آیا مایلید ادامه محصولات را در فاکتور جدید ثبت کنید؟
</p>
<div class="dialog-actions">
<BaseButton variant="outline" @click="proceedAnyway">تسویه همین فاکتور</BaseButton>
<BaseButton @click="createNewInvoice">فاکتور جدید</BaseButton>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.cart-view {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dialog h3 {
margin: 0 0 12px;
}
+21 -24
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import BaseButton from '@/components/ui/BaseButton.vue'
import IconCart from '@/components/icons/IconCart.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import IconTrash from '@/components/icons/IconTrash.vue'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import { formatMeterage, formatNumber } from '@/utils/format'
@@ -82,11 +82,9 @@ const emit = defineEmits<{
</div>
</div>
<div v-else class="cart-mobile-page__empty">
<IconCart :size="56" />
<p>سبد خرید خالی است</p>
<EmptyState v-else variant="cart" message="برای شروع سفارش، یک شرکت انتخاب کنید">
<BaseButton @click="emit('selectCompany')">انتخاب شرکت</BaseButton>
</div>
</EmptyState>
</div>
</template>
@@ -96,8 +94,11 @@ const emit = defineEmits<{
display: flex;
flex-direction: column;
min-height: 0;
background: #fff;
width: 100%;
background: var(--color-bg);
color: var(--color-text);
padding: 12px 0 0;
overflow: hidden;
}
.cart-mobile-page__nav {
@@ -112,6 +113,7 @@ const emit = defineEmits<{
font-size: 1.0625rem;
font-weight: 700;
text-align: center;
color: var(--color-text);
}
.nav-btn {
@@ -119,6 +121,7 @@ const emit = defineEmits<{
height: 40px;
border: none;
background: none;
color: var(--color-text);
cursor: pointer;
display: flex;
align-items: center;
@@ -156,6 +159,7 @@ const emit = defineEmits<{
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-elevated);
}
.cart-mobile-table__head,
@@ -168,7 +172,7 @@ const emit = defineEmits<{
}
.cart-mobile-table__head {
background: #f1f5f9;
background: var(--color-summary-bg);
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
@@ -176,6 +180,7 @@ const emit = defineEmits<{
.cart-mobile-table__row {
border-top: 1px solid var(--color-border);
color: var(--color-text);
}
.col-product strong {
@@ -209,23 +214,25 @@ const emit = defineEmits<{
border: none;
background: none;
cursor: pointer;
color: #64748b;
color: var(--color-text-muted);
}
.cart-mobile-page__footer {
flex-shrink: 0;
margin: 0 calc(-1 * var(--page-padding));
padding: 12px var(--page-padding);
margin: 0;
padding: 12px 0 calc(12px + env(safe-area-inset-bottom, 0));
border-top: 1px solid var(--color-border);
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
background: #fff;
background: var(--color-bg);
}
.cart-mobile-summary {
margin-bottom: 12px;
padding: 14px 16px;
background: #eef2f7;
background: var(--color-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
box-shadow: var(--shadow-sm);
}
.cart-mobile-summary__row {
@@ -236,16 +243,6 @@ const emit = defineEmits<{
}
.cart-mobile-summary__row:not(:last-child) {
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.cart-mobile-page__empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border);
}
</style>
+43 -15
View File
@@ -1,14 +1,16 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCatalogStore } from '@/stores/catalog'
import PageHeader from '@/components/layout/PageHeader.vue'
import SearchField from '@/components/ui/SearchField.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import IconCatalog from '@/components/icons/IconCatalog.vue'
const route = useRoute()
const router = useRouter()
const catalogStore = useCatalogStore()
const loading = ref(true)
const companyId = computed(() => route.params.companyId as string)
const company = computed(() => catalogStore.getCompany(companyId.value))
@@ -16,11 +18,25 @@ const search = ref('')
const catalogs = computed(() => catalogStore.fetchCatalogs(companyId.value, search.value))
async function load() {
loading.value = true
if (!catalogStore.getCompany(companyId.value)) {
await catalogStore.loadCompanies()
}
await catalogStore.loadCatalogs(companyId.value)
loading.value = false
}
onMounted(load)
watch(companyId, load)
function openCatalog(catalogId: string) {
router.push(`/companies/${companyId.value}/catalogs/${catalogId}/products`)
}
function getProductCount(catalogId: string) {
const catalog = catalogStore.getCatalog(catalogId)
if (catalog?.productCount != null) return catalog.productCount
return catalogStore.fetchProducts(catalogId).length
}
</script>
@@ -42,8 +58,8 @@ function getProductCount(catalogId: string) {
class="catalog-page__search"
/>
<div class="page__scroll page__scroll--with-nav">
<div class="catalog-mobile-list">
<div class="page__scroll page__scroll--with-nav catalog-mobile-scroll">
<div v-if="catalogs.length > 0" class="catalog-mobile-list">
<button
v-for="catalog in catalogs"
:key="catalog.id"
@@ -58,8 +74,12 @@ function getProductCount(catalogId: string) {
</div>
<span class="catalog-card__count">{{ getProductCount(catalog.id) }} محصول</span>
</button>
<div v-if="catalogs.length === 0" class="catalog-empty">کالیته‌ای یافت نشد.</div>
</div>
<EmptyState
v-else
:variant="search ? 'search' : 'catalogs'"
:message="search ? 'با این جستجو کالیته‌ای پیدا نشد' : 'برای این شرکت هنوز کالیته‌ای ثبت نشده است'"
/>
</div>
</div>
@@ -76,7 +96,7 @@ function getProductCount(catalogId: string) {
class="catalog-page__search"
/>
<div class="desktop-grid-3">
<div v-if="catalogs.length > 0" class="desktop-grid-3">
<button
v-for="catalog in catalogs"
:key="catalog.id"
@@ -93,13 +113,18 @@ function getProductCount(catalogId: string) {
</button>
</div>
<div v-if="catalogs.length === 0" class="catalog-empty">کالیته‌ای یافت نشد.</div>
<EmptyState
v-else
:variant="search ? 'search' : 'catalogs'"
:message="search ? 'با این جستجو کالیته‌ای پیدا نشد' : 'برای این شرکت هنوز کالیته‌ای ثبت نشده است'"
/>
</div>
</template>
<style scoped>
.catalog-mobile-page {
background: #fff;
background: var(--color-bg);
color: var(--color-text);
padding: 12px 0 0;
}
@@ -107,6 +132,13 @@ function getProductCount(catalogId: string) {
margin-bottom: 16px;
}
.catalog-mobile-scroll {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.catalog-mobile-list {
display: flex;
flex-direction: column;
@@ -121,8 +153,9 @@ function getProductCount(catalogId: string) {
padding: 14px 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
background: var(--color-elevated);
box-shadow: var(--shadow-sm);
color: var(--color-text);
text-align: right;
cursor: pointer;
}
@@ -135,7 +168,7 @@ function getProductCount(catalogId: string) {
}
.catalog-card:hover {
border-color: #cbd5e1;
border-color: var(--color-primary);
box-shadow: var(--shadow-md);
}
@@ -157,6 +190,7 @@ function getProductCount(catalogId: string) {
.catalog-card__info h3 {
margin: 0 0 4px;
font-size: 0.9375rem;
color: var(--color-text);
}
.catalog-card__info p {
@@ -170,10 +204,4 @@ function getProductCount(catalogId: string) {
font-weight: 600;
color: var(--color-primary);
}
.catalog-empty {
text-align: center;
padding: 32px;
color: var(--color-text-muted);
}
</style>
+40 -20
View File
@@ -1,20 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useCartStore } from '@/stores/cart'
import { useOrderStore } from '@/stores/order'
import { useAuthStore } from '@/stores/auth'
import { useCatalogStore } from '@/stores/catalog'
import PageHeader from '@/components/layout/PageHeader.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import IconSuccessCircle from '@/components/icons/IconSuccessCircle.vue'
import { toPersianDigits } from '@/utils/format'
import { getCompanyById } from '@/data/mockData'
import type { Order } from '@/types'
import type { BankAccount, Order } from '@/types'
const router = useRouter()
const cartStore = useCartStore()
const orderStore = useOrderStore()
const authStore = useAuthStore()
const catalogStore = useCatalogStore()
const bankAccounts = ref<BankAccount[]>([])
const submitting = ref(false)
const companyId = computed(() => {
const firstItem = cartStore.activeCart?.items[0]
@@ -23,16 +26,24 @@ const companyId = computed(() => {
const companyName = computed(
() => cartStore.companyNamesLabel || cartStore.activeCart?.companyName || '',
)
const bankAccounts = computed(() => orderStore.getBankAccountsForCompany(companyId.value))
onMounted(async () => {
if (!catalogStore.getCompany(companyId.value)) {
await catalogStore.loadCompanies()
}
if (companyId.value) {
bankAccounts.value = await catalogStore.loadBankAccounts(companyId.value)
}
})
const expiryMessage = computed(() => {
const hour = getCompanyById(companyId.value)?.cashInvoiceValidUntilHour ?? 17
const hour = catalogStore.getCompany(companyId.value)?.cashInvoiceValidUntilHour ?? 17
const time = toPersianDigits(String(hour).padStart(2, '0'))
return `این فاکتور تا ساعت ${time}:۰۰ امروز معتبر است.`
})
const accountsSectionTitle = computed(() => {
const company = getCompanyById(companyId.value)
const company = catalogStore.getCompany(companyId.value)
const name = (company?.name || companyName.value).replace(/^شرکت\s+/u, '')
return `شماره حساب‌های شرکت ${name}`
})
@@ -47,21 +58,29 @@ function formatIban(value: string) {
return clean.replace(/(.{4})/g, '$1 ').trim()
}
function createOrder(): Order | null {
if (!authStore.user || !cartStore.activeCart) return null
const order = orderStore.submitOrder(authStore.user.id, cartStore.activeCart, 'cash')
cartStore.clearActiveCart()
return order
async function createOrder(): Promise<Order | null> {
if (!authStore.user || !cartStore.activeCart || submitting.value) return null
submitting.value = true
try {
const order = await orderStore.submitOrder(authStore.user.id, cartStore.activeCart, 'cash')
await cartStore.hydrate()
return order
} catch (e) {
alert(e instanceof Error ? e.message : 'ثبت سفارش ناموفق بود.')
return null
} finally {
submitting.value = false
}
}
function viewOrder() {
const order = createOrder()
async function viewOrder() {
const order = await createOrder()
if (!order) return
router.replace(`/orders/${order.id}?track=1`)
}
function registerReceipt() {
const order = createOrder()
async function registerReceipt() {
const order = await createOrder()
if (!order) return
router.replace(`/orders/${order.id}?track=1&receipt=1`)
}
@@ -200,7 +219,8 @@ function registerReceipt() {
.accounts-panel {
padding: 16px;
border-radius: 16px;
background: #eef2f7;
background: var(--color-surface);
border: 1px solid var(--color-border);
}
.accounts-panel__title {
@@ -214,8 +234,9 @@ function registerReceipt() {
.bank-card {
padding: 14px 16px;
border-radius: 14px;
background: #ffffff;
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04);
background: var(--color-elevated);
color: var(--color-text);
box-shadow: var(--shadow-sm);
}
.bank-card + .bank-card {
@@ -269,7 +290,7 @@ function registerReceipt() {
.bank-card__field + .bank-card__field {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
border-top: 1px solid var(--color-border);
}
.bank-card__label {
@@ -289,7 +310,6 @@ function registerReceipt() {
padding: 12px 0 calc(12px + env(safe-area-inset-bottom, 0));
background: var(--color-bg);
border-top: 1px solid var(--color-border);
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
margin: 0 calc(-1 * var(--page-padding));
padding-left: var(--page-padding);
padding-right: var(--page-padding);
+1 -1
View File
@@ -281,7 +281,7 @@ function resetForm() {
min-height: 120px;
border: 1.5px dashed var(--color-border);
border-radius: var(--radius-md);
background: #fafafa;
background: var(--color-surface);
cursor: pointer;
overflow: hidden;
position: relative;
+34 -26
View File
@@ -86,18 +86,22 @@ function toggleMenu(id: string) {
openMenuId.value = openMenuId.value === id ? '' : id
}
function submitOrder() {
async function submitOrder() {
if (!authStore.user || !cartStore.activeCart || checks.value.length === 0) return
const order = orderStore.submitOrder(
authStore.user.id,
cartStore.activeCart,
'check',
[...orderStore.draftChecks],
orderStore.draftCheckMode ?? 'brief',
)
cartStore.clearActiveCart()
router.replace(`/orders/${order.id}/success`)
try {
const order = await orderStore.submitOrder(
authStore.user.id,
cartStore.activeCart,
'check',
[...orderStore.draftChecks],
orderStore.draftCheckMode ?? 'brief',
)
await cartStore.hydrate()
router.replace(`/orders/${order.id}/success`)
} catch (e) {
alert(e instanceof Error ? e.message : 'ثبت سفارش ناموفق بود.')
}
}
</script>
@@ -115,9 +119,10 @@ function submitOrder() {
</header>
<div v-if="checks.length === 0" class="check-list-page__empty page__scroll page__scroll--with-nav">
<p>چکی ثبت نشده است.</p>
<BaseButton v-if="isCheckout" @click="addAnother">ثبت چک جدید</BaseButton>
<BaseButton v-else variant="outline" @click="router.push('/')">ثبت سفارش جدید</BaseButton>
<EmptyState variant="checks" message="برای ادامه، حداقل یک چک ثبت کنید">
<BaseButton v-if="isCheckout" @click="addAnother">ثبت چک جدید</BaseButton>
<BaseButton v-else variant="outline" @click="router.push('/')">ثبت سفارش جدید</BaseButton>
</EmptyState>
</div>
<template v-else>
@@ -268,7 +273,7 @@ function submitOrder() {
</div>
<div v-else class="desktop-checkout-shell desktop-only">
<EmptyState message="چکی ثبت نشده است">
<EmptyState variant="checks" message="برای ادامه، حداقل یک چک ثبت کنید">
<BaseButton v-if="isCheckout" @click="addAnother">ثبت چک جدید</BaseButton>
</EmptyState>
</div>
@@ -293,7 +298,7 @@ function submitOrder() {
min-height: 0;
height: 100%;
padding: 12px var(--page-padding) 0;
background: #ffffff;
background: var(--color-bg);
overflow: hidden;
}
@@ -360,8 +365,10 @@ function submitOrder() {
.summary-card {
padding: 14px 12px;
border-radius: 14px;
background: #f1f5f9;
background: var(--color-summary-bg);
border: 1px solid var(--color-border);
text-align: center;
color: var(--color-text);
}
.summary-card__label {
@@ -422,8 +429,9 @@ function submitOrder() {
align-items: center;
padding: 12px;
border-radius: 14px;
background: #f8fafc;
border: 1px solid #eef2f7;
background: var(--color-elevated);
border: 1px solid var(--color-border);
color: var(--color-text);
}
.check-card__menu-wrap {
@@ -450,7 +458,7 @@ function submitOrder() {
min-width: 88px;
padding: 4px;
border-radius: var(--radius-sm);
background: #fff;
background: var(--color-elevated);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-md);
}
@@ -505,7 +513,7 @@ function submitOrder() {
border-radius: 8px;
object-fit: cover;
border: 1px solid var(--color-border);
background: #fff;
background: var(--color-surface);
}
.check-card__thumb--empty {
@@ -519,8 +527,8 @@ function submitOrder() {
.rass-panel {
padding: 16px;
border-radius: 14px;
background: #ecfdf3;
border: 1px solid #bbf7d0;
background: var(--color-success-light);
border: 1px solid var(--color-success);
margin-bottom: 12px;
text-align: center;
}
@@ -529,26 +537,26 @@ function submitOrder() {
margin: 0 0 6px;
font-size: 0.875rem;
font-weight: 600;
color: #166534;
color: var(--color-success);
}
.rass-panel__days {
font-size: 1.75rem;
font-weight: 700;
color: #15803d;
color: var(--color-success);
line-height: 1.2;
}
.rass-panel__date {
margin: 6px 0 0;
font-size: 0.8125rem;
color: #166534;
color: var(--color-success);
}
.check-list-page__footer {
flex-shrink: 0;
padding: 12px 0 calc(12px + env(safe-area-inset-bottom, 0));
background: #fff;
background: var(--color-bg);
border-top: 1px solid var(--color-border);
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
margin: 0 calc(-1 * var(--page-padding));
+3 -2
View File
@@ -71,8 +71,9 @@ function selectMode(mode: 'full' | 'brief') {
padding: 32px 24px;
border: none;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
background: var(--color-elevated);
color: var(--color-text);
box-shadow: var(--shadow-sm);
cursor: pointer;
transition:
box-shadow 0.15s,
+2 -1
View File
@@ -99,9 +99,10 @@ function selectType(type: 'cash' | 'check') {
padding: 32px 24px;
border: none;
border-radius: 16px;
background: #fff;
background: var(--color-elevated);
box-shadow: var(--shadow-sm);
cursor: pointer;
color: var(--color-text);
}
.settlement-card--desktop {
+29 -17
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useCatalogStore } from '@/stores/catalog'
import { useCartStore } from '@/stores/cart'
@@ -11,14 +12,20 @@ import type { Company } from '@/types'
const router = useRouter()
const catalogStore = useCatalogStore()
const cartStore = useCartStore()
const loading = ref(true)
const companies = catalogStore.getActiveCompanies()
const companies = computed(() => catalogStore.getActiveCompanies())
const displayMeta: Record<string, { description: string; logo: typeof IconParLogo }> = {
par: { description: 'تولید و عرضه انواع پارچه', logo: IconParLogo },
'fam-baft': { description: 'تولید و عرضه انواع پارچه', logo: IconFamBaftLogo },
}
onMounted(async () => {
await catalogStore.loadCompanies()
loading.value = false
})
function selectCompany(companyId: string) {
const company = catalogStore.getCompany(companyId)
if (!company) return
@@ -41,21 +48,26 @@ function companyLogo(slug: string) {
</script>
<template>
<MobileCompanySelect
class="mobile-only"
:companies="companies"
:card-variant="cardVariant"
:company-description="companyDescription"
:company-logo="companyLogo"
@select="selectCompany"
/>
<div v-if="loading" class="page page__scroll" style="padding: 2rem; text-align: center">
در حال بارگذاری…
</div>
<template v-else>
<MobileCompanySelect
class="mobile-only"
:companies="companies"
:card-variant="cardVariant"
:company-description="companyDescription"
:company-logo="companyLogo"
@select="selectCompany"
/>
<DesktopHomeDashboard
class="desktop-only"
:companies="companies"
:card-variant="cardVariant"
:company-description="companyDescription"
:company-logo="companyLogo"
@select="selectCompany"
/>
<DesktopHomeDashboard
class="desktop-only"
:companies="companies"
:card-variant="cardVariant"
:company-description="companyDescription"
:company-logo="companyLogo"
@select="selectCompany"
/>
</template>
</template>
@@ -46,7 +46,8 @@ defineEmits<{
display: flex;
flex-direction: column;
min-height: 0;
background: #fff;
background: var(--color-bg);
color: var(--color-text);
padding: 12px 0;
}
@@ -91,13 +92,14 @@ defineEmits<{
.company-brand-card--par {
background: var(--color-primary);
color: #fff;
box-shadow: 0 8px 24px rgba(29, 53, 87, 0.25);
box-shadow: var(--shadow-md);
}
.company-brand-card--fam-baft {
background: #f3ece3;
color: #1a202c;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06);
background: var(--color-brand-warm);
color: var(--color-brand-warm-text);
box-shadow: var(--shadow-card);
border: 1px solid var(--color-border);
}
.company-brand-card__logo {
@@ -121,6 +123,6 @@ defineEmits<{
}
.company-brand-card--fam-baft .company-brand-card__desc {
color: #718096;
color: var(--color-brand-warm-muted);
}
</style>
+50 -22
View File
@@ -64,8 +64,31 @@ watch(destinationAccountId, () => {
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
})
const bankAccounts = computed(() =>
order.value ? orderStore.getBankAccountsForCompany(order.value.companyId) : [],
const bankAccounts = ref(
[] as Awaited<ReturnType<typeof orderStore.getBankAccountsForCompany>>,
)
watch(
() => order.value?.companyId,
async (companyId) => {
if (!companyId) {
bankAccounts.value = []
return
}
bankAccounts.value = await orderStore.getBankAccountsForCompany(companyId)
},
{ immediate: true },
)
watch(
() => orderId.value,
async (id) => {
if (!id) return
if (!orderStore.getOrderById(id)) {
await orderStore.fetchOrder(id)
}
},
{ immediate: true },
)
const statusHero = computed(() => (order.value ? getStatusHero(order.value.status) : null))
@@ -114,7 +137,7 @@ async function handleReceiptImage(event: Event) {
receiptImage.value = await fileToBase64(file)
}
function submitReceipt() {
async function submitReceipt() {
if (!order.value) return
fieldErrors.value = {}
@@ -142,17 +165,21 @@ function submitReceipt() {
return
}
orderStore.submitCashReceipt(order.value.id, {
amount,
transferDate: receiptDate.value,
transferTime: receiptTime.value || '12:00',
trackingNumber: trackingNumber.value,
destinationAccountId: destinationAccountId.value,
receiptImage: receiptImage.value || undefined,
})
showReceiptForm.value = false
fieldErrors.value = {}
formError.value = ''
try {
await orderStore.submitCashReceipt(order.value.id, {
amount,
transferDate: receiptDate.value,
transferTime: receiptTime.value || '12:00',
trackingNumber: trackingNumber.value,
destinationAccountId: destinationAccountId.value,
receiptImage: receiptImage.value || undefined,
})
showReceiptForm.value = false
fieldErrors.value = {}
formError.value = ''
} catch (e) {
formError.value = e instanceof Error ? e.message : 'ثبت رسید ناموفق بود.'
}
}
</script>
@@ -388,7 +415,8 @@ function submitReceipt() {
min-height: 0;
height: 100%;
padding: 12px var(--page-padding) 0;
background: #ffffff;
background: var(--color-bg);
color: var(--color-text);
overflow: hidden;
}
@@ -457,15 +485,15 @@ function submitReceipt() {
}
.order-info-card {
background: #ffffff;
background: var(--color-elevated);
border-radius: 20px;
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.08);
box-shadow: var(--shadow-sm);
padding: 20px 18px 8px;
}
.order-info-card__head {
padding-bottom: 16px;
border-bottom: 1px solid #eeeeee;
border-bottom: 1px solid var(--color-border);
margin-bottom: 4px;
}
@@ -490,7 +518,7 @@ function submitReceipt() {
align-items: center;
gap: 12px;
padding: 14px 0;
border-bottom: 1px solid #eeeeee;
border-bottom: 1px solid var(--color-border);
}
.order-info-card__row--last {
@@ -511,7 +539,7 @@ function submitReceipt() {
}
.order-info-card__value--accent {
color: #2b66e3;
color: var(--color-primary);
font-weight: 500;
}
@@ -525,7 +553,7 @@ function submitReceipt() {
.order-detail-page__footer {
flex-shrink: 0;
padding: 12px 0;
background: #ffffff;
background: var(--color-bg);
border-top: 1px solid var(--color-border);
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
margin: 0 calc(-1 * var(--page-padding));
@@ -550,7 +578,7 @@ function submitReceipt() {
padding: 16px;
border: 1px solid var(--color-border);
border-radius: 16px;
background: #ffffff;
background: var(--color-elevated);
box-shadow: var(--shadow-sm);
}
@@ -203,7 +203,7 @@ const emit = defineEmits<{
</div>
</div>
<EmptyState v-else message="سفارشی یافت نشد">
<EmptyState v-else variant="orders" message="هنوز سفارشی ثبت نکرده‌اید">
<BaseButton @click="emit('newOrder')">ثبت سفارش جدید</BaseButton>
</EmptyState>
</div>
@@ -4,15 +4,21 @@ import BaseButton from '@/components/ui/BaseButton.vue'
import FilterTabs from '@/components/ui/FilterTabs.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconParLogo from '@/components/icons/IconParLogo.vue'
import IconFamBaftLogo from '@/components/icons/IconFamBaftLogo.vue'
import { formatCurrency } from '@/utils/format'
import { formatCurrency, formatNumber, toPersianDigits } from '@/utils/format'
import type { Order } from '@/types'
defineProps<{
tab: string
tabs: { id: string; label: string }[]
orders: Order[]
totalCount: number
currentPage: number
totalPages: number
paginationRange: { start: number; end: number }
visiblePages: (number | 'ellipsis')[]
companySlug: (order: Order) => string
iconVariant: (order: Order) => string
}>()
@@ -21,6 +27,7 @@ const emit = defineEmits<{
'update:tab': [value: string]
select: [id: string]
back: []
'set-page': [page: number]
}>()
</script>
@@ -44,7 +51,7 @@ const emit = defineEmits<{
@update:model-value="emit('update:tab', $event)"
/>
<div class="page__scroll page__scroll--with-nav">
<div class="page__scroll page__scroll--with-nav orders-mobile-page__scroll">
<div v-if="orders.length > 0" class="orders-mobile-page__list">
<button
v-for="order in orders"
@@ -69,16 +76,57 @@ const emit = defineEmits<{
</button>
</div>
<EmptyState v-else message="سفارشی یافت نشد">
<EmptyState v-else variant="orders" message="هنوز سفارشی ثبت نکرده‌اید">
<BaseButton @click="emit('back')">ثبت سفارش جدید</BaseButton>
</EmptyState>
<div v-if="totalCount > 0" class="orders-mobile-pagination">
<p class="orders-mobile-pagination__summary">
{{ toPersianDigits(String(paginationRange.start)) }}–
{{ toPersianDigits(String(paginationRange.end)) }}
از {{ formatNumber(totalCount) }}
</p>
<div class="orders-mobile-pagination__controls">
<button
type="button"
class="orders-mobile-pagination__arrow"
:disabled="currentPage <= 1"
aria-label="صفحه قبل"
@click="emit('set-page', currentPage - 1)"
>
<IconChevronLeft :size="16" class="orders-mobile-pagination__arrow--next" />
</button>
<template v-for="(page, index) in visiblePages" :key="`${page}-${index}`">
<span v-if="page === 'ellipsis'" class="orders-mobile-pagination__ellipsis">…</span>
<button
v-else
type="button"
class="orders-mobile-pagination__page"
:class="{ 'orders-mobile-pagination__page--active': page === currentPage }"
@click="emit('set-page', page)"
>
{{ toPersianDigits(String(page)) }}
</button>
</template>
<button
type="button"
class="orders-mobile-pagination__arrow"
:disabled="currentPage >= totalPages"
aria-label="صفحه بعد"
@click="emit('set-page', currentPage + 1)"
>
<IconChevronLeft :size="16" class="orders-mobile-pagination__arrow--prev" />
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.orders-mobile-page {
background: #ffffff;
background: var(--color-bg);
color: var(--color-text);
padding: 12px 0 0;
}
@@ -87,6 +135,7 @@ const emit = defineEmits<{
grid-template-columns: 40px 1fr 40px;
align-items: center;
margin-bottom: 16px;
padding-inline: var(--page-padding);
}
.orders-mobile-page__title {
@@ -94,6 +143,7 @@ const emit = defineEmits<{
font-size: 1.0625rem;
font-weight: 700;
text-align: center;
color: var(--color-text);
}
.nav-btn {
@@ -101,6 +151,7 @@ const emit = defineEmits<{
height: 40px;
border: none;
background: none;
color: var(--color-text);
cursor: pointer;
display: flex;
align-items: center;
@@ -118,6 +169,12 @@ const emit = defineEmits<{
.orders-mobile-page__tabs {
margin-bottom: 16px;
padding-inline: var(--page-padding);
}
.orders-mobile-page__scroll {
padding-inline: var(--page-padding);
padding-bottom: 24px;
}
.orders-mobile-page__list {
@@ -130,10 +187,11 @@ const emit = defineEmits<{
display: flex;
width: 100%;
padding: 16px;
border: none;
border: 1px solid var(--color-border);
border-radius: 16px;
background: #fff;
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
background: var(--color-elevated);
box-shadow: var(--shadow-card);
color: var(--color-text);
text-align: right;
cursor: pointer;
}
@@ -154,18 +212,21 @@ const emit = defineEmits<{
font-size: 0.9375rem;
font-weight: 700;
margin-bottom: 6px;
color: var(--color-text);
}
.order-card__company {
margin: 0 0 6px;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
}
.order-card__amount {
margin: 0 0 10px;
font-size: 0.875rem;
font-weight: 700;
color: var(--color-text);
}
.order-card__badge {
@@ -189,11 +250,73 @@ const emit = defineEmits<{
}
.order-card__icon--fam-baft {
background: #f3ece3;
background: var(--color-brand-warm);
color: var(--color-brand-warm-text);
}
.order-card__icon--success {
background: var(--color-success-soft);
color: var(--color-success);
}
.orders-mobile-pagination {
margin-top: 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.orders-mobile-pagination__summary {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.orders-mobile-pagination__controls {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
justify-content: center;
}
.orders-mobile-pagination__arrow,
.orders-mobile-pagination__page {
width: 36px;
height: 36px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-elevated);
color: var(--color-text);
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
font-family: inherit;
font-size: 0.8125rem;
cursor: pointer;
}
.orders-mobile-pagination__arrow:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.orders-mobile-pagination__arrow--prev {
transform: scaleX(-1);
}
.orders-mobile-pagination__page--active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
font-weight: 600;
}
.orders-mobile-pagination__ellipsis {
min-width: 20px;
text-align: center;
color: var(--color-text-muted);
}
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import BaseButton from '@/components/ui/BaseButton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import IconCart from '@/components/icons/IconCart.vue'
import { formatMeterage, formatNumber } from '@/utils/format'
import type { Product } from '@/types'
@@ -61,7 +62,7 @@ const emit = defineEmits<{
</button>
</div>
<div class="product-desktop-grid">
<div v-if="products.length > 0" class="product-desktop-grid">
<article v-for="product in products" :key="product.id" class="product-desktop-card">
<div class="product-desktop-card__preview" aria-hidden="true" />
<div class="product-desktop-card__body">
@@ -113,6 +114,12 @@ const emit = defineEmits<{
</div>
</article>
</div>
<EmptyState
v-else
variant="products"
message="برای این کالیته یا رنگ، محصولی ثبت نشده است"
/>
</div>
<aside v-if="itemCount > 0" class="desktop-summary-panel">
@@ -288,10 +295,12 @@ const emit = defineEmits<{
left: 50%;
transform: translateX(-50%);
background: var(--color-text);
color: #fff;
color: var(--color-bg);
padding: 10px 20px;
border-radius: var(--radius-sm);
z-index: 150;
box-shadow: var(--shadow-md);
font-size: 0.875rem;
}
.desktop-summary-panel__action :deep(.btn) {
@@ -2,6 +2,7 @@
import PageHeader from '@/components/layout/PageHeader.vue'
import CartMeterageFooter from '@/components/ui/CartMeterageFooter.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import { formatMeterage } from '@/utils/format'
import type { Product } from '@/types'
@@ -53,7 +54,7 @@ const emit = defineEmits<{
</div>
<div class="page__scroll product-mobile-page__scroll">
<div class="product-mobile-list">
<div v-if="products.length > 0" class="product-mobile-list">
<article v-for="product in products" :key="product.id" class="product-mobile-card">
<div class="product-mobile-card__head">
<h3>{{ product.name }}</h3>
@@ -97,6 +98,12 @@ const emit = defineEmits<{
<p v-if="errors[product.id]" class="error-text">{{ errors[product.id] }}</p>
</article>
</div>
<EmptyState
v-else
variant="products"
message="برای این کالیته یا رنگ، محصولی ثبت نشده است"
/>
</div>
<div v-if="toast" class="toast">{{ toast }}</div>
@@ -113,12 +120,17 @@ const emit = defineEmits<{
<style scoped>
.product-mobile-page {
background: #fff;
background: var(--color-bg);
color: var(--color-text);
padding: 12px 0 0;
}
.product-mobile-page__scroll {
padding-bottom: 76px;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.color-tabs {
@@ -219,9 +231,13 @@ const emit = defineEmits<{
left: 50%;
transform: translateX(-50%);
background: var(--color-text);
color: #fff;
color: var(--color-bg);
padding: 10px 20px;
border-radius: var(--radius-sm);
z-index: 150;
box-shadow: var(--shadow-md);
font-size: 0.875rem;
white-space: nowrap;
max-width: calc(100% - 32px);
}
</style>
+158 -84
View File
@@ -1,51 +1,83 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useTimeoutFn } from '@vueuse/core'
import BaseButton from '@/components/ui/BaseButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import ProfileSubpageLayout from '@/views/profile/components/ProfileSubpageLayout.vue'
import { useThemeStore, type ThemePreference } from '@/stores/theme'
import { ref } from 'vue'
const language = ref('fa')
const currency = ref('irr')
const density = ref('comfortable')
const theme = useThemeStore()
const saved = ref(false)
const { start: hideSaved } = useTimeoutFn(() => {
saved.value = false
}, 2000)
const themeOptions: {
value: ThemePreference
label: string
description: string
icon: string
}[] = [
{
value: 'light',
label: 'روشن',
description: 'پس‌زمینه روشن و کنتراست بالا',
icon: 'vuesax-linear:sun-1',
},
{
value: 'dark',
label: 'تاریک',
description: 'مناسب استفاده در شب',
icon: 'vuesax-linear:moon',
},
{
value: 'system',
label: 'سیستم',
description: 'هم‌تراز با تنظیمات دستگاه',
icon: 'vuesax-linear:mobile',
},
]
function save() {
saved.value = true
setTimeout(() => {
saved.value = false
}, 2000)
hideSaved()
}
</script>
<template>
<ProfileSubpageLayout title="تنظیمات نمایش" subtitle="زبان، واحد پول و حالت نمایش">
<div class="display-form">
<label class="display-field">
<span class="display-field__label">زبان</span>
<select v-model="language" class="display-field__control">
<option value="fa">فارسی</option>
<option value="en">English</option>
</select>
</label>
<ProfileSubpageLayout title="تنظیمات نمایش" subtitle="حالت نمایش روشن، تاریک یا سیستم">
<div class="display-page">
<fieldset class="display-fieldset">
<legend class="display-fieldset__legend">حالت نمایش</legend>
<label class="display-field">
<span class="display-field__label">واحد پول</span>
<select v-model="currency" class="display-field__control">
<option value="irr">تومان</option>
<option value="rial">ریال</option>
</select>
</label>
<fieldset class="display-field">
<legend class="display-field__label">حالت نمایش</legend>
<div class="display-options">
<label class="display-option">
<input v-model="density" type="radio" value="comfortable" />
<span>راحت</span>
</label>
<label class="display-option">
<input v-model="density" type="radio" value="compact" />
<span>فشرده</span>
</label>
<div class="display-grid" role="radiogroup" aria-label="حالت نمایش">
<button
v-for="option in themeOptions"
:key="option.value"
type="button"
class="theme-card"
:class="{ 'theme-card--active': theme.preference === option.value }"
role="radio"
:aria-checked="theme.preference === option.value"
@click="theme.preference = option.value"
>
<span class="theme-card__icon" aria-hidden="true">
<BasicIcon :icon="option.icon" :size="26" :stroke-width="1.5" />
</span>
<span class="theme-card__body">
<strong class="theme-card__label">{{ option.label }}</strong>
<span class="theme-card__desc">{{ option.description }}</span>
</span>
<span class="theme-card__check" aria-hidden="true">
<BasicIcon
v-if="theme.preference === option.value"
icon="vuesax-linear:tick-circle"
:size="20"
:stroke-width="1.75"
/>
</span>
</button>
</div>
</fieldset>
@@ -56,71 +88,113 @@ function save() {
</template>
<style scoped>
.display-form {
.display-page {
display: flex;
flex-direction: column;
gap: 16px;
gap: 20px;
}
.display-field {
display: flex;
flex-direction: column;
gap: 8px;
.display-fieldset {
margin: 0;
padding: 0;
border: none;
}
.display-field__label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text);
}
.display-field__control {
width: 100%;
min-height: var(--btn-height);
padding: 0 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font-family: inherit;
font-size: 0.9375rem;
}
.display-field__control:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-soft);
}
.display-options {
display: flex;
gap: 12px;
}
.display-option {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 0 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
.display-fieldset__legend {
margin: 0 0 12px;
padding: 0;
font-size: 0.875rem;
cursor: pointer;
font-weight: 700;
color: var(--color-text);
}
.display-option:has(input:checked) {
.display-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.theme-card {
display: grid;
grid-template-columns: 48px 1fr 24px;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
border: 1px solid var(--color-border);
border-radius: 16px;
background: var(--color-elevated);
color: var(--color-text);
text-align: right;
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s,
transform 0.15s;
}
.theme-card:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-sm);
}
.theme-card:active {
transform: scale(0.99);
}
.theme-card--active {
border-color: var(--color-primary);
background: var(--color-primary-soft);
color: var(--color-primary);
font-weight: 600;
box-shadow: 0 0 0 1px var(--color-primary);
}
.display-option input {
accent-color: var(--color-primary);
.theme-card__icon {
width: 48px;
height: 48px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.theme-card--active .theme-card__icon {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.theme-card__body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.theme-card__label {
font-size: 0.9375rem;
font-weight: 700;
}
.theme-card__desc {
font-size: 0.75rem;
color: var(--color-text-muted);
line-height: 1.4;
}
.theme-card--active .theme-card__label {
color: var(--color-primary);
}
.theme-card__check {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-primary);
min-height: 20px;
}
.display-saved {
+6 -2
View File
@@ -28,7 +28,7 @@ function clearField(key: string) {
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
}
function save() {
async function save() {
fieldErrors.value = {}
formError.value = ''
saved.value = false
@@ -46,11 +46,15 @@ function save() {
return
}
auth.updateProfile({
const updated = await auth.updateProfile({
firstName: firstName.value.trim(),
lastName: lastName.value.trim(),
nationalId: nationalId.value || undefined,
})
if (!updated) {
formError.value = 'ذخیره پروفایل ناموفق بود.'
return
}
saved.value = true
setTimeout(() => {
saved.value = false
@@ -118,7 +118,7 @@ const settingsItems: Array<{
{
id: 'display',
label: 'تنظیمات نمایش',
description: 'زبان، واحد پول و حالت نمایش',
description: 'تم روشن، تاریک یا سیستم',
icon: IconLayoutGrid,
path: '/profile/display',
},
@@ -7,17 +7,12 @@ import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconProfile from '@/components/icons/IconProfile.vue'
import IconCheckBook from '@/components/icons/IconCheckBook.vue'
import IconOrders from '@/components/icons/IconOrders.vue'
import IconSettings from '@/components/icons/IconSettings.vue'
const router = useRouter()
const auth = useAuthStore()
const orderStore = useOrderStore()
const initials = computed(() => {
const first = auth.user?.firstName?.[0] ?? ''
const last = auth.user?.lastName?.[0] ?? ''
return `${first}${last}` || '؟'
})
const draftCheckCount = computed(() => orderStore.draftChecks.length)
const menuItems = computed(() => [
@@ -28,6 +23,13 @@ const menuItems = computed(() => [
path: '/profile/edit',
icon: IconProfile,
},
{
id: 'display',
label: 'تنظیمات نمایش',
description: 'تم روشن، تاریک یا سیستم',
path: '/profile/display',
icon: IconSettings,
},
{
id: 'checks',
label: 'لیست چک‌ها',
@@ -51,7 +53,9 @@ const emit = defineEmits<{ logout: [] }>()
<template>
<div class="page profile-page">
<div class="profile-hero">
<div class="profile-hero__avatar">{{ initials }}</div>
<div class="profile-hero__avatar" aria-hidden="true">
<IconProfile :size="36" :stroke-width="1.5" />
</div>
<h1 class="profile-hero__name">{{ auth.fullName }}</h1>
<p class="profile-hero__phone" dir="ltr">{{ auth.user?.phone }}</p>
</div>
@@ -108,8 +112,7 @@ const emit = defineEmits<{ logout: [] }>()
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: 700;
color: #fff;
}
.profile-hero__name {
@@ -143,12 +146,13 @@ const emit = defineEmits<{ logout: [] }>()
gap: 12px;
width: 100%;
padding: 14px 16px;
border: none;
border: 1px solid var(--color-border);
border-radius: 16px;
background: var(--color-surface);
background: var(--color-elevated);
box-shadow: var(--shadow-sm);
cursor: pointer;
text-align: right;
color: var(--color-text);
transition: box-shadow 0.15s;
}
@@ -201,15 +205,14 @@ const emit = defineEmits<{ logout: [] }>()
.profile-menu__chevron {
color: var(--color-text-muted);
transform: scaleX(-1);
}
.profile-logout {
width: 100%;
height: var(--btn-height);
border: 1px solid #fecaca;
border: 1px solid var(--color-danger-light);
border-radius: 14px;
background: #fff;
background: var(--color-elevated);
color: var(--color-danger);
font-family: inherit;
font-size: 0.9375rem;