Wire dashboards to the API and add customer portal plus discounts.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
af11d96fd0
commit
b103e330ef
@@ -117,3 +117,47 @@ export async function apiRequest<T>(
|
||||
|
||||
return payload as T
|
||||
}
|
||||
|
||||
type UploadOptions = {
|
||||
auth?: boolean
|
||||
skipRefresh?: boolean
|
||||
}
|
||||
|
||||
/** Multipart upload helper (do not set Content-Type; browser sets boundary). */
|
||||
export async function apiUpload<T>(
|
||||
path: string,
|
||||
formData: FormData,
|
||||
options: UploadOptions = {},
|
||||
): Promise<T> {
|
||||
const { auth = true, skipRefresh = false } = options
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (auth) {
|
||||
const token = getAccessToken()
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (response.status === 401 && auth && !skipRefresh) {
|
||||
const refreshed = await tryRefreshSession()
|
||||
if (refreshed) {
|
||||
return apiUpload<T>(path, formData, { ...options, skipRefresh: true })
|
||||
}
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
parseErrorMessage(payload, 'خطایی رخ داد'),
|
||||
)
|
||||
}
|
||||
|
||||
return payload as T
|
||||
}
|
||||
|
||||
+88
-5
@@ -1,6 +1,7 @@
|
||||
import type { UserRole } from '../data/users'
|
||||
|
||||
const AUTH_KEY = 'balout.admin.auth'
|
||||
const AUTH_COOKIE = 'balout.auth'
|
||||
const LEGACY_AUTH_KEY = 'balout.admin.auth'
|
||||
|
||||
export type AuthUser = {
|
||||
id: string
|
||||
@@ -31,9 +32,57 @@ function isSession(value: unknown): value is AuthSession {
|
||||
)
|
||||
}
|
||||
|
||||
export function getSession(): AuthSession | null {
|
||||
function cookieDomain() {
|
||||
const host = window.location.hostname
|
||||
// Browsers reject Domain=.baloutpastry.com when the page is on localhost.
|
||||
if (host === 'localhost' || host === '127.0.0.1') return undefined
|
||||
const configured = import.meta.env.VITE_COOKIE_DOMAIN as string | undefined
|
||||
if (configured) return configured
|
||||
if (host.endsWith('.baloutpastry.com') || host === 'baloutpastry.com') {
|
||||
return '.baloutpastry.com'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readCookie(name: string) {
|
||||
const prefix = `${encodeURIComponent(name)}=`
|
||||
const parts = document.cookie.split(';')
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim()
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return decodeURIComponent(trimmed.slice(prefix.length))
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function writeCookie(name: string, value: string, maxAgeSeconds: number) {
|
||||
const domain = cookieDomain()
|
||||
const parts = [
|
||||
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
|
||||
'Path=/',
|
||||
`Max-Age=${maxAgeSeconds}`,
|
||||
'SameSite=Lax',
|
||||
]
|
||||
if (domain) parts.push(`Domain=${domain}`)
|
||||
document.cookie = parts.join('; ')
|
||||
}
|
||||
|
||||
function clearCookie(name: string) {
|
||||
const domain = cookieDomain()
|
||||
const parts = [
|
||||
`${encodeURIComponent(name)}=`,
|
||||
'Path=/',
|
||||
'Max-Age=0',
|
||||
'SameSite=Lax',
|
||||
]
|
||||
if (domain) parts.push(`Domain=${domain}`)
|
||||
document.cookie = parts.join('; ')
|
||||
}
|
||||
|
||||
function readLegacySession(): AuthSession | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(AUTH_KEY)
|
||||
const raw = localStorage.getItem(LEGACY_AUTH_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return isSession(parsed) ? parsed : null
|
||||
@@ -42,12 +91,36 @@ export function getSession(): AuthSession | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function getSession(): AuthSession | null {
|
||||
try {
|
||||
const raw = readCookie(AUTH_COOKIE)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (isSession(parsed)) return parsed
|
||||
}
|
||||
|
||||
// Migrate older localStorage sessions once.
|
||||
const legacy = readLegacySession()
|
||||
if (legacy) {
|
||||
setSession(legacy)
|
||||
localStorage.removeItem(LEGACY_AUTH_KEY)
|
||||
return legacy
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setSession(session: AuthSession) {
|
||||
localStorage.setItem(AUTH_KEY, JSON.stringify(session))
|
||||
// ~7 days, aligned with refresh token lifetime
|
||||
writeCookie(AUTH_COOKIE, JSON.stringify(session), 60 * 60 * 24 * 7)
|
||||
localStorage.removeItem(LEGACY_AUTH_KEY)
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
localStorage.removeItem(AUTH_KEY)
|
||||
clearCookie(AUTH_COOKIE)
|
||||
localStorage.removeItem(LEGACY_AUTH_KEY)
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
@@ -65,3 +138,13 @@ export function getRefreshToken(): string | null {
|
||||
export function getAuthUser(): AuthUser | null {
|
||||
return getSession()?.user ?? null
|
||||
}
|
||||
|
||||
export function patchSessionUser(user: AuthUser) {
|
||||
const session = getSession()
|
||||
if (!session) return
|
||||
setSession({ ...session, user })
|
||||
}
|
||||
|
||||
export function isElevatedRole(role: UserRole | undefined | null) {
|
||||
return role === 'admin' || role === 'superAdmin'
|
||||
}
|
||||
|
||||
+84
-6
@@ -7,12 +7,13 @@ export type AuthTokensResponse = {
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
export async function login(cellNumber: string, password: string) {
|
||||
const data = await apiRequest<AuthTokensResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { cellNumber, password },
|
||||
})
|
||||
export type OtpSendCodeResponse = {
|
||||
ok: true
|
||||
expiresInSeconds: number
|
||||
resendAfterSeconds: number
|
||||
}
|
||||
|
||||
function setAuthSession(data: AuthTokensResponse) {
|
||||
setSession({
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
@@ -21,6 +22,83 @@ export async function login(cellNumber: string, password: string) {
|
||||
return data.user
|
||||
}
|
||||
|
||||
export async function login(cellNumber: string, password: string) {
|
||||
const data = await apiRequest<AuthTokensResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { cellNumber, password },
|
||||
})
|
||||
return setAuthSession(data)
|
||||
}
|
||||
|
||||
export async function loginSendCode(cellNumber: string) {
|
||||
return apiRequest<OtpSendCodeResponse>('/auth/login/send-code', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { cellNumber },
|
||||
})
|
||||
}
|
||||
|
||||
export async function loginVerify(cellNumber: string, code: string) {
|
||||
const data = await apiRequest<AuthTokensResponse>('/auth/login/verify', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { cellNumber, code },
|
||||
})
|
||||
return setAuthSession(data)
|
||||
}
|
||||
|
||||
export async function forgotPasswordSendCode(cellNumber: string) {
|
||||
return apiRequest<OtpSendCodeResponse>('/auth/forgot-password/send-code', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { cellNumber },
|
||||
})
|
||||
}
|
||||
|
||||
export async function forgotPasswordVerify(cellNumber: string, code: string) {
|
||||
return apiRequest<{ ok: true }>('/auth/forgot-password/verify', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { cellNumber, code },
|
||||
})
|
||||
}
|
||||
|
||||
export async function forgotPasswordReset(input: {
|
||||
cellNumber: string
|
||||
code: string
|
||||
newPassword: string
|
||||
}) {
|
||||
const data = await apiRequest<AuthTokensResponse>('/auth/forgot-password/reset', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: input,
|
||||
})
|
||||
return setAuthSession(data)
|
||||
}
|
||||
|
||||
export async function registerSendCode(input: {
|
||||
firstName: string
|
||||
lastName: string
|
||||
cellNumber: string
|
||||
password: string
|
||||
}) {
|
||||
return apiRequest<OtpSendCodeResponse>('/auth/register/send-code', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function registerVerify(cellNumber: string, code: string) {
|
||||
const data = await apiRequest<AuthTokensResponse>('/auth/register/verify', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { cellNumber, code },
|
||||
})
|
||||
return setAuthSession(data)
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
const refreshToken = getRefreshToken()
|
||||
try {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
export type BranchRecord = {
|
||||
id: string
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline: string
|
||||
cellNumber: string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'balout-branches'
|
||||
|
||||
const seedBranches: BranchRecord[] = [
|
||||
{
|
||||
id: 'branch-1',
|
||||
name: 'شعبه مرکزی',
|
||||
district: 'حرم',
|
||||
address: 'قم، خیابان ارم، نبش کوچه ۳',
|
||||
landline: '02531230001',
|
||||
cellNumber: '09120000001',
|
||||
},
|
||||
{
|
||||
id: 'branch-2',
|
||||
name: 'شعبه صفاییه',
|
||||
district: 'صفاییه',
|
||||
address: 'قم، صفاییه، بلوار شهید بهشتی',
|
||||
landline: '02531230002',
|
||||
cellNumber: '09120000002',
|
||||
},
|
||||
{
|
||||
id: 'branch-3',
|
||||
name: 'شعبه نیروی هوایی',
|
||||
district: 'نیروگاه',
|
||||
address: 'قم، شهرک نیروی هوایی، فاز ۲',
|
||||
landline: '02531230003',
|
||||
cellNumber: '09120000003',
|
||||
},
|
||||
]
|
||||
|
||||
function readBranches(): BranchRecord[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(seedBranches))
|
||||
return seedBranches
|
||||
}
|
||||
const parsed = JSON.parse(raw) as BranchRecord[]
|
||||
return parsed.length > 0 ? parsed : seedBranches
|
||||
} catch {
|
||||
return seedBranches
|
||||
}
|
||||
}
|
||||
|
||||
export function getBranches(): BranchRecord[] {
|
||||
return readBranches().filter((branch) => branch.name.trim())
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Category } from '../data/categories'
|
||||
import type { Flavor } from '../data/flavors'
|
||||
import { apiRequest } from './api'
|
||||
|
||||
export type CategoryOptionEntry = {
|
||||
id: string
|
||||
amount: string
|
||||
price: number
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export type CategoryOptionBlock = {
|
||||
id: string
|
||||
categoryId: string
|
||||
flavorId: string
|
||||
sortOrder: number
|
||||
flavor: Flavor
|
||||
entries: CategoryOptionEntry[]
|
||||
}
|
||||
|
||||
export type CreateCategoryPayload = {
|
||||
nameFa: string
|
||||
nameEn: string
|
||||
parentId?: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export type UpdateCategoryPayload = {
|
||||
nameFa?: string
|
||||
nameEn?: string
|
||||
parentId?: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export type ReplaceCategoryOptionsPayload = {
|
||||
blocks: {
|
||||
flavorId: string
|
||||
sortOrder?: number
|
||||
entries: { amount: string; price: number }[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export function listCategories() {
|
||||
return apiRequest<Category[]>('/categories')
|
||||
}
|
||||
|
||||
export function createCategory(payload: CreateCategoryPayload) {
|
||||
return apiRequest<Category>('/categories', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCategory(id: string, payload: UpdateCategoryPayload) {
|
||||
return apiRequest<Category>(`/categories/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCategory(id: string) {
|
||||
return apiRequest<{ ok: true }>(`/categories/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function getCategoryOptions(categoryId: string) {
|
||||
return apiRequest<CategoryOptionBlock[]>(`/categories/${categoryId}/options`)
|
||||
}
|
||||
|
||||
export function replaceCategoryOptions(
|
||||
categoryId: string,
|
||||
payload: ReplaceCategoryOptionsPayload,
|
||||
) {
|
||||
return apiRequest<CategoryOptionBlock[]>(
|
||||
`/categories/${categoryId}/options`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { apiRequest } from './api'
|
||||
|
||||
export type DiscountUser = {
|
||||
id: string
|
||||
title: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
cellNumber: string
|
||||
role: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type DiscountCategory = {
|
||||
id: string
|
||||
nameFa: string
|
||||
nameEn: string
|
||||
}
|
||||
|
||||
export type Discount = {
|
||||
id: string
|
||||
code: string
|
||||
categoryId: string | null
|
||||
category: DiscountCategory | null
|
||||
userId: string | null
|
||||
user: DiscountUser | null
|
||||
minOrderAmount: number
|
||||
expiresAt: string
|
||||
percent: number
|
||||
maxValue: number
|
||||
active: boolean
|
||||
createdByAdminId: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
expired: boolean
|
||||
general: boolean
|
||||
}
|
||||
|
||||
export type DiscountListResponse = {
|
||||
items: Discount[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type ListDiscountsParams = {
|
||||
q?: string
|
||||
code?: string
|
||||
expiresOn?: string
|
||||
user?: string
|
||||
userId?: string
|
||||
generalOnly?: boolean
|
||||
active?: boolean
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export type CreateDiscountPayload = {
|
||||
code: string
|
||||
userId?: string | null
|
||||
categoryId?: string | null
|
||||
minOrderAmount: number
|
||||
expiresAt: string
|
||||
percent: number
|
||||
maxValue: number
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export type UpdateDiscountPayload = Partial<CreateDiscountPayload>
|
||||
|
||||
function buildQuery(params: ListDiscountsParams) {
|
||||
const search = new URLSearchParams()
|
||||
if (params.q?.trim()) search.set('q', params.q.trim())
|
||||
if (params.code?.trim()) search.set('code', params.code.trim())
|
||||
if (params.expiresOn) search.set('expiresOn', params.expiresOn)
|
||||
if (params.user?.trim()) search.set('user', params.user.trim())
|
||||
if (params.userId) search.set('userId', params.userId)
|
||||
if (params.generalOnly !== undefined) {
|
||||
search.set('generalOnly', String(params.generalOnly))
|
||||
}
|
||||
if (params.active !== undefined) search.set('active', String(params.active))
|
||||
if (params.page) search.set('page', String(params.page))
|
||||
if (params.pageSize) search.set('pageSize', String(params.pageSize))
|
||||
const qs = search.toString()
|
||||
return qs ? `?${qs}` : ''
|
||||
}
|
||||
|
||||
export function listDiscounts(params: ListDiscountsParams = {}) {
|
||||
return apiRequest<DiscountListResponse>(`/discounts${buildQuery(params)}`)
|
||||
}
|
||||
|
||||
export function listMyDiscounts(params: ListDiscountsParams = {}) {
|
||||
return apiRequest<DiscountListResponse>(
|
||||
`/discounts/mine${buildQuery(params)}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function createDiscount(payload: CreateDiscountPayload) {
|
||||
return apiRequest<Discount>('/discounts', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateDiscount(id: string, payload: UpdateDiscountPayload) {
|
||||
return apiRequest<Discount>(`/discounts/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteDiscount(id: string) {
|
||||
return apiRequest<{ ok: true }>(`/discounts/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Flavor } from '../data/flavors'
|
||||
import { apiRequest } from './api'
|
||||
|
||||
export function listFlavors() {
|
||||
return apiRequest<Flavor[]>('/flavors')
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export type AppKind = 'admin' | 'customer'
|
||||
|
||||
function stripPort(host: string) {
|
||||
return host.split(':')[0]?.toLowerCase() ?? host.toLowerCase()
|
||||
}
|
||||
|
||||
export function getAdminHost() {
|
||||
return (
|
||||
(import.meta.env.VITE_ADMIN_HOST as string | undefined) ??
|
||||
'admin.baloutpastry.com'
|
||||
)
|
||||
}
|
||||
|
||||
export function getCustomerHost() {
|
||||
return (
|
||||
(import.meta.env.VITE_CUSTOMER_HOST as string | undefined) ??
|
||||
'customer.baloutpastry.com'
|
||||
)
|
||||
}
|
||||
|
||||
export function getHostname() {
|
||||
return stripPort(window.location.hostname)
|
||||
}
|
||||
|
||||
export function isLocalDevHost(hostname = getHostname()) {
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1'
|
||||
}
|
||||
|
||||
/** Only the admin host is admin; localhost and other hosts use customer (single login). */
|
||||
export function getAppKind(hostname = getHostname()): AppKind {
|
||||
if (hostname === getAdminHost()) return 'admin'
|
||||
return 'customer'
|
||||
}
|
||||
|
||||
export function isApexHost(hostname = getHostname()) {
|
||||
return hostname === 'baloutpastry.com'
|
||||
}
|
||||
|
||||
export function buildAppOrigin(kind: AppKind) {
|
||||
const host = kind === 'admin' ? getAdminHost() : getCustomerHost()
|
||||
const { protocol, port } = window.location
|
||||
const portPart = port ? `:${port}` : ''
|
||||
return `${protocol}//${host}${portPart}`
|
||||
}
|
||||
|
||||
export function redirectToApp(kind: AppKind, path = '/') {
|
||||
const target = `${buildAppOrigin(kind)}${path.startsWith('/') ? path : `/${path}`}`
|
||||
window.location.replace(target)
|
||||
}
|
||||
|
||||
/** Public storefront home (Website). */
|
||||
export function getWebsiteOrigin() {
|
||||
return (
|
||||
(import.meta.env.VITE_WEBSITE_URL as string | undefined)?.replace(/\/$/, '') ||
|
||||
'http://baloutpastry.com:5174'
|
||||
)
|
||||
}
|
||||
|
||||
export function redirectToWebsite(path = '/') {
|
||||
const base = getWebsiteOrigin()
|
||||
const target = `${base}${path.startsWith('/') ? path : `/${path}`}`
|
||||
window.location.replace(target)
|
||||
}
|
||||
|
||||
/** Single login entry — always on the customer app. */
|
||||
export function redirectToLogin() {
|
||||
redirectToApp('customer', '/login')
|
||||
}
|
||||
|
||||
export function redirectApexToCustomer() {
|
||||
redirectToApp('customer', '/')
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { apiUpload } from './api'
|
||||
|
||||
export type MediaKind = 'main' | 'gallery' | 'temp'
|
||||
|
||||
export type MediaUploadResult = {
|
||||
url: string
|
||||
storageKey: string
|
||||
storageDisk: string
|
||||
}
|
||||
|
||||
export function uploadMedia(kind: MediaKind, file: Blob | File, fileName?: string) {
|
||||
const formData = new FormData()
|
||||
const name =
|
||||
fileName ??
|
||||
(file instanceof File && file.name ? file.name : `upload-${kind}.jpg`)
|
||||
formData.append('file', file, name)
|
||||
return apiUpload<MediaUploadResult>(`/media/upload?kind=${kind}`, formData)
|
||||
}
|
||||
|
||||
export async function dataUrlToBlob(dataUrl: string): Promise<Blob> {
|
||||
const response = await fetch(dataUrl)
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
export function isLocalImageSrc(src: string) {
|
||||
return (
|
||||
src.startsWith('data:') ||
|
||||
src.startsWith('blob:') ||
|
||||
src.startsWith('file:')
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
CakeSlice,
|
||||
FolderTree,
|
||||
MapPin,
|
||||
Package,
|
||||
Percent,
|
||||
PlusCircle,
|
||||
Settings,
|
||||
UserRound,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
@@ -93,6 +96,15 @@ export const navSections: NavSection[] = [
|
||||
icon: Package,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'discounts',
|
||||
title: 'کدهای تخفیف',
|
||||
titleEn: 'Discounts',
|
||||
description: 'تعریف و مدیریت کد تخفیف کاربران',
|
||||
href: '/discounts',
|
||||
icon: Percent,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: 'تنظیمات',
|
||||
@@ -103,3 +115,52 @@ export const navSections: NavSection[] = [
|
||||
items: [],
|
||||
},
|
||||
]
|
||||
|
||||
/** Header links — settings & discounts stay on home tiles only. */
|
||||
export const adminHeaderSections: NavSection[] = navSections.filter(
|
||||
(section) => section.id !== 'settings' && section.id !== 'discounts',
|
||||
)
|
||||
|
||||
export const customerNavSections: NavSection[] = [
|
||||
{
|
||||
id: 'profile',
|
||||
title: 'پروفایل من',
|
||||
titleEn: 'Profile',
|
||||
description: 'مشاهده و ویرایش اطلاعات حساب کاربری',
|
||||
href: '/profile',
|
||||
icon: UserRound,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'orders',
|
||||
title: 'سفارشهای من',
|
||||
titleEn: 'My Orders',
|
||||
description: 'پیگیری سفارشهای ثبتشده',
|
||||
href: '/orders',
|
||||
icon: Package,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'discounts',
|
||||
title: 'تخفیفهای من',
|
||||
titleEn: 'Discounts',
|
||||
description: 'کدها و پیشنهادهای تخفیف شما',
|
||||
href: '/discounts',
|
||||
icon: Percent,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'addresses',
|
||||
title: 'آدرسهای من',
|
||||
titleEn: 'Addresses',
|
||||
description: 'مدیریت آدرسهای ارسال',
|
||||
href: '/addresses',
|
||||
icon: MapPin,
|
||||
items: [],
|
||||
},
|
||||
]
|
||||
|
||||
/** Header links only — profile lives under the name menu. */
|
||||
export const customerHeaderSections: NavSection[] = customerNavSections.filter(
|
||||
(section) => section.id !== 'profile',
|
||||
)
|
||||
|
||||
+42
-67
@@ -1,7 +1,8 @@
|
||||
import { products, type Product } from '../data/products'
|
||||
import { users, formatCellNumber, type User } from '../data/users'
|
||||
import { flavors } from '../data/flavors'
|
||||
import { getSelectableCategoryOptions } from './categoryOptionsStore'
|
||||
import { listProducts } from './productsApi'
|
||||
import { listUsers } from './usersApi'
|
||||
import { formatCellNumber } from '../data/users'
|
||||
import type { Product } from '../data/products'
|
||||
import { ApiError } from './api'
|
||||
|
||||
export type SearchUserResult = {
|
||||
id: string
|
||||
@@ -16,76 +17,50 @@ export type ProductOptionChoice = {
|
||||
price: number
|
||||
}
|
||||
|
||||
function delay(ms = 280) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/** Simulated API user search (`GET /users?q=`). */
|
||||
/** User search via `GET /users?q=`. Falls back to empty on error. */
|
||||
export async function searchUsers(query: string): Promise<SearchUserResult[]> {
|
||||
await delay()
|
||||
const q = query.trim().toLowerCase()
|
||||
const digits = q.replace(/\D/g, '')
|
||||
|
||||
return users
|
||||
.filter((user) => !user.disabled)
|
||||
.filter((user) => {
|
||||
if (!q) return true
|
||||
const fullName = `${user.firstName} ${user.lastName}`
|
||||
return (
|
||||
fullName.includes(query.trim()) ||
|
||||
user.firstName.includes(query.trim()) ||
|
||||
user.lastName.includes(query.trim()) ||
|
||||
(digits.length > 0 && user.cellNumber.includes(digits))
|
||||
)
|
||||
try {
|
||||
const result = await listUsers({
|
||||
q: query.trim() || undefined,
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
})
|
||||
.slice(0, 12)
|
||||
.map((user) => ({
|
||||
id: user.id,
|
||||
name: `${user.firstName} ${user.lastName}`,
|
||||
phone: user.cellNumber,
|
||||
phoneDisplay: formatCellNumber(user.cellNumber),
|
||||
}))
|
||||
return result.items
|
||||
.filter((user) => !user.disabled)
|
||||
.map((user) => ({
|
||||
id: user.id,
|
||||
name: `${user.firstName} ${user.lastName}`,
|
||||
phone: user.cellNumber,
|
||||
phoneDisplay: formatCellNumber(user.cellNumber),
|
||||
}))
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) return []
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulated API product search (`GET /products?q=`). */
|
||||
/** Product search via `GET /products?q=`. */
|
||||
export async function searchProducts(query: string): Promise<Product[]> {
|
||||
await delay()
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return products.slice(0, 12)
|
||||
|
||||
return products
|
||||
.filter(
|
||||
(product) =>
|
||||
product.nameFa.includes(query.trim()) ||
|
||||
product.nameEn.toLowerCase().includes(q) ||
|
||||
product.category.includes(query.trim()),
|
||||
)
|
||||
.slice(0, 12)
|
||||
}
|
||||
|
||||
function flavorLabel(flavorId: string) {
|
||||
return flavors.find((item) => item.id === flavorId)?.nameFa ?? flavorId
|
||||
try {
|
||||
const result = await listProducts({
|
||||
q: query.trim() || undefined,
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
})
|
||||
return result.items.map((item) => ({
|
||||
...item,
|
||||
options: item.options ?? [],
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Options available when adding a product to an order. */
|
||||
export function getProductOrderOptions(product: Product): ProductOptionChoice[] {
|
||||
if (product.options.length > 0) {
|
||||
return product.options.map((option) => ({
|
||||
id: option.id,
|
||||
name: `${flavorLabel(option.flavorId)} — ${option.amount}`,
|
||||
price: option.price,
|
||||
}))
|
||||
}
|
||||
|
||||
return getSelectableCategoryOptions(product.category).flatMap((block) =>
|
||||
block.entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
name: `${flavorLabel(block.flavorId)} — ${entry.amount}`,
|
||||
price: entry.price,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
export function findUserById(id: string): User | undefined {
|
||||
return users.find((user) => user.id === id)
|
||||
return (product.options ?? []).map((option) => ({
|
||||
id: option.id,
|
||||
name: `${option.flavor?.nameFa ?? option.flavorId} — ${option.amount}`,
|
||||
price: option.price,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Order, OrderStatus } from '../data/orders'
|
||||
import { apiRequest } from './api'
|
||||
|
||||
export type OrderListResponse = {
|
||||
items: Order[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type ListOrdersParams = {
|
||||
q?: string
|
||||
status?: OrderStatus | ''
|
||||
customerId?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export type CreateOrderItemPayload = {
|
||||
productId: string
|
||||
quantity: number
|
||||
optionIds?: string[]
|
||||
}
|
||||
|
||||
export type CreateOrderPayload = {
|
||||
customerId: string
|
||||
deliveryType: 'pickup' | 'shipping'
|
||||
branchId?: string
|
||||
shippingAddressId?: string
|
||||
note?: string
|
||||
items: CreateOrderItemPayload[]
|
||||
}
|
||||
|
||||
function buildQuery(params: ListOrdersParams) {
|
||||
const search = new URLSearchParams()
|
||||
if (params.q?.trim()) search.set('q', params.q.trim())
|
||||
if (params.status) search.set('status', params.status)
|
||||
if (params.customerId) search.set('customerId', params.customerId)
|
||||
if (params.page) search.set('page', String(params.page))
|
||||
if (params.pageSize) search.set('pageSize', String(params.pageSize))
|
||||
const qs = search.toString()
|
||||
return qs ? `?${qs}` : ''
|
||||
}
|
||||
|
||||
export function listOrders(params: ListOrdersParams = {}) {
|
||||
return apiRequest<OrderListResponse>(`/orders${buildQuery(params)}`)
|
||||
}
|
||||
|
||||
export function listMyOrders(params: ListOrdersParams = {}) {
|
||||
return apiRequest<OrderListResponse>(`/orders/mine${buildQuery(params)}`)
|
||||
}
|
||||
|
||||
export function getOrder(id: string) {
|
||||
return apiRequest<Order>(`/orders/${id}`)
|
||||
}
|
||||
|
||||
export function getMyOrder(id: string) {
|
||||
return apiRequest<Order>(`/orders/mine/${id}`)
|
||||
}
|
||||
|
||||
export function createOrder(payload: CreateOrderPayload) {
|
||||
return apiRequest<Order>('/orders', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateOrderStatus(id: string, status: OrderStatus) {
|
||||
return apiRequest<Order>(`/orders/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
body: { status },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
Product,
|
||||
ProductGalleryImage,
|
||||
ProductOptionValue,
|
||||
SellUnit,
|
||||
} from '../data/products'
|
||||
import { apiRequest } from './api'
|
||||
|
||||
export type ProductListResponse = {
|
||||
items: Product[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type ListProductsParams = {
|
||||
q?: string
|
||||
categoryId?: string
|
||||
minPrice?: number
|
||||
maxPrice?: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export type ProductOptionPayload = {
|
||||
flavorId: string
|
||||
amount: string
|
||||
price: number
|
||||
}
|
||||
|
||||
export type ProductGalleryPayload = {
|
||||
url: string
|
||||
storageKey: string
|
||||
}
|
||||
|
||||
export type CreateProductPayload = {
|
||||
nameFa: string
|
||||
nameEn: string
|
||||
categoryId: string
|
||||
price: number
|
||||
sellUnit: SellUnit
|
||||
intro?: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
mainImageUrl?: string
|
||||
mainImageKey?: string
|
||||
gallery?: ProductGalleryPayload[]
|
||||
options?: ProductOptionPayload[]
|
||||
}
|
||||
|
||||
export type UpdateProductPayload = Partial<CreateProductPayload>
|
||||
|
||||
function buildQuery(params: ListProductsParams) {
|
||||
const search = new URLSearchParams()
|
||||
if (params.q?.trim()) search.set('q', params.q.trim())
|
||||
if (params.categoryId) search.set('categoryId', params.categoryId)
|
||||
if (params.minPrice !== undefined) search.set('minPrice', String(params.minPrice))
|
||||
if (params.maxPrice !== undefined) search.set('maxPrice', String(params.maxPrice))
|
||||
if (params.page) search.set('page', String(params.page))
|
||||
if (params.pageSize) search.set('pageSize', String(params.pageSize))
|
||||
const qs = search.toString()
|
||||
return qs ? `?${qs}` : ''
|
||||
}
|
||||
|
||||
export function listProducts(params: ListProductsParams = {}) {
|
||||
return apiRequest<ProductListResponse>(`/products${buildQuery(params)}`)
|
||||
}
|
||||
|
||||
export function getProduct(id: string) {
|
||||
return apiRequest<Product>(`/products/${id}`)
|
||||
}
|
||||
|
||||
export function createProduct(payload: CreateProductPayload) {
|
||||
return apiRequest<Product>('/products', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateProduct(id: string, payload: UpdateProductPayload) {
|
||||
return apiRequest<Product>(`/products/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteProduct(id: string) {
|
||||
return apiRequest<{ ok: true }>(`/products/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function toOptionPayload(
|
||||
options: ProductOptionValue[],
|
||||
): ProductOptionPayload[] {
|
||||
return options.map(({ flavorId, amount, price }) => ({
|
||||
flavorId,
|
||||
amount,
|
||||
price,
|
||||
}))
|
||||
}
|
||||
|
||||
export function toGalleryPayload(
|
||||
gallery: ProductGalleryImage[],
|
||||
): ProductGalleryPayload[] {
|
||||
return gallery.map(({ url, storageKey }) => ({ url, storageKey }))
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { apiRequest } from './api'
|
||||
|
||||
export type Branch = {
|
||||
id: string
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline: string
|
||||
cellNumber: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type ShippingException = {
|
||||
id: string
|
||||
district: string
|
||||
price: number
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type CreateBranchPayload = {
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline?: string
|
||||
cellNumber?: string
|
||||
}
|
||||
|
||||
export type UpdateBranchPayload = Partial<CreateBranchPayload>
|
||||
|
||||
export type ReplaceShippingPayload = {
|
||||
exceptions: { district: string; price: number }[]
|
||||
}
|
||||
|
||||
export function listDistricts() {
|
||||
return apiRequest<string[]>('/settings/districts')
|
||||
}
|
||||
|
||||
export function listShippingExceptions() {
|
||||
return apiRequest<ShippingException[]>('/settings/shipping')
|
||||
}
|
||||
|
||||
export function replaceShippingExceptions(payload: ReplaceShippingPayload) {
|
||||
return apiRequest<ShippingException[]>('/settings/shipping', {
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function listBranches() {
|
||||
return apiRequest<Branch[]>('/settings/branches')
|
||||
}
|
||||
|
||||
export function createBranch(payload: CreateBranchPayload) {
|
||||
return apiRequest<Branch>('/settings/branches', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateBranch(id: string, payload: UpdateBranchPayload) {
|
||||
return apiRequest<Branch>(`/settings/branches/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function removeBranch(id: string) {
|
||||
return apiRequest<{ ok: true }>(`/settings/branches/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import type { ShippingAddress } from '../data/orders'
|
||||
|
||||
const STORAGE_KEY = 'balout-user-addresses'
|
||||
|
||||
type AddressMap = Record<string, ShippingAddress[]>
|
||||
|
||||
function uid() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
}
|
||||
|
||||
function seedForUser(userId: string): ShippingAddress[] {
|
||||
const seeds: Record<string, ShippingAddress[]> = {
|
||||
'1': [
|
||||
{
|
||||
id: 'u1-a1',
|
||||
name: 'منزل',
|
||||
district: 'خیابان ارم',
|
||||
address: 'قم، خیابان ارم، کوچه ۵، پلاک ۱۲',
|
||||
landline: '02531234567',
|
||||
},
|
||||
{
|
||||
id: 'u1-a2',
|
||||
name: 'مطب',
|
||||
district: 'بلوار امین',
|
||||
address: 'قم، بلوار امین، ساختمان پزشکان، طبقه ۳',
|
||||
landline: '02537654321',
|
||||
},
|
||||
],
|
||||
'2': [
|
||||
{
|
||||
id: 'u2-a1',
|
||||
name: 'منزل',
|
||||
district: 'صفاییه',
|
||||
address: 'قم، صفاییه، خیابان شهید بهشتی، پلاک ۴۵',
|
||||
landline: '02531112233',
|
||||
},
|
||||
],
|
||||
}
|
||||
return (
|
||||
seeds[userId] ?? [
|
||||
{
|
||||
id: `${userId}-default`,
|
||||
name: 'منزل',
|
||||
district: 'حرم',
|
||||
address: 'قم، اطراف حرم، پلاک ۱۰',
|
||||
landline: '',
|
||||
},
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
function readMap(): AddressMap {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return {}
|
||||
return JSON.parse(raw) as AddressMap
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeMap(map: AddressMap) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(map))
|
||||
}
|
||||
|
||||
export function getUserAddresses(userId: string): ShippingAddress[] {
|
||||
const map = readMap()
|
||||
if (!map[userId]) {
|
||||
map[userId] = seedForUser(userId)
|
||||
writeMap(map)
|
||||
}
|
||||
return map[userId]
|
||||
}
|
||||
|
||||
export function addUserAddress(
|
||||
userId: string,
|
||||
input: Omit<ShippingAddress, 'id'>,
|
||||
): ShippingAddress {
|
||||
const map = readMap()
|
||||
const list = map[userId] ?? seedForUser(userId)
|
||||
const address: ShippingAddress = { ...input, id: uid() }
|
||||
map[userId] = [...list, address]
|
||||
writeMap(map)
|
||||
return address
|
||||
}
|
||||
@@ -54,6 +54,10 @@ export function listUsers(params: ListUsersParams = {}) {
|
||||
return apiRequest<UserListResponse>(`/users${buildQuery(params)}`)
|
||||
}
|
||||
|
||||
export function getUser(id: string) {
|
||||
return apiRequest<User>(`/users/${id}`)
|
||||
}
|
||||
|
||||
export function createUser(payload: CreateUserPayload) {
|
||||
return apiRequest<User>('/users', {
|
||||
method: 'POST',
|
||||
@@ -71,6 +75,17 @@ export function updateUser(id: string, payload: UpdateUserPayload) {
|
||||
})
|
||||
}
|
||||
|
||||
export function getMyProfile() {
|
||||
return apiRequest<User>('/users/me')
|
||||
}
|
||||
|
||||
export function updateMyProfile(payload: UpdateUserPayload) {
|
||||
return apiRequest<User>('/users/me', {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateUserRole(id: string, role: UserRole) {
|
||||
return apiRequest<User>(`/users/${id}/role`, {
|
||||
method: 'PATCH',
|
||||
@@ -90,3 +105,64 @@ export function deleteUser(id: string) {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export type UserAddress = {
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type CreateUserAddressPayload = {
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline?: string
|
||||
}
|
||||
|
||||
export type UpdateUserAddressPayload = Partial<CreateUserAddressPayload>
|
||||
|
||||
export function listUserAddresses(userId: string) {
|
||||
return apiRequest<UserAddress[]>(`/users/${userId}/addresses`)
|
||||
}
|
||||
|
||||
export function createUserAddress(
|
||||
userId: string,
|
||||
payload: CreateUserAddressPayload,
|
||||
) {
|
||||
return apiRequest<UserAddress>(`/users/${userId}/addresses`, {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function listMyAddresses() {
|
||||
return apiRequest<UserAddress[]>('/users/me/addresses')
|
||||
}
|
||||
|
||||
export function createMyAddress(payload: CreateUserAddressPayload) {
|
||||
return apiRequest<UserAddress>('/users/me/addresses', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateMyAddress(
|
||||
addressId: string,
|
||||
payload: UpdateUserAddressPayload,
|
||||
) {
|
||||
return apiRequest<UserAddress>(`/users/me/addresses/${addressId}`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteMyAddress(addressId: string) {
|
||||
return apiRequest<{ ok: true }>(`/users/me/addresses/${addressId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user