Wire dashboards to the API and add customer portal plus discounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-05 15:15:16 +03:30
co-authored by Cursor
parent af11d96fd0
commit b103e330ef
73 changed files with 8720 additions and 1605 deletions
+44
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
-55
View File
@@ -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())
}
+82
View File
@@ -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,
},
)
}
+115
View File
@@ -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',
})
}
+6
View File
@@ -0,0 +1,6 @@
import type { Flavor } from '../data/flavors'
import { apiRequest } from './api'
export function listFlavors() {
return apiRequest<Flavor[]>('/flavors')
}
+72
View File
@@ -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', '/')
}
+31
View File
@@ -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:')
)
}
+61
View 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
View File
@@ -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,
}))
}
+73
View File
@@ -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 },
})
}
+107
View File
@@ -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 }))
}
+73
View File
@@ -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',
})
}
-85
View File
@@ -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
}
+76
View File
@@ -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',
})
}