Improve auth, checkout, and API client for real backend.

Wire OTP and cart/catalog to the API with Vite proxy, fix resend/register name persistence, harden order submit errors, and polish OTP and cart footer UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-08 11:50:18 +02:00
co-authored by Cursor
parent 38864704f1
commit 72cc6000f1
20 changed files with 1023 additions and 171 deletions
+1 -1
View File
@@ -303,7 +303,7 @@ input[type='number'] {
.sticky-footer {
position: fixed;
bottom: var(--nav-height);
bottom: calc(var(--nav-height) + 12px);
inset-inline: 0;
width: 100%;
max-width: 100%;
+56 -27
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { formatMeterage } from '@/utils/format'
import { formatMeterage, toPersianDigits } from '@/utils/format'
import IconCart from '@/components/icons/IconCart.vue'
defineProps<{
@@ -13,21 +13,27 @@ defineEmits<{
</script>
<template>
<div class="cart-meterage-footer">
<div class="cart-meterage-footer__info">
<button
type="button"
class="cart-meterage-footer"
aria-label="مشاهده سبد خرید"
@click="$emit('click')"
>
<span class="cart-meterage-footer__info">
<span class="cart-meterage-footer__label">کل متراژ</span>
<span class="cart-meterage-footer__value">{{ formatMeterage(totalMeterage) }}</span>
</div>
<button
type="button"
class="cart-meterage-footer__btn"
aria-label="مشاهده سبد"
@click="$emit('click')"
>
<IconCart :size="22" />
<span v-if="itemCount" class="cart-meterage-footer__badge">{{ itemCount }}</span>
</button>
</div>
</span>
<span class="cart-meterage-footer__action">
<span class="cart-meterage-footer__action-text">مشاهده سبد</span>
<span class="cart-meterage-footer__icon-wrap">
<IconCart :size="20" />
<span v-if="itemCount" class="cart-meterage-footer__badge">
{{ toPersianDigits(String(itemCount)) }}
</span>
</span>
</span>
</button>
</template>
<style scoped>
@@ -36,15 +42,34 @@ defineEmits<{
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-height: 56px;
padding: 12px 16px;
border: none;
border-radius: var(--radius-md);
background: var(--color-primary);
color: #fff;
box-shadow: var(--shadow-md);
cursor: pointer;
text-align: start;
transition:
transform 0.15s ease,
filter 0.15s ease;
}
.cart-meterage-footer:hover {
filter: brightness(1.05);
}
.cart-meterage-footer:active {
transform: scale(0.99);
}
.cart-meterage-footer__info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.cart-meterage-footer__label {
@@ -57,29 +82,33 @@ defineEmits<{
font-weight: 700;
}
.cart-meterage-footer__btn {
.cart-meterage-footer__action {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.cart-meterage-footer__action-text {
font-size: 0.875rem;
font-weight: 700;
}
.cart-meterage-footer__icon-wrap {
position: relative;
width: 44px;
height: 44px;
border: none;
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.15);
color: #fff;
cursor: pointer;
background: rgba(255, 255, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.cart-meterage-footer__btn:hover {
background: rgba(255, 255, 255, 0.25);
}
.cart-meterage-footer__badge {
position: absolute;
top: -4px;
left: -4px;
inset-inline-start: -4px;
min-width: 18px;
height: 18px;
padding: 0 4px;
+10 -4
View File
@@ -62,23 +62,29 @@ export function useProductOrdering() {
async function addProduct(productId: string) {
const product = catalogStore.getProduct(productId)
if (!product || !catalog.value) return
if (!product || !catalog.value) {
errors.value = {
...errors.value,
[productId]: 'اطلاعات محصول هنوز آماده نیست. صفحه را تازه کنید.',
}
return
}
const meterage = getMeterage(productId)
if (meterage <= 0) {
errors.value[productId] = 'متراژ را وارد کنید.'
errors.value = { ...errors.value, [productId]: 'متراژ را وارد کنید.' }
return
}
const result = await cartStore.addItem(product, catalog.value.code, meterage)
if (result.success) {
errors.value[productId] = ''
errors.value = { ...errors.value, [productId]: '' }
toast.value = `${product.name} به سبد اضافه شد.`
setTimeout(() => {
toast.value = ''
}, 2000)
} else {
errors.value[productId] = result.message ?? 'خطا'
errors.value = { ...errors.value, [productId]: result.message ?? 'خطا' }
}
}
+166 -2
View File
@@ -1,8 +1,24 @@
import { normalizePhone } from '@/utils/format'
import { extractNumericString, normalizePhone } from '@/utils/format'
import { MOCK_OTP_CODE } from '@/utils/constants'
import { apiRequest, ApiError, getAccessToken, setAccessToken } from './http'
import type { User } from '@/types'
const USER_KEY = 'ft_user'
const PENDING_OTP_KEY = 'ft_pending_otp'
const MOCK_USERS_KEY = 'ft_mock_users'
type PendingOtp = {
phone: string
isRegister: boolean
firstName?: string
lastName?: string
}
let memoryPending: PendingOtp | null = null
function useMockAuth(): boolean {
return import.meta.env.VITE_USE_MOCK_AUTH === 'true'
}
function cacheUser(user: User | null): void {
if (user) localStorage.setItem(USER_KEY, JSON.stringify(user))
@@ -28,6 +44,126 @@ export function isAuthenticated(): boolean {
return !!getAccessToken()
}
function readMockUsers(): Record<string, User> {
try {
const raw = localStorage.getItem(MOCK_USERS_KEY)
if (!raw) return {}
return JSON.parse(raw) as Record<string, User>
} catch {
return {}
}
}
function writeMockUsers(users: Record<string, User>) {
localStorage.setItem(MOCK_USERS_KEY, JSON.stringify(users))
}
function setPendingOtp(value: PendingOtp | null) {
memoryPending = value
try {
if (value) sessionStorage.setItem(PENDING_OTP_KEY, JSON.stringify(value))
else sessionStorage.removeItem(PENDING_OTP_KEY)
} catch {
// ignore storage failures; in-memory pending still works
}
}
function getPendingOtp(): PendingOtp | null {
if (memoryPending) return memoryPending
try {
const raw = sessionStorage.getItem(PENDING_OTP_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as PendingOtp
memoryPending = parsed
return parsed
} catch {
return null
}
}
function isMockToken(token: string | null): boolean {
return !!token?.startsWith('mock.')
}
function ensureMockUser(phone: string, firstName = 'کاربر', lastName = 'آزمایشی'): User {
const users = readMockUsers()
const existing = users[phone]
if (existing) return existing
const user: User = {
id: `mock-${phone}`,
firstName,
lastName,
phone,
createdAt: new Date().toISOString(),
}
users[phone] = user
writeMockUsers(users)
return user
}
async function sendOtpMock(
phone: string,
options: { firstName?: string; lastName?: string; isRegister: boolean },
): Promise<{ success: boolean; message: string }> {
const users = readMockUsers()
if (options.isRegister && users[phone]) {
return { success: false, message: 'این شماره قبلاً ثبت‌نام شده است. از ورود استفاده کنید.' }
}
if (!options.isRegister) {
ensureMockUser(phone)
}
setPendingOtp({
phone,
isRegister: options.isRegister,
firstName: options.firstName,
lastName: options.lastName,
})
return {
success: true,
message: `کد تأیید ارسال شد. (کد آزمایشی: ${MOCK_OTP_CODE})`,
}
}
async function verifyOtpMock(
phone: string,
code: string,
): Promise<{ success: boolean; message: string; user?: User }> {
const normalizedCode = extractNumericString(code)
if (normalizedCode !== MOCK_OTP_CODE) {
return { success: false, message: 'کد تأیید نامعتبر است.' }
}
let pending = getPendingOtp()
if (pending && normalizePhone(pending.phone) !== phone) {
pending = null
}
// If the OTP page is open but pending was lost (refresh/HMR), still allow the known mock code.
if (!pending) {
pending = {
phone,
isRegister: false,
}
}
const user = pending.isRegister
? ensureMockUser(
phone,
pending.firstName?.trim() || 'کاربر',
pending.lastName?.trim() || 'جدید',
)
: ensureMockUser(phone)
setAccessToken(`mock.${phone}`)
cacheUser(user)
setPendingOtp(null)
return { success: true, message: 'ورود موفق.', user }
}
export async function sendOtp(
phone: string,
options: { firstName?: string; lastName?: string; isRegister: boolean },
@@ -41,6 +177,10 @@ export async function sendOtp(
return { success: false, message: 'نام و نام خانوادگی الزامی است.' }
}
if (useMockAuth()) {
return sendOtpMock(normalized, options)
}
try {
const res = await apiRequest<{ success: boolean; message: string; debugCode?: string }>(
'/auth/otp/request',
@@ -70,6 +210,11 @@ export async function verifyOtp(
code: string,
): Promise<{ success: boolean; message: string; user?: User }> {
const normalized = normalizePhone(phone)
if (useMockAuth()) {
return verifyOtpMock(normalized, code)
}
try {
const res = await apiRequest<{ accessToken: string; user: User }>('/auth/otp/verify', {
method: 'POST',
@@ -90,13 +235,20 @@ export async function verifyOtp(
export function logout(): void {
setAccessToken(null)
cacheUser(null)
setPendingOtp(null)
}
export async function fetchMe(): Promise<User | null> {
if (!getAccessToken()) {
const token = getAccessToken()
if (!token) {
cacheUser(null)
return null
}
if (isMockToken(token)) {
return getCachedUser()
}
try {
const user = await apiRequest<User>('/auth/me')
cacheUser(user)
@@ -110,6 +262,18 @@ export async function fetchMe(): Promise<User | null> {
export async function updateProfile(
data: Partial<Pick<User, 'firstName' | 'lastName' | 'nationalId'>>,
): Promise<User | null> {
const token = getAccessToken()
if (isMockToken(token)) {
const current = getCachedUser()
if (!current) return null
const updated = { ...current, ...data }
cacheUser(updated)
const users = readMockUsers()
users[current.phone] = updated
writeMockUsers(users)
return updated
}
try {
const user = await apiRequest<User>('/auth/profile', {
method: 'PATCH',
+53 -13
View File
@@ -31,7 +31,14 @@ type RequestOptions = {
function buildUrl(path: string, query?: RequestOptions['query']): string {
const base = getApiBaseUrl()
const url = new URL(path.startsWith('http') ? path : `${base}${path.startsWith('/') ? '' : '/'}${path}`)
const joined = path.startsWith('http')
? path
: `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
const url = joined.startsWith('http')
? new URL(joined)
: new URL(joined, typeof window !== 'undefined' ? window.location.origin : 'http://127.0.0.1')
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue
@@ -41,6 +48,31 @@ function buildUrl(path: string, query?: RequestOptions['query']): string {
return url.toString()
}
function errorMessageFromBody(data: unknown, status: number): string {
if (typeof data === 'object' && data) {
if ('message' in data) {
const message = (data as { message: unknown }).message
if (Array.isArray(message)) return message.join('، ')
if (message != null && String(message).trim()) return String(message)
}
if ('error' in data) {
const error = (data as { error: unknown }).error
if (typeof error === 'string' && error.trim()) return `${error} (${status})`
}
}
if (typeof data === 'string') {
if (/upstream error/i.test(data)) {
return 'سرور API در دسترس نیست. کمی بعد دوباره تلاش کنید.'
}
if (data.trim() && !data.trimStart().startsWith('<')) {
return data.trim().slice(0, 200)
}
}
if (status === 400) return 'درخواست نامعتبر است. اطلاعات سفارش را بررسی کنید.'
if (status === 401) return 'نشست شما منقضی شده است. دوباره وارد شوید.'
return `خطای سرور (${status})`
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const headers: Record<string, string> = {
Accept: 'application/json',
@@ -53,11 +85,25 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
if (token) headers.Authorization = `Bearer ${token}`
}
const res = await fetch(buildUrl(path, options.query), {
method: options.method ?? (options.body !== undefined ? 'POST' : 'GET'),
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
})
let res: Response
try {
res = await fetch(buildUrl(path, options.query), {
method: options.method ?? (options.body !== undefined ? 'POST' : 'GET'),
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
signal: AbortSignal.timeout(20_000),
})
} catch (err) {
const timedOut =
(err instanceof DOMException && err.name === 'TimeoutError') ||
(err instanceof Error && err.name === 'TimeoutError')
throw new ApiError(
0,
timedOut
? 'پاسخ سرور طول کشید. دوباره تلاش کنید.'
: 'اتصال به سرور برقرار نشد. اینترنت یا آدرس API را بررسی کنید.',
)
}
const text = await res.text()
let data: unknown = null
@@ -70,13 +116,7 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
}
if (!res.ok) {
const message =
typeof data === 'object' && data && 'message' in data
? Array.isArray((data as { message: unknown }).message)
? ((data as { message: string[] }).message).join('، ')
: String((data as { message: unknown }).message)
: `خطای سرور (${res.status})`
throw new ApiError(res.status, message)
throw new ApiError(res.status, errorMessageFromBody(data, res.status))
}
return data as T
+37 -4
View File
@@ -9,6 +9,11 @@ import {
updateProfile as authUpdateProfile,
fetchMe,
} from '@/services/authService'
import {
getPendingRegister,
setPendingRegister,
type PendingRegister,
} from '@/utils/pendingRegister'
import { useCartStore } from './cart'
import { useOrderStore } from './order'
import { useCatalogStore } from './catalog'
@@ -17,6 +22,7 @@ export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(getCurrentUser())
const loading = ref(false)
const error = ref<string | null>(null)
const pendingRegister = ref<PendingRegister | null>(getPendingRegister())
const isAuthenticated = computed(() => !!user.value)
const fullName = computed(() =>
@@ -27,16 +33,41 @@ export const useAuthStore = defineStore('auth', () => {
error.value = null
}
function clearPendingRegister() {
pendingRegister.value = null
setPendingRegister(null)
}
async function requestOtp(
phone: string,
options: { firstName?: string; lastName?: string; isRegister: boolean },
) {
loading.value = true
error.value = null
const result = await sendOtp(phone, options)
loading.value = false
if (!result.success) error.value = result.message
return result
try {
// Re-read storage on every call (survives HMR / refresh / separate tabs).
const stored = getPendingRegister()
pendingRegister.value = stored
const firstName = options.firstName?.trim() || stored?.firstName
const lastName = options.lastName?.trim() || stored?.lastName
if (options.isRegister && firstName && lastName) {
pendingRegister.value = { firstName, lastName }
setPendingRegister(pendingRegister.value)
}
const result = await sendOtp(phone, {
isRegister: options.isRegister,
firstName,
lastName,
})
if (!result.success) error.value = result.message
return result
} finally {
loading.value = false
}
}
async function confirmOtp(phone: string, code: string) {
@@ -46,6 +77,7 @@ export const useAuthStore = defineStore('auth', () => {
loading.value = false
if (result.success && result.user) {
user.value = result.user
clearPendingRegister()
await Promise.all([
useCatalogStore().loadCompanies(),
useCartStore().hydrate(),
@@ -60,6 +92,7 @@ export const useAuthStore = defineStore('auth', () => {
function logout() {
authLogout()
user.value = null
clearPendingRegister()
useCartStore().resetLocal()
useOrderStore().resetLocal()
}
+90 -4
View File
@@ -1,9 +1,8 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Cart, Company, Product } from '@/types'
import type { Cart, CartItem, Company, Product } from '@/types'
import { validateMeterage } from '@/utils/validation'
import { apiRequest, ApiError } from '@/services/http'
import { getAccessToken } from '@/services/http'
import { apiRequest, ApiError, getAccessToken } from '@/services/http'
import { useCatalogStore } from './catalog'
const DEFAULT_MAX_METERAGE = 5000
@@ -14,6 +13,11 @@ function emptyCart(): Cart {
return { id: 'local', companyId: '', companyName: '', items: [] }
}
function useLocalCart(): boolean {
const token = getAccessToken()
return import.meta.env.VITE_USE_MOCK_AUTH === 'true' || !!token?.startsWith('mock.')
}
function uniqueCompanyNames(items: Cart['items']): string {
const names = [...new Set(items.map((item) => item.companyName).filter(Boolean))]
return names.join('، ')
@@ -31,7 +35,9 @@ export const useCartStore = defineStore('cart', () => {
const selectedCompanyId = ref<string | null>(null)
const syncing = ref(false)
const activeCart = computed(() => carts.value.find((c) => c.id === activeCartId.value) ?? carts.value[0] ?? null)
const activeCart = computed(
() => carts.value.find((c) => c.id === activeCartId.value) ?? carts.value[0] ?? null,
)
const totalMeterage = computed(
() => activeCart.value?.items.reduce((sum, i) => sum + i.meterage, 0) ?? 0,
@@ -77,11 +83,21 @@ export const useCartStore = defineStore('cart', () => {
if (cart.companyId) selectedCompanyId.value = cart.companyId
}
function replaceActiveCart(cart: Cart) {
carts.value = [{ ...cart, items: cart.items.map((item) => ({ ...item })) }]
activeCartId.value = carts.value[0]!.id
}
async function hydrate() {
if (!getAccessToken()) {
resetLocal()
return
}
if (useLocalCart()) {
// Keep whatever local cart already exists for mock sessions.
syncing.value = false
return
}
syncing.value = true
try {
const serverCart = await apiRequest<ApiCart>('/carts')
@@ -118,6 +134,10 @@ export const useCartStore = defineStore('cart', () => {
}
async function createNewCartForSameCompany(): Promise<Cart | null> {
if (useLocalCart()) {
resetLocal()
return activeCart.value
}
try {
const serverCart = await apiRequest<ApiCart>('/carts', { method: 'DELETE' })
applyServerCart(serverCart)
@@ -128,6 +148,45 @@ export const useCartStore = defineStore('cart', () => {
}
}
function addItemLocal(
product: Product,
catalogCode: string,
meterage: number,
company: Company,
): { success: boolean; message?: string } {
const cart = getOrCreateCart(company)
const existing = cart.items.find((i) => i.productId === product.id)
const items = [...cart.items]
if (existing) {
const index = items.findIndex((i) => i.id === existing.id)
items[index] = { ...existing, meterage }
} else {
const item: CartItem = {
id: `local-item-${product.id}-${Date.now()}`,
productId: product.id,
catalogCode,
productName: product.name,
productCode: product.code,
color: product.color,
meterage,
companyId: company.id,
companyName: company.name,
}
items.push(item)
}
replaceActiveCart({
id: cart.id || 'local',
companyId: company.id,
companyName: company.name,
items,
notes: cart.notes,
})
selectedCompanyId.value = company.id
return { success: true }
}
async function addItem(
product: Product,
catalogCode: string,
@@ -155,6 +214,10 @@ export const useCartStore = defineStore('cart', () => {
return { success: false, message: validation.message }
}
if (useLocalCart()) {
return addItemLocal(product, catalogCode, meterage, company)
}
try {
let serverCart: ApiCart
if (existing) {
@@ -203,6 +266,14 @@ export const useCartStore = defineStore('cart', () => {
return { success: false, message: validation.message }
}
if (useLocalCart()) {
const items = activeCart.value.items.map((entry) =>
entry.id === itemId ? { ...entry, meterage } : entry,
)
replaceActiveCart({ ...activeCart.value, items })
return { success: true }
}
try {
const serverCart = await apiRequest<ApiCart>(`/carts/items/${itemId}`, {
method: 'PATCH',
@@ -219,6 +290,12 @@ export const useCartStore = defineStore('cart', () => {
}
async function removeItem(itemId: string) {
if (useLocalCart()) {
if (!activeCart.value) return
const items = activeCart.value.items.filter((entry) => entry.id !== itemId)
replaceActiveCart({ ...activeCart.value, items })
return
}
try {
const serverCart = await apiRequest<ApiCart>(`/carts/items/${itemId}`, {
method: 'DELETE',
@@ -230,6 +307,11 @@ export const useCartStore = defineStore('cart', () => {
}
async function setNotes(notes: string) {
if (useLocalCart()) {
if (!activeCart.value) return
replaceActiveCart({ ...activeCart.value, notes })
return
}
try {
const serverCart = await apiRequest<ApiCart>('/carts/notes', {
method: 'PATCH',
@@ -242,6 +324,10 @@ export const useCartStore = defineStore('cart', () => {
}
async function clearActiveCart() {
if (useLocalCart()) {
resetLocal()
return
}
try {
const serverCart = await apiRequest<ApiCart>('/carts', { method: 'DELETE' })
applyServerCart(serverCart)
+107 -24
View File
@@ -1,8 +1,16 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { apiRequest } from '@/services/http'
import {
bankAccounts as mockBankAccounts,
catalogs as mockCatalogs,
companies as mockCompanies,
products as mockProducts,
} from '@/data/mockData'
import type { BankAccount, Catalog, Company, Product } from '@/types'
const useMockCatalog = () => import.meta.env.VITE_USE_MOCK_AUTH === 'true'
export const useCatalogStore = defineStore('catalog', () => {
const companies = ref<Company[]>([])
const catalogs = ref<Catalog[]>([])
@@ -16,6 +24,17 @@ export const useCatalogStore = defineStore('catalog', () => {
if (loaded.value && !force) return companies.value
loading.value = true
try {
if (import.meta.env.VITE_USE_MOCK_AUTH === 'true') {
companies.value = mockCompanies
.filter((c) => c.isActive)
.sort((a, b) => a.displayOrder - b.displayOrder)
catalogs.value = mockCatalogs
products.value = mockProducts
bankAccounts.value = mockBankAccounts
loaded.value = true
return companies.value
}
const list = await apiRequest<Company[]>('/companies', {
query: { activeOnly: true },
})
@@ -23,6 +42,18 @@ export const useCatalogStore = defineStore('catalog', () => {
.filter((c) => c.isActive)
.sort((a, b) => a.displayOrder - b.displayOrder)
loaded.value = true
} catch (err) {
if (useMockCatalog()) {
companies.value = mockCompanies
.filter((c) => c.isActive)
.sort((a, b) => a.displayOrder - b.displayOrder)
catalogs.value = mockCatalogs
products.value = mockProducts
bankAccounts.value = mockBankAccounts
loaded.value = true
} else {
throw err
}
} finally {
loading.value = false
}
@@ -38,36 +69,88 @@ export const useCatalogStore = defineStore('catalog', () => {
}
async function loadCatalogs(companyId: string) {
const list = await apiRequest<Catalog[]>('/catalogs', {
query: { companyId, activeOnly: true },
})
catalogs.value = [
...catalogs.value.filter((c) => c.companyId !== companyId),
...list,
]
return list
if (import.meta.env.VITE_USE_MOCK_AUTH === 'true') {
const list = mockCatalogs.filter((c) => c.companyId === companyId && c.isActive)
catalogs.value = [
...catalogs.value.filter((c) => c.companyId !== companyId),
...list,
]
return list
}
try {
const list = await apiRequest<Catalog[]>('/catalogs', {
query: { companyId, activeOnly: true },
})
catalogs.value = [
...catalogs.value.filter((c) => c.companyId !== companyId),
...list,
]
return list
} catch (err) {
if (!useMockCatalog()) throw err
const list = mockCatalogs.filter((c) => c.companyId === companyId && c.isActive)
catalogs.value = [
...catalogs.value.filter((c) => c.companyId !== companyId),
...list,
]
return list
}
}
async function loadProducts(catalogId: string, color?: string) {
const list = await apiRequest<Product[]>('/products', {
query: {
catalogId,
color: color && color !== 'all' ? color : undefined,
activeOnly: true,
},
})
const others = products.value.filter((p) => p.catalogId !== catalogId)
products.value = [...others, ...list]
return list
if (import.meta.env.VITE_USE_MOCK_AUTH === 'true') {
let list = mockProducts.filter((p) => p.catalogId === catalogId && p.isActive)
if (color && color !== 'all') list = list.filter((p) => p.color.includes(color))
const others = products.value.filter((p) => p.catalogId !== catalogId)
products.value = [...others, ...list]
return list
}
try {
const list = await apiRequest<Product[]>('/products', {
query: {
catalogId,
color: color && color !== 'all' ? color : undefined,
activeOnly: true,
},
})
const others = products.value.filter((p) => p.catalogId !== catalogId)
products.value = [...others, ...list]
return list
} catch (err) {
if (!useMockCatalog()) throw err
let list = mockProducts.filter((p) => p.catalogId === catalogId && p.isActive)
if (color && color !== 'all') list = list.filter((p) => p.color.includes(color))
const others = products.value.filter((p) => p.catalogId !== catalogId)
products.value = [...others, ...list]
return list
}
}
async function loadBankAccounts(companyId: string) {
const list = await apiRequest<BankAccount[]>(`/companies/${companyId}/bank-accounts`)
bankAccounts.value = [
...bankAccounts.value.filter((b) => b.companyId !== companyId),
...list.filter((b) => b.isActive),
]
return bankAccounts.value.filter((b) => b.companyId === companyId)
if (import.meta.env.VITE_USE_MOCK_AUTH === 'true') {
const list = mockBankAccounts.filter((b) => b.companyId === companyId && b.isActive)
bankAccounts.value = [
...bankAccounts.value.filter((b) => b.companyId !== companyId),
...list,
]
return list
}
try {
const list = await apiRequest<BankAccount[]>(`/companies/${companyId}/bank-accounts`)
bankAccounts.value = [
...bankAccounts.value.filter((b) => b.companyId !== companyId),
...list.filter((b) => b.isActive),
]
return bankAccounts.value.filter((b) => b.companyId === companyId)
} catch (err) {
if (!useMockCatalog()) throw err
const list = mockBankAccounts.filter((b) => b.companyId === companyId && b.isActive)
bankAccounts.value = [
...bankAccounts.value.filter((b) => b.companyId !== companyId),
...list,
]
return list
}
}
function fetchCatalogs(companyId: string, query = ''): Catalog[] {
+8 -2
View File
@@ -104,21 +104,27 @@ export const useOrderStore = defineStore('order', () => {
checks?: CheckData[],
checkMode?: CheckRegistrationMode,
): Promise<Order> {
if (!cart.companyId) {
const companyId = cart.companyId || cart.items[0]?.companyId || ''
if (!companyId) {
throw new Error('شرکت سبد مشخص نیست.')
}
if (!cart.items.length) {
throw new Error('سبد خرید خالی است.')
}
if (cart.id === 'local' || cart.items.some((item) => item.id.startsWith('local-item-'))) {
throw new Error('سبد خرید با سرور همگام نیست. لطفاً دوباره محصولات را به سبد اضافه کنید.')
}
const body = {
companyId: cart.companyId,
companyId,
settlementType,
settlementMethod: settlementType,
checkMode: settlementType === 'check' ? (checkMode ?? 'full') : undefined,
notes: cart.notes,
items: cart.items.map((item) => ({
productId: item.productId,
meterage: item.meterage,
requestedMeterage: item.meterage,
notes: item.notes,
})),
checks:
+1 -1
View File
@@ -1,7 +1,7 @@
import type { OrderStatus } from '@/types'
export const DEFAULT_MAX_METERAGE = 5000
export const MOCK_OTP_CODE = '123456'
export const MOCK_OTP_CODE = '1234'
export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
cart: 'سبد خرید',
+32
View File
@@ -0,0 +1,32 @@
const PENDING_REGISTER_KEY = 'ft_pending_register'
export type PendingRegister = { firstName: string; lastName: string }
export function getPendingRegister(): PendingRegister | null {
try {
const raw = sessionStorage.getItem(PENDING_REGISTER_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as PendingRegister
if (!parsed?.firstName?.trim() || !parsed?.lastName?.trim()) return null
return {
firstName: parsed.firstName.trim(),
lastName: parsed.lastName.trim(),
}
} catch {
return null
}
}
export function setPendingRegister(value: PendingRegister | null) {
if (value?.firstName?.trim() && value?.lastName?.trim()) {
sessionStorage.setItem(
PENDING_REGISTER_KEY,
JSON.stringify({
firstName: value.firstName.trim(),
lastName: value.lastName.trim(),
}),
)
return
}
sessionStorage.removeItem(PENDING_REGISTER_KEY)
}
+58 -26
View File
@@ -6,22 +6,29 @@ import { useAuthStore } from '@/stores/auth'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
import { toPersianDigits, extractNumericString } from '@/utils/format'
import IconEditPen from '@/components/icons/IconEditPen.vue'
import { toPersianDigits, extractNumericString, normalizePhone } from '@/utils/format'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
hasFieldErrors,
type FieldErrors,
} from '@/utils/formErrors'
import { getPendingRegister } from '@/utils/pendingRegister'
const OTP_LENGTH = 6
const OTP_LENGTH = 4
const RESEND_SECONDS = 60
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const phone = computed(() => (route.query.phone as string) ?? '')
const phone = computed(() => {
const raw = route.query.phone
const value = Array.isArray(raw) ? raw[0] : raw
return normalizePhone(typeof value === 'string' ? value : '')
})
const isRegister = computed(() => route.query.mode === 'register')
const digits = ref<string[]>(Array.from({ length: OTP_LENGTH }, () => ''))
const inputRefs = ref<(HTMLInputElement | null)[]>([])
const fieldErrors = ref<FieldErrors>({})
@@ -171,7 +178,7 @@ async function submit() {
return
}
const result = await auth.confirmOtp(phone.value, code.value)
const result = await auth.confirmOtp(phone.value, extractNumericString(code.value))
if (result.success) {
router.replace('/')
} else {
@@ -184,12 +191,23 @@ async function submit() {
async function resend() {
if (!canResend.value) return
const isRegister = route.query.mode === 'register'
const result = await auth.requestOtp(phone.value, { isRegister })
formError.value = ''
fieldErrors.value = {}
const pending = isRegister.value ? getPendingRegister() : null
if (isRegister.value && !pending) {
formError.value = 'برای ارسال مجدد کد، نام و نام خانوادگی لازم است. لطفاً دوباره ثبت‌نام را شروع کنید.'
return
}
const result = await auth.requestOtp(phone.value, {
isRegister: isRegister.value,
firstName: pending?.firstName,
lastName: pending?.lastName,
})
if (result.success) {
digits.value = Array.from({ length: OTP_LENGTH }, () => '')
formError.value = ''
fieldErrors.value = {}
startTimer()
focusInput(0)
} else {
@@ -198,11 +216,14 @@ async function resend() {
}
function changePhone() {
const isRegister = route.query.mode === 'register'
router.replace({ name: isRegister ? 'register' : 'login' })
router.replace({ name: isRegister.value ? 'register' : 'login' })
}
onMounted(() => {
if (!phone.value) {
router.replace({ name: 'login' })
return
}
startTimer()
focusInput(0)
})
@@ -222,8 +243,12 @@ onMounted(() => {
<FormErrorBanner :message="formError" />
<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="کد تأیید">
<div
class="otp-row flex w-full max-w-full justify-center ltr"
dir="ltr"
role="group"
aria-label="کد تأیید"
>
<input
v-for="(digit, index) in digits"
:id="`otp-${index}`"
@@ -257,24 +282,39 @@ onMounted(() => {
<template v-if="canResend">ارسال مجدد کد</template>
<template v-else>ارسال مجدد کد ({{ timerLabel }})</template>
</BaseButton>
<BaseButton block variant="ghost" type="button" class="mt-2" @click="changePhone">
تغییر شماره موبایل
</BaseButton>
<button
type="button"
class="mt-3 mx-auto flex items-center justify-center gap-1.5 border-0 bg-transparent p-0 text-sm text-muted cursor-pointer transition-colors hover:text-text"
@click="changePhone"
>
<span class="ltr tabular-nums" dir="ltr">{{ toPersianDigits(phone) }}</span>
<span>ویرایش شماره</span>
<IconEditPen :size="18" :stroke-width="1.5" />
</button>
</form>
</div>
</div>
</template>
<style scoped>
.otp-row {
gap: clamp(0.25rem, 1.8vw, 0.625rem);
}
.otp-box {
width: 48px;
height: 52px;
flex: 1 1 0;
width: 0;
min-width: 0;
max-width: 48px;
aspect-ratio: 48 / 52;
height: auto;
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-size: clamp(0.95rem, 4.2vw, 1.25rem);
font-weight: 600;
text-align: center;
caret-color: var(--color-primary);
@@ -295,12 +335,4 @@ onMounted(() => {
font-size: 0.8125rem;
margin: 4px 0 0;
}
@media (max-width: 360px) {
.otp-box {
width: 42px;
height: 48px;
font-size: 1.125rem;
}
}
</style>
+8 -2
View File
@@ -14,6 +14,7 @@ import {
hasFieldErrors,
type FieldErrors,
} from '@/utils/formErrors'
import { setPendingRegister } from '@/utils/pendingRegister'
const router = useRouter()
const auth = useAuthStore()
@@ -52,10 +53,15 @@ async function submit() {
return
}
const result = await auth.requestOtp(normalized, {
isRegister: true,
const names = {
firstName: firstName.value.trim(),
lastName: lastName.value.trim(),
}
setPendingRegister(names)
const result = await auth.requestOtp(normalized, {
isRegister: true,
...names,
})
if (result.success) {
router.push({ name: 'otp', query: { phone: normalized, mode: 'register' } })
+30 -8
View File
@@ -7,6 +7,7 @@ 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 FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import IconSuccessCircle from '@/components/icons/IconSuccessCircle.vue'
import { toPersianDigits } from '@/utils/format'
import type { BankAccount, Order } from '@/types'
@@ -18,6 +19,8 @@ const authStore = useAuthStore()
const catalogStore = useCatalogStore()
const bankAccounts = ref<BankAccount[]>([])
const submitting = ref(false)
const formError = ref('')
const createdOrder = ref<Order | null>(null)
const companyId = computed(() => {
const firstItem = cartStore.activeCart?.items[0]
@@ -39,7 +42,7 @@ onMounted(async () => {
const expiryMessage = computed(() => {
const hour = catalogStore.getCompany(companyId.value)?.cashInvoiceValidUntilHour ?? 17
const time = toPersianDigits(String(hour).padStart(2, '0'))
return `این فاکتور تا ساعت ${time}:۰۰ امروز معتبر است.`
return `پس از ثبت، فاکتور تا ساعت ${time}:۰۰ امروز معتبر است.`
})
const accountsSectionTitle = computed(() => {
@@ -59,14 +62,17 @@ function formatIban(value: string) {
}
async function createOrder(): Promise<Order | null> {
if (createdOrder.value) return createdOrder.value
if (!authStore.user || !cartStore.activeCart || submitting.value) return null
formError.value = ''
submitting.value = true
try {
const order = await orderStore.submitOrder(authStore.user.id, cartStore.activeCart, 'cash')
createdOrder.value = order
await cartStore.hydrate()
return order
} catch (e) {
alert(e instanceof Error ? e.message : 'ثبت سفارش ناموفق بود.')
formError.value = e instanceof Error ? e.message : 'ثبت سفارش ناموفق بود.'
return null
} finally {
submitting.value = false
@@ -97,9 +103,13 @@ async function registerReceipt() {
/>
<div class="cash-page__scroll page__scroll">
<FormErrorBanner :message="formError" />
<div class="cash-page__success">
<IconSuccessCircle :size="56" />
<p class="cash-page__success-title">فاکتور شما ثبت شد.</p>
<p class="cash-page__success-title">
{{ createdOrder ? 'فاکتور شما ثبت شد.' : 'آماده ثبت فاکتور نقدی' }}
</p>
<p class="cash-page__success-subtitle">{{ expiryMessage }}</p>
</div>
@@ -128,8 +138,12 @@ async function registerReceipt() {
</div>
<div class="cash-page__footer">
<BaseButton variant="outline" block @click="viewOrder">مشاهده سفارش</BaseButton>
<BaseButton block class="cash-page__submit" @click="registerReceipt">ثبت رسید پرداخت</BaseButton>
<BaseButton variant="outline" block :loading="submitting" @click="viewOrder">
ثبت و مشاهده سفارش
</BaseButton>
<BaseButton block class="cash-page__submit" :loading="submitting" @click="registerReceipt">
ثبت سفارش و رسید پرداخت
</BaseButton>
</div>
</div>
@@ -142,9 +156,13 @@ async function registerReceipt() {
<div class="cash-page__success">
<IconSuccessCircle :size="56" />
<p class="cash-page__success-title">فاکتور شما ثبت شد.</p>
<p class="cash-page__success-title">
{{ createdOrder ? 'فاکتور شما ثبت شد.' : 'آماده ثبت فاکتور نقدی' }}
</p>
</div>
<FormErrorBanner :message="formError" />
<section class="accounts-panel">
<h2 class="accounts-panel__title">{{ accountsSectionTitle }}</h2>
<div class="desktop-grid-2">
@@ -166,8 +184,12 @@ async function registerReceipt() {
</section>
<div class="cash-desktop-page__actions">
<BaseButton variant="outline" class="desktop-btn-compact" @click="viewOrder">مشاهده سفارش</BaseButton>
<BaseButton class="desktop-btn-compact" @click="registerReceipt">ثبت رسید پرداخت</BaseButton>
<BaseButton variant="outline" class="desktop-btn-compact" :loading="submitting" @click="viewOrder">
ثبت و مشاهده سفارش
</BaseButton>
<BaseButton class="desktop-btn-compact" :loading="submitting" @click="registerReceipt">
ثبت سفارش و رسید پرداخت
</BaseButton>
</div>
</div>
</template>
+7 -2
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCartStore } from '@/stores/cart'
import { useOrderStore } from '@/stores/order'
import { useAuthStore } from '@/stores/auth'
import BaseButton from '@/components/ui/BaseButton.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import { formatCurrency, formatDate, formatNumber, toPersianDigits } from '@/utils/format'
@@ -17,6 +18,7 @@ const router = useRouter()
const cartStore = useCartStore()
const orderStore = useOrderStore()
const authStore = useAuthStore()
const formError = ref('')
const isCheckout = computed(() => route.name === 'check-list')
@@ -88,6 +90,7 @@ function toggleMenu(id: string) {
async function submitOrder() {
if (!authStore.user || !cartStore.activeCart || checks.value.length === 0) return
formError.value = ''
try {
const order = await orderStore.submitOrder(
@@ -100,7 +103,7 @@ async function submitOrder() {
await cartStore.hydrate()
router.replace(`/orders/${order.id}/success`)
} catch (e) {
alert(e instanceof Error ? e.message : 'ثبت سفارش ناموفق بود.')
formError.value = e instanceof Error ? e.message : 'ثبت سفارش ناموفق بود.'
}
}
</script>
@@ -127,6 +130,8 @@ async function submitOrder() {
<template v-else>
<div class="check-list-page__scroll page__scroll">
<FormErrorBanner :message="formError" />
<div class="summary-cards">
<div class="summary-card">
<span class="summary-card__label">مجموع مبلغ چک‌ها</span>
@@ -126,7 +126,7 @@ const emit = defineEmits<{
}
.product-mobile-page__scroll {
padding-bottom: 76px;
padding-bottom: 96px;
display: flex;
flex-direction: column;
flex: 1;