Initial commit of Balout Pastry admin dashboards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-02 17:23:00 +03:30
co-authored by Cursor
commit af11d96fd0
103 changed files with 14651 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
import { getAccessToken, getRefreshToken, setSession, clearSession } from './auth'
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3100/api/v1'
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
type RequestOptions = {
method?: string
body?: unknown
auth?: boolean
/** Skip refresh retry (used by refresh itself). */
skipRefresh?: boolean
}
let refreshInFlight: Promise<boolean> | null = null
function parseErrorMessage(payload: unknown, fallback: string) {
if (!payload || typeof payload !== 'object') return fallback
const message = (payload as { message?: unknown }).message
if (typeof message === 'string' && message.trim()) return message
if (Array.isArray(message)) {
const parts = message.filter((item) => typeof item === 'string')
if (parts.length > 0) return parts.join('، ')
}
return fallback
}
async function tryRefreshSession(): Promise<boolean> {
if (refreshInFlight) return refreshInFlight
refreshInFlight = (async () => {
const refreshToken = getRefreshToken()
if (!refreshToken) {
clearSession()
return false
}
try {
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
})
const payload = await response.json().catch(() => null)
if (!response.ok) {
clearSession()
return false
}
setSession({
accessToken: payload.accessToken,
refreshToken: payload.refreshToken,
user: payload.user,
})
return true
} catch {
clearSession()
return false
} finally {
refreshInFlight = null
}
})()
return refreshInFlight
}
export async function apiRequest<T>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const { method = 'GET', body, auth = true, skipRefresh = false } = options
const headers: Record<string, string> = {}
if (body !== undefined) {
headers['Content-Type'] = 'application/json'
}
if (auth) {
const token = getAccessToken()
if (token) headers.Authorization = `Bearer ${token}`
}
const response = await fetch(`${API_BASE_URL}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (response.status === 401 && auth && !skipRefresh) {
const refreshed = await tryRefreshSession()
if (refreshed) {
return apiRequest<T>(path, { ...options, skipRefresh: true })
}
}
if (response.status === 204) {
return undefined as T
}
const payload = await response.json().catch(() => null)
if (!response.ok) {
throw new ApiError(
response.status,
parseErrorMessage(payload, 'خطایی رخ داد'),
)
}
return payload as T
}
+67
View File
@@ -0,0 +1,67 @@
import type { UserRole } from '../data/users'
const AUTH_KEY = 'balout.admin.auth'
export type AuthUser = {
id: string
title: string
firstName: string
lastName: string
cellNumber: string
role: UserRole
name: string
}
export type AuthSession = {
accessToken: string
refreshToken: string
user: AuthUser
}
function isSession(value: unknown): value is AuthSession {
if (!value || typeof value !== 'object') return false
const session = value as Partial<AuthSession>
return (
typeof session.accessToken === 'string' &&
typeof session.refreshToken === 'string' &&
!!session.user &&
typeof session.user === 'object' &&
typeof session.user.name === 'string' &&
typeof session.user.id === 'string'
)
}
export function getSession(): AuthSession | null {
try {
const raw = localStorage.getItem(AUTH_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as unknown
return isSession(parsed) ? parsed : null
} catch {
return null
}
}
export function setSession(session: AuthSession) {
localStorage.setItem(AUTH_KEY, JSON.stringify(session))
}
export function clearSession() {
localStorage.removeItem(AUTH_KEY)
}
export function isAuthenticated(): boolean {
return getSession() !== null
}
export function getAccessToken(): string | null {
return getSession()?.accessToken ?? null
}
export function getRefreshToken(): string | null {
return getSession()?.refreshToken ?? null
}
export function getAuthUser(): AuthUser | null {
return getSession()?.user ?? null
}
+40
View File
@@ -0,0 +1,40 @@
import { apiRequest } from './api'
import { clearSession, getRefreshToken, setSession, type AuthUser } from './auth'
export type AuthTokensResponse = {
accessToken: string
refreshToken: string
user: AuthUser
}
export async function login(cellNumber: string, password: string) {
const data = await apiRequest<AuthTokensResponse>('/auth/login', {
method: 'POST',
auth: false,
body: { cellNumber, password },
})
setSession({
accessToken: data.accessToken,
refreshToken: data.refreshToken,
user: data.user,
})
return data.user
}
export async function logout() {
const refreshToken = getRefreshToken()
try {
if (refreshToken) {
await apiRequest('/auth/logout', {
method: 'POST',
auth: false,
skipRefresh: true,
body: { refreshToken },
})
}
} catch {
// Always clear local session even if logout request fails.
} finally {
clearSession()
}
}
+55
View File
@@ -0,0 +1,55 @@
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())
}
+93
View File
@@ -0,0 +1,93 @@
import {
createFlavorBlock,
createFlavorEntry,
type CategoryFlavorOptions,
} from '../data/flavors'
const STORAGE_KEY = 'balout-category-flavor-options'
type OptionsMap = Record<string, CategoryFlavorOptions>
function seedOptions(): OptionsMap {
return {
شیرینی: [
{
...createFlavorBlock('chocolate'),
entries: [
createFlavorEntry('۱۰۰ گرم', 25000),
createFlavorEntry('۵۰۰ گرم', 50000),
],
},
{
...createFlavorBlock('pistachio'),
entries: [
createFlavorEntry('۱۵۰ گرم', 45000),
createFlavorEntry('۱ کیلو', 280000),
],
},
],
کیک: [
{
...createFlavorBlock('vanilla'),
entries: [
createFlavorEntry('نیم کیلو', 220000),
createFlavorEntry('۱ کیلو', 420000),
],
},
{
...createFlavorBlock('chocolate'),
entries: [createFlavorEntry('۱ کیلو', 450000)],
},
],
}
}
function readStore(): OptionsMap {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) {
const seeded = seedOptions()
localStorage.setItem(STORAGE_KEY, JSON.stringify(seeded))
return seeded
}
return JSON.parse(raw) as OptionsMap
} catch {
return seedOptions()
}
}
function writeStore(map: OptionsMap) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(map))
}
export function getAllCategoryOptions(): OptionsMap {
return readStore()
}
export function getCategoryOptionsByName(
categoryNameFa: string,
): CategoryFlavorOptions {
return readStore()[categoryNameFa] ?? []
}
export function setCategoryOptionsByName(
categoryNameFa: string,
options: CategoryFlavorOptions,
) {
const map = readStore()
map[categoryNameFa] = options
writeStore(map)
}
export function removeCategoryOptionsByName(categoryNameFa: string) {
const map = readStore()
delete map[categoryNameFa]
writeStore(map)
}
/** Available options that have at least one amount/price value. */
export function getSelectableCategoryOptions(categoryNameFa: string) {
return getCategoryOptionsByName(categoryNameFa).filter(
(block) => block.entries.length > 0,
)
}
+105
View File
@@ -0,0 +1,105 @@
import {
CakeSlice,
FolderTree,
Package,
PlusCircle,
Settings,
Users,
type LucideIcon,
} from 'lucide-react'
export type NavItem = {
label: string
href: string
}
export type NavSection = {
id: string
title: string
titleEn: string
description: string
href: string
icon: LucideIcon
items: NavItem[]
}
export type ProductTile = {
id: string
title: string
titleEn: string
description: string
href: string
icon: LucideIcon
}
export const productTiles: ProductTile[] = [
{
id: 'products-list',
title: 'محصولات من',
titleEn: 'My Products',
description: 'مشاهده و مدیریت همه محصولات فروشگاه',
href: '/products/list',
icon: CakeSlice,
},
{
id: 'products-new',
title: 'ثبت محصول جدید',
titleEn: 'New Product',
description: 'افزودن شیرینی یا محصول تازه به فروشگاه',
href: '/products/new',
icon: PlusCircle,
},
{
id: 'products-categories',
title: 'دسته بندی محصولات',
titleEn: 'Categories',
description: 'مدیریت دسته‌بندی‌های محصولات',
href: '/products/categories',
icon: FolderTree,
},
]
export const navSections: NavSection[] = [
{
id: 'products',
title: 'محصولات من',
titleEn: 'Products',
description: 'مدیریت شیرینی‌ها و محصولات فروشگاه',
href: '/products',
icon: CakeSlice,
items: productTiles.map((tile) => ({
label: tile.title,
href: tile.href,
})),
},
{
id: 'users',
title: 'کاربران',
titleEn: 'Users',
description: 'مدیریت کاربران، نقش‌ها و دسترسی‌ها',
href: '/users',
icon: Users,
items: [
{ label: 'همه کاربران', href: '/users' },
{ label: 'ثبت کاربر جدید', href: '/users' },
],
},
{
id: 'orders',
title: 'سفارشات من',
titleEn: 'Orders',
description: 'پیگیری و مدیریت سفارش‌ها',
href: '/orders',
icon: Package,
items: [],
},
{
id: 'settings',
title: 'تنظیمات',
titleEn: 'Settings',
description: 'تنظیمات فروشگاه و حساب کاربری',
href: '/settings',
icon: Settings,
items: [],
},
]
+91
View File
@@ -0,0 +1,91 @@
import { products, type Product } from '../data/products'
import { users, formatCellNumber, type User } from '../data/users'
import { flavors } from '../data/flavors'
import { getSelectableCategoryOptions } from './categoryOptionsStore'
export type SearchUserResult = {
id: string
name: string
phone: string
phoneDisplay: string
}
export type ProductOptionChoice = {
id: string
name: string
price: number
}
function delay(ms = 280) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
/** Simulated API user search (`GET /users?q=`). */
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))
)
})
.slice(0, 12)
.map((user) => ({
id: user.id,
name: `${user.firstName} ${user.lastName}`,
phone: user.cellNumber,
phoneDisplay: formatCellNumber(user.cellNumber),
}))
}
/** Simulated API product search (`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
}
/** 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)
}
+85
View File
@@ -0,0 +1,85 @@
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
}
+92
View File
@@ -0,0 +1,92 @@
import type {
User,
UserCategory,
UserRole,
UserTitle,
} from '../data/users'
import { apiRequest } from './api'
export type UserListResponse = {
items: User[]
total: number
page: number
pageSize: number
}
export type ListUsersParams = {
q?: string
role?: UserRole | ''
page?: number
pageSize?: number
}
export type CreateUserPayload = {
title: UserTitle
firstName: string
lastName: string
category?: UserCategory | null
cellNumber: string
password: string
role?: UserRole
disabled?: boolean
}
export type UpdateUserPayload = {
title?: UserTitle
firstName?: string
lastName?: string
category?: UserCategory | null
cellNumber?: string
disabled?: boolean
}
function buildQuery(params: ListUsersParams) {
const search = new URLSearchParams()
if (params.q?.trim()) search.set('q', params.q.trim())
if (params.role) search.set('role', params.role)
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 listUsers(params: ListUsersParams = {}) {
return apiRequest<UserListResponse>(`/users${buildQuery(params)}`)
}
export function createUser(payload: CreateUserPayload) {
return apiRequest<User>('/users', {
method: 'POST',
body: {
...payload,
category: payload.category ?? undefined,
},
})
}
export function updateUser(id: string, payload: UpdateUserPayload) {
return apiRequest<User>(`/users/${id}`, {
method: 'PATCH',
body: payload,
})
}
export function updateUserRole(id: string, role: UserRole) {
return apiRequest<User>(`/users/${id}/role`, {
method: 'PATCH',
body: { role },
})
}
export function updateUserPassword(id: string, password: string) {
return apiRequest<User>(`/users/${id}/password`, {
method: 'PATCH',
body: { password },
})
}
export function deleteUser(id: string) {
return apiRequest<{ ok: true }>(`/users/${id}`, {
method: 'DELETE',
})
}