Initial commit: Meshkee dashboards monorepo.

Includes business, customer, and super-admin apps with shared packages and production deploy scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-22 13:48:53 +03:30
co-authored by Cursor
commit f566387c61
509 changed files with 62690 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
import { apiRequest, setTokens, clearTokens } from '../lib/api'
import { clearActiveBusiness } from '../lib/businessContext'
import type {
LoginResponse,
MeResponse,
OtpSendResponse,
OtpVerifyResponse,
RegisterResponse,
} from '../types/auth'
export async function login(cellNumber: string, password: string) {
const data = await apiRequest<LoginResponse>('/auth/login', {
method: 'POST',
body: { cellNumber, password },
})
setTokens(data.accessToken, data.refreshToken)
return data
}
export async function register(input: {
cellNumber: string
password: string
firstName: string
lastName: string
email?: string
domain: string
}) {
const data = await apiRequest<RegisterResponse>('/auth/register', {
method: 'POST',
body: input,
})
setTokens(data.accessToken, data.refreshToken)
return data
}
export async function fetchCurrentUser(signal?: AbortSignal) {
return apiRequest<MeResponse>('/auth/me', { auth: true, signal })
}
export async function sendOtp(cellNumber: string) {
return apiRequest<OtpSendResponse>('/auth/send-otp', {
method: 'POST',
body: { cellNumber },
})
}
export async function verifyOtp(cellNumber: string, code: string) {
return apiRequest<OtpVerifyResponse>('/auth/verify-otp', {
method: 'POST',
body: { cellNumber, code },
})
}
export async function changePassword(currentPassword: string, newPassword: string) {
return apiRequest<{ message: string }>('/auth/change-password', {
method: 'POST',
auth: true,
body: { currentPassword, newPassword },
})
}
export function logout() {
clearTokens()
clearActiveBusiness()
}
@@ -0,0 +1,76 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
CategoryFormData,
CreateCategoryPayload,
ProductCategoriesResponse,
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
const ENTITY_TYPE = 'blog' as const
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/categories${suffix}`
}
export async function listBlogCategories(signal?: AbortSignal) {
const data = await apiRequest<ProductCategoriesResponse>(
`${businessPath()}?entityType=${ENTITY_TYPE}`,
{ auth: true, signal },
)
return data.items
}
export async function createBlogCategory(payload: CreateCategoryPayload) {
return apiRequest<{ message: string; category: ProductCategory }>(businessPath(), {
method: 'POST',
auth: true,
body: { ...payload, entityType: ENTITY_TYPE },
})
}
export async function updateBlogCategory(categoryId: string, payload: UpdateCategoryPayload) {
return apiRequest<{ message: string; category: ProductCategory }>(
businessPath(`/${categoryId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export async function deleteBlogCategory(categoryId: string) {
return apiRequest<{ message: string; deletedIds: string[] }>(
businessPath(`/${categoryId}`),
{
method: 'DELETE',
auth: true,
},
)
}
export function toBlogCategoryFormPayload(data: CategoryFormData): CreateCategoryPayload {
return {
parentId: data.parentId || undefined,
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
}
}
export function mapBlogCategoryToUi(category: ProductCategory) {
return {
id: category.id,
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
}
}
+216
View File
@@ -0,0 +1,216 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import { extractFirstImageUrl } from '../utils/htmlImages'
import type { Blog, BlogAuthor, BlogDetail, BlogPostType, BlogStatus } from '../types/blog'
export const BLOGS_PER_PAGE = 12
export interface BlogApi {
id: string
businessId: string
title: string
slug: string
type: BlogPostType
abstract: string
mainTextHtml: string
status: BlogStatus
categoryId: string | null
categoryName: string
tags: string[]
titleImageUrl: string | null
featuredMediaId: string | null
authorId?: string | null
author?: BlogAuthor | null
commentCount: number
publishedAt: string | null
createdAt: string
updatedAt: string
}
export interface BlogsListResponse {
items: BlogApi[]
total: number
page: number
pageSize: number
}
export interface ListBlogsParams {
page?: number
pageSize?: number
status?: BlogStatus
type?: BlogPostType
categoryId?: string
title?: string
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/blogs${suffix}`
}
export interface CreateBlogPayload {
title: string
type: BlogPostType
abstract?: string
mainTextHtml?: string
categoryId?: string
status?: BlogStatus
featuredMediaId?: string
tags?: string[]
}
export interface UpdateBlogPayload {
title?: string
type?: BlogPostType
abstract?: string | null
mainTextHtml?: string | null
categoryId?: string | null
status?: BlogStatus
featuredMediaId?: string | null
tags?: string[]
}
export function resolveBlogTitleImageUrl(blog: BlogApi): string | null {
return blog.titleImageUrl?.trim() || extractFirstImageUrl(blog.mainTextHtml) || null
}
export function mapBlogApiToUi(blog: BlogApi): Blog {
return {
id: blog.id,
title: blog.title,
slug: blog.slug,
type: blog.type,
abstract: blog.abstract,
status: blog.status,
categoryId: blog.categoryId,
categoryName: blog.categoryName,
tags: blog.tags,
titleImageUrl: resolveBlogTitleImageUrl(blog),
commentCount: blog.commentCount,
author: blog.author ?? null,
publishedAt: blog.publishedAt,
createdAt: blog.createdAt,
updatedAt: blog.updatedAt,
}
}
export function mapBlogDetailFromApi(blog: BlogApi): BlogDetail {
return {
...mapBlogApiToUi(blog),
mainTextHtml: blog.mainTextHtml,
author: blog.author ?? null,
}
}
export function formatBlogAuthor(author: BlogAuthor | null): string {
if (!author) return 'Unknown author'
const name = [author.firstName, author.lastName].filter(Boolean).join(' ').trim()
return name || author.email || 'Unknown author'
}
export function formatBlogDate(value: string | null): string {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export function formatBlogCardDate(value: string | null): string {
if (!value) return 'Not published'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export function isBlogVerified(blog: Pick<Blog, 'status'>): boolean {
return blog.status === 'published'
}
export async function setBlogVerified(blogId: string, verified: boolean) {
return updateBlog(blogId, { status: verified ? 'published' : 'draft' })
}
export function mapBlogApiToFormState(blog: BlogApi) {
return {
title: blog.title,
type: blog.type,
abstract: blog.abstract,
mainTextHtml: blog.mainTextHtml,
categoryId: blog.categoryId ?? '',
tags: blog.tags,
titleImage: resolveBlogTitleImageUrl(blog),
featuredMediaId: blog.featuredMediaId,
}
}
export async function listBlogs(
page = 1,
pageSize = BLOGS_PER_PAGE,
signal?: AbortSignal,
filters?: Omit<ListBlogsParams, 'page' | 'pageSize'>,
) {
const q = new URLSearchParams()
q.set('page', String(page))
q.set('pageSize', String(pageSize))
if (filters?.status) q.set('status', filters.status)
if (filters?.type) q.set('type', filters.type)
if (filters?.categoryId) q.set('categoryId', filters.categoryId)
if (filters?.title) q.set('title', filters.title)
const data = await apiRequest<BlogsListResponse>(`${businessPath()}?${q.toString()}`, {
auth: true,
signal,
})
return {
...data,
items: data.items.map(mapBlogApiToUi),
}
}
export async function getBlog(blogId: string, signal?: AbortSignal) {
const data = await apiRequest<{ blog: BlogApi }>(businessPath(`/${blogId}`), {
auth: true,
signal,
})
return data.blog
}
export async function getBlogDetail(blogId: string, signal?: AbortSignal) {
const blog = await getBlog(blogId, signal)
return mapBlogDetailFromApi(blog)
}
export async function createBlog(payload: CreateBlogPayload) {
return apiRequest<{ message: string; blog: BlogApi }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateBlog(blogId: string, payload: UpdateBlogPayload) {
return apiRequest<{ message: string; blog: BlogApi }>(businessPath(`/${blogId}`), {
method: 'PATCH',
auth: true,
body: payload,
})
}
export async function deleteBlog(blogId: string) {
return apiRequest<{ message: string }>(businessPath(`/${blogId}`), {
method: 'DELETE',
auth: true,
})
}
+113
View File
@@ -0,0 +1,113 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
BrandApi,
BrandFormData,
BrandsListResponse,
CreateBrandPayload,
UpdateBrandPayload,
} from '../types/brand'
import type { FlatCategory } from '../types/category'
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/brands${suffix}`
}
export async function listBrands(
page = 1,
pageSize = 100,
signal?: AbortSignal,
filters?: { name?: string },
) {
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
})
if (filters?.name?.trim()) {
params.set('name', filters.name.trim())
}
return apiRequest<BrandsListResponse>(`${businessPath()}?${params.toString()}`, {
auth: true,
signal,
})
}
export async function listAllBrands(signal?: AbortSignal) {
const pageSize = 100
let page = 1
let items: BrandApi[] = []
let total = Infinity
while (items.length < total) {
const data = await listBrands(page, pageSize, signal)
items = items.concat(data.items)
total = data.total
page += 1
}
return items
}
export async function createBrand(payload: CreateBrandPayload) {
return apiRequest<{ message: string; brand: BrandApi }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateBrand(brandId: string, payload: UpdateBrandPayload) {
return apiRequest<{ message: string; brand: BrandApi }>(businessPath(`/${brandId}`), {
method: 'PATCH',
auth: true,
body: payload,
})
}
export async function deleteBrand(brandId: string) {
return apiRequest<{ message: string }>(businessPath(`/${brandId}`), {
method: 'DELETE',
auth: true,
})
}
export function toBrandFormPayload(
data: BrandFormData,
imageMediaId?: string | null,
): CreateBrandPayload {
return {
nameEn: data.nameEn,
nameFa: data.nameFa || undefined,
about: data.about || undefined,
...(imageMediaId ? { imageMediaId } : {}),
}
}
export function mapBrandApiToUi(brand: BrandApi) {
return {
id: brand.id,
nameEn: brand.nameEn,
nameFa: brand.nameFa ?? '',
about: brand.about ?? '',
slug: brand.slug,
imageUrl: brand.imageUrl,
imageMediaId: brand.imageMediaId,
}
}
export function mapBrandToSelectOption(brand: BrandApi): FlatCategory {
return {
id: brand.id,
nameEn: brand.nameEn,
nameFa: brand.nameFa ?? '',
description: brand.about ?? '',
parentId: null,
variationCount: 0,
depth: 0,
}
}
@@ -0,0 +1,23 @@
import { apiRequest } from '../lib/api'
export interface BusinessActivityCategory {
id: string
parentId: string | null
name: string
slug: string
description: string | null
icon: string | null
sortOrder: number
}
export async function listBusinessActivityCategories(signal?: AbortSignal) {
const data = await apiRequest<{ items: BusinessActivityCategory[] }>(
'/business-categories',
{ auth: true, signal },
)
return data.items.map((item) => ({
...item,
id: String(item.id),
parentId: item.parentId ? String(item.parentId) : null,
}))
}
@@ -0,0 +1,97 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
export type PhoneNumberType = 'landline' | 'cell'
export interface BusinessPhoneNumber {
type: PhoneNumberType
number: string
}
export interface BusinessSocialMedia {
whatsapp: string
telegram: string
instagram: string
linkedin: string
youtube: string
aparat: string
}
export interface BusinessProfile {
nameEn: string
nameFa: string
about: string
vision: string
emails: string[]
phoneNumbers: BusinessPhoneNumber[]
socialMedia: BusinessSocialMedia
logoMediaId: string | null
logoUrl: string | null
faviconMediaId: string | null
faviconUrl: string | null
categoryIds: string[]
}
export interface BusinessAddress {
id: string
province: string
city: string
address: string
postalCode: string
landline: string | null
}
export interface BusinessProfileResponse {
businessId: string
profile: BusinessProfile
addresses: BusinessAddress[]
}
export interface UpdateBusinessProfilePayload {
nameEn?: string
nameFa?: string
about?: string
vision?: string
emails?: string[]
phoneNumbers?: BusinessPhoneNumber[]
socialMedia?: Partial<BusinessSocialMedia>
logoMediaId?: string | null
categoryIds?: string[]
addresses?: Array<{
id?: string
province: string
city: string
address: string
postalCode: string
landline?: string | null
}>
}
function profilePath() {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/profile`
}
export async function getBusinessProfile(signal?: AbortSignal) {
return apiRequest<BusinessProfileResponse>(profilePath(), { auth: true, signal })
}
export async function updateBusinessProfile(payload: UpdateBusinessProfilePayload) {
return apiRequest<BusinessProfileResponse>(profilePath(), {
method: 'PATCH',
auth: true,
body: {
...payload,
categoryIds: payload.categoryIds?.map((id) => Number(id)),
logoMediaId:
payload.logoMediaId === undefined
? undefined
: payload.logoMediaId
? Number(payload.logoMediaId)
: null,
},
})
}
@@ -0,0 +1,82 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
CategoryTechnicalForm,
TechnicalFormFieldDraft,
TechnicalFormField,
} from '../types/technicalForm'
function businessPath(categoryId: string) {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/categories/${categoryId}/technical-form`
}
export function mapApiFieldToDraft(field: TechnicalFormField): TechnicalFormFieldDraft {
return {
id: field.id,
label: field.label,
type: field.type,
isRequired: field.isRequired,
options: field.options.map((option) => option.label),
}
}
export function mapDraftToPayload(fields: TechnicalFormFieldDraft[]) {
return {
fields: fields.map((field) => ({
label: field.label.trim(),
type: field.type,
isRequired: field.isRequired,
...(field.type === 'select' || field.type === 'multi_select'
? { options: field.options.map((option) => option.trim()).filter(Boolean) }
: {}),
})),
}
}
export async function getCategoryTechnicalForm(categoryId: string, signal?: AbortSignal) {
const data = await apiRequest<{ form: CategoryTechnicalForm | null }>(
businessPath(categoryId),
{ auth: true, signal },
)
return data.form
}
export async function saveCategoryTechnicalForm(
categoryId: string,
fields: TechnicalFormFieldDraft[],
) {
const data = await apiRequest<{ form: CategoryTechnicalForm | null }>(
businessPath(categoryId),
{
method: 'PUT',
auth: true,
body: mapDraftToPayload(fields),
},
)
return data.form
}
export async function suggestCategoryTechnicalFormByAi(
categoryId: string,
hint?: string,
) {
const data = await apiRequest<{
message: string
fields: {
label: string
type: TechnicalFormFieldDraft['type']
isRequired: boolean
options?: string[]
}[]
}>(`${businessPath(categoryId)}/ai-suggest`, {
method: 'POST',
auth: true,
body: hint ? { hint } : {},
})
return data.fields
}
@@ -0,0 +1,85 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type { Variation, VariationType } from '../types/variation'
export interface CategoryVariationApi {
id: string
name: string
type: VariationType
sortOrder: number
values: string[]
options: {
id: string
label: string
value: string
colorHex: string | null
sortOrder: number
}[]
}
export interface ColorPreset {
name: string
hex: string
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/categories${suffix}`
}
export function mapApiVariationToUi(item: CategoryVariationApi): Variation {
return {
id: item.id,
name: item.name,
type: item.type,
values: item.values,
}
}
export async function listColorPresets(signal?: AbortSignal) {
const data = await apiRequest<{ items: ColorPreset[] }>(businessPath('/color-presets'), {
auth: true,
signal,
})
return data.items
}
export async function listCategoryVariations(categoryId: string, signal?: AbortSignal) {
const data = await apiRequest<{ items: CategoryVariationApi[] }>(
businessPath(`/${categoryId}/variations`),
{ auth: true, signal },
)
return data.items.map(mapApiVariationToUi)
}
export async function fetchCategoryVariations(categoryId: string, signal?: AbortSignal) {
const data = await apiRequest<{ items: CategoryVariationApi[] }>(
businessPath(`/${categoryId}/variations`),
{ auth: true, signal },
)
return data.items
}
export async function saveCategoryVariations(
categoryId: string,
variations: Variation[],
) {
const data = await apiRequest<{ items: CategoryVariationApi[] }>(
businessPath(`/${categoryId}/variations`),
{
method: 'PUT',
auth: true,
body: {
variations: variations.map((variation) => ({
type: variation.type,
name: variation.name,
values: variation.values,
})),
},
},
)
return data.items.map(mapApiVariationToUi)
}
@@ -0,0 +1,20 @@
import { apiRequest } from '../lib/api'
import type { CityOption } from '@meshkee/dashboard-ui'
export type { CityOption }
export async function listIranProvinces(signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>(
'/cities?level=province&parentSlug=iran',
{ signal },
)
return data.items
}
export async function listCitiesByProvinceSlug(parentSlug: string, signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>(
`/cities?level=city&parentSlug=${encodeURIComponent(parentSlug)}`,
{ signal },
)
return data.items
}
@@ -0,0 +1,115 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type { ProductComment } from '../types/comment'
export const COMMENTS_PER_PAGE = 12
export interface CommentApi {
id: string
businessId: string
entityType: string
entityId: string
authorName: string
authorEmail: string | null
text: string
isApproved: boolean
approvedAt: string | null
approvedBy: string | null
approver: { id: string; firstName: string; lastName: string } | null
createdAt: string
updatedAt: string
}
export interface CommentsListResponse {
items: CommentApi[]
total: number
page: number
pageSize: number
}
function commentsPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/comments${suffix}`
}
export function mapCommentApiToUi(comment: CommentApi): ProductComment {
return {
id: comment.id,
author: comment.authorName,
createdAt: comment.createdAt,
text: comment.text,
likesCount: 0,
approved: comment.isApproved,
}
}
async function listEntityComments(
entityType: 'product' | 'blog' | 'portfolio',
entityId: string,
page = 1,
pageSize = COMMENTS_PER_PAGE,
signal?: AbortSignal,
) {
const params = new URLSearchParams({
entityType,
entityId,
page: String(page),
pageSize: String(pageSize),
})
const data = await apiRequest<CommentsListResponse>(`${commentsPath()}?${params}`, {
auth: true,
signal,
})
return {
...data,
items: data.items.map(mapCommentApiToUi),
}
}
export async function listProductComments(
productId: string,
page = 1,
pageSize = COMMENTS_PER_PAGE,
signal?: AbortSignal,
) {
return listEntityComments('product', productId, page, pageSize, signal)
}
export async function listBlogComments(
blogId: string,
page = 1,
pageSize = COMMENTS_PER_PAGE,
signal?: AbortSignal,
) {
return listEntityComments('blog', blogId, page, pageSize, signal)
}
export async function listPortfolioComments(
portfolioId: string,
page = 1,
pageSize = COMMENTS_PER_PAGE,
signal?: AbortSignal,
) {
return listEntityComments('portfolio', portfolioId, page, pageSize, signal)
}
export async function updateCommentApproval(commentId: string, isApproved: boolean) {
const data = await apiRequest<{ comment: CommentApi }>(commentsPath(`/${commentId}`), {
method: 'PATCH',
auth: true,
body: { isApproved },
})
return mapCommentApiToUi(data.comment)
}
export async function deleteComment(commentId: string) {
return apiRequest<{ message: string }>(commentsPath(`/${commentId}`), {
method: 'DELETE',
auth: true,
})
}
@@ -0,0 +1,42 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
ContactSubmission,
ContactSubmissionsListResponse,
} from '../types/contactSubmission'
export interface ListContactSubmissionsParams {
page?: number
pageSize?: number
q?: string
}
function basePath() {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/contact-submissions`
}
export async function listContactSubmissions(
params: ListContactSubmissionsParams,
signal?: AbortSignal,
) {
const q = new URLSearchParams()
if (params.page !== undefined) q.set('page', String(params.page))
if (params.pageSize !== undefined) q.set('pageSize', String(params.pageSize))
if (params.q) q.set('q', params.q)
const query = q.toString()
const path = `${basePath()}${query ? `?${query}` : ''}`
return apiRequest<ContactSubmissionsListResponse>(path, { auth: true, signal })
}
export async function getContactSubmission(submissionId: string, signal?: AbortSignal) {
return apiRequest<{ submission: ContactSubmission }>(`${basePath()}/${submissionId}`, {
auth: true,
signal,
})
}
@@ -0,0 +1,119 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
export interface BusinessCustomer {
id: string
cellNumber: string
firstName: string | null
lastName: string | null
email: string | null
label: string
}
export interface BusinessCustomerListItem extends BusinessCustomer {
createdAt: string
isEnabled: boolean
/** Placeholder until orders stats API exists */
orderCount?: number | null
/** Placeholder until orders stats API exists */
totalTransactionsIrt?: number | null
}
export interface CustomersListResponse {
items: BusinessCustomerListItem[]
total: number
page: number
pageSize: number
}
export interface ListCustomersParams {
page?: number
pageSize?: number
name?: string
cellNumber?: string
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/customers${suffix}`
}
export async function listCustomers(
params: ListCustomersParams = {},
signal?: AbortSignal,
) {
const q = new URLSearchParams()
if (params.page !== undefined) q.set('page', String(params.page))
if (params.pageSize !== undefined) q.set('pageSize', String(params.pageSize))
if (params.name) q.set('name', params.name)
if (params.cellNumber) q.set('cellNumber', params.cellNumber)
const query = q.toString()
const path = `${businessPath()}${query ? `?${query}` : ''}`
return apiRequest<CustomersListResponse>(path, { auth: true, signal })
}
export interface UpdateCustomerPayload {
isEnabled?: boolean
firstName?: string
lastName?: string
email?: string
cellNumber?: string
}
export interface CreateCustomerPayload {
cellNumber: string
firstName: string
lastName: string
password?: string
email?: string
}
export async function createCustomer(payload: CreateCustomerPayload) {
return apiRequest<BusinessCustomerListItem & { isVerified: boolean; role: string }>(
businessPath(),
{
method: 'POST',
auth: true,
body: payload,
},
)
}
export async function updateCustomer(userId: string, payload: UpdateCustomerPayload) {
return apiRequest<BusinessCustomer & { isEnabled: boolean }>(`${businessPath(`/${userId}`)}`, {
method: 'PATCH',
auth: true,
body: payload,
})
}
export async function updateCustomerEnabled(userId: string, isEnabled: boolean) {
return updateCustomer(userId, { isEnabled })
}
export async function removeCustomer(userId: string) {
return apiRequest<{ success: boolean }>(`${businessPath(`/${userId}`)}`, {
method: 'DELETE',
auth: true,
})
}
export async function searchCustomers(
query: string,
limit = 20,
signal?: AbortSignal,
) {
const q = query.trim()
if (q.length < 2) {
return { items: [] as BusinessCustomer[] }
}
return apiRequest<{ items: BusinessCustomer[] }>(
`${businessPath('/search')}?q=${encodeURIComponent(q)}&limit=${limit}`,
{ auth: true, signal },
)
}
@@ -0,0 +1,98 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type { ProductExpertReview } from '../types/expertReview'
export const EXPERT_REVIEWS_PER_PAGE = 20
export interface ExpertReviewApi {
id: string
businessId: string
productId: string
authorName: string
authorEmail: string | null
rate: number
positivePoints: string[]
negativePoints: string[]
text: string
isApproved: boolean
approvedAt: string | null
approvedBy: string | null
approver: { id: string; firstName: string; lastName: string } | null
createdAt: string
updatedAt: string
}
export interface ExpertReviewsListResponse {
items: ExpertReviewApi[]
total: number
page: number
pageSize: number
}
function expertReviewsPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/expert-reviews${suffix}`
}
export function mapExpertReviewApiToUi(review: ExpertReviewApi): ProductExpertReview {
return {
id: review.id,
authorName: review.authorName,
rate: review.rate,
positivePoints: review.positivePoints ?? [],
negativePoints: review.negativePoints ?? [],
text: review.text,
approved: review.isApproved,
createdAt: review.createdAt,
}
}
export function formatReviewDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export async function listProductExpertReviews(
productId: string,
page = 1,
pageSize = EXPERT_REVIEWS_PER_PAGE,
signal?: AbortSignal,
) {
const params = new URLSearchParams({
productId,
page: String(page),
pageSize: String(pageSize),
})
const data = await apiRequest<ExpertReviewsListResponse>(`${expertReviewsPath()}?${params}`, {
auth: true,
signal,
})
return {
...data,
items: data.items.map(mapExpertReviewApiToUi),
}
}
export async function updateExpertReviewApproval(reviewId: string, isApproved: boolean) {
const data = await apiRequest<{ review: ExpertReviewApi }>(expertReviewsPath(`/${reviewId}`), {
method: 'PATCH',
auth: true,
body: { isApproved },
})
return mapExpertReviewApiToUi(data.review)
}
export async function deleteExpertReview(reviewId: string) {
return apiRequest<{ success: boolean }>(expertReviewsPath(`/${reviewId}`), {
method: 'DELETE',
auth: true,
})
}
+167
View File
@@ -0,0 +1,167 @@
import { getAccessToken, clearTokens, setTokens } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import { API_BASE_URL } from '../lib/config'
import { ensureJpegUploadFile, ensureUploadFile } from '../utils/imageUpload'
export interface MediaItem {
id: string
publicUrl: string
fileName: string
originalFileName: string
mimeType: string
width: number | null
height: number | null
}
function isDataUrl(value: string) {
return value.startsWith('data:')
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/media${suffix}`
}
async function refreshAccessToken() {
const refreshToken = localStorage.getItem('meshkee_business_refresh_token')
if (!refreshToken) return false
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
})
if (!response.ok) {
clearTokens()
return false
}
const data = await response.json()
setTokens(data.accessToken, data.refreshToken)
return true
}
function parseUploadError(payload: unknown, status: number) {
if (payload && typeof payload === 'object') {
const message = (payload as { message?: string | string[] }).message
if (Array.isArray(message)) {
return message.join(', ')
}
if (typeof message === 'string' && message) {
return message
}
}
return `Upload failed with status ${status}`
}
export async function uploadMediaFiles(
files: File[],
signal?: AbortSignal,
): Promise<MediaItem[]> {
if (!files.length) return []
const send = async () => {
const formData = new FormData()
files.forEach((file) => formData.append('files', file))
const headers = new Headers()
const accessToken = getAccessToken()
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`)
}
return fetch(`${API_BASE_URL}${businessPath()}`, {
method: 'POST',
headers,
body: formData,
signal,
})
}
let response = await send()
if (response.status === 401) {
const refreshed = await refreshAccessToken()
if (refreshed) {
response = await send()
}
}
const payload = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(parseUploadError(payload, response.status))
}
return (payload.items as Array<Record<string, unknown>>).map((item) => ({
id: String(item.id),
publicUrl: String(item.publicUrl),
fileName: String(item.fileName),
originalFileName: String(item.originalFileName),
mimeType: String(item.mimeType),
width: typeof item.width === 'number' ? item.width : null,
height: typeof item.height === 'number' ? item.height : null,
}))
}
export async function resolveDataUrlToMediaId(
value: string | null,
filename: string,
existingMediaId?: string | null,
): Promise<string | null> {
if (!value) return null
if (!isDataUrl(value)) {
return existingMediaId ?? null
}
const file = filename.toLowerCase().endsWith('.png')
? await ensureUploadFile(value, filename)
: await ensureJpegUploadFile(value, filename)
const uploaded = await uploadMediaFiles([file])
return uploaded[0]?.id ?? null
}
export async function resolveHtmlEmbeddedDataUrls(html: string): Promise<string> {
if (!html.includes('data:')) return html
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
const images = Array.from(doc.querySelectorAll('img'))
for (let index = 0; index < images.length; index += 1) {
const src = images[index].getAttribute('src')
if (!src || !isDataUrl(src)) continue
const file = await ensureJpegUploadFile(src, `inline-image-${index + 1}.jpg`)
const uploaded = await uploadMediaFiles([file])
if (uploaded[0]) {
images[index].setAttribute('src', uploaded[0].publicUrl)
}
}
return doc.body.innerHTML
}
export async function resolveDataUrlsToMediaIds(
values: string[],
existingMediaIds: string[],
): Promise<string[]> {
const resolved: string[] = []
for (let index = 0; index < values.length; index += 1) {
const value = values[index]
if (isDataUrl(value)) {
const file = await ensureJpegUploadFile(value, `product-image-${index + 1}.jpg`)
const uploaded = await uploadMediaFiles([file])
if (uploaded[0]) resolved.push(uploaded[0].id)
} else if (existingMediaIds[index]) {
resolved.push(existingMediaIds[index])
}
}
return resolved
}
+179
View File
@@ -0,0 +1,179 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
export type OrderStatus =
| 'pending'
| 'confirmed'
| 'processing'
| 'shipped'
| 'delivered'
| 'cancelled'
export type OrderSource = 'website' | 'admin' | 'app'
export interface OrderItem {
id: string
storeItemVariantId: string | null
productId: string
productTitle: string
productImage: string | null
variantSku: string | null
unitPrice: number
compareAtPrice: number | null
quantity: number
lineTotal: number
selections: {
variationId: string
variationName: string
optionId: string
value: string
}[]
}
export interface OrderCustomer {
id: string
firstName: string | null
lastName: string | null
cellNumber: string
email: string | null
}
export type TransactionType = 'pos' | 'cash' | 'transfer' | 'e_payment_gate'
export type TransactionStatus = 'pending' | 'completed' | 'failed' | 'refunded'
export interface OrderTransaction {
id: string
type: TransactionType
status: TransactionStatus
amount: number
posType: string | null
gatewayType: string | null
transferAccount: string | null
transferRefNumber: string | null
notes: string | null
createdBy: string | null
createdAt: string
updatedAt: string
}
export interface Order {
id: string
businessId: string
orderNumber: string
status: OrderStatus
processStepId: string
processStepLabel?: string | null
processStepColor?: string | null
source: OrderSource
subtotal: number
shippingTotal: number
discountTotal: number
total: number
shippingAddress: Record<string, unknown>
addressId: string | null
customerNotes: string | null
adminNotes: string | null
createdBy: string | null
createdAt: string
updatedAt: string
customer: OrderCustomer
items: OrderItem[]
transactions?: OrderTransaction[]
}
export interface OrdersListResponse {
items: Order[]
total: number
page: number
pageSize: number
}
export interface ListOrdersParams {
page?: number
pageSize?: number
status?: OrderStatus
orderNumber?: string
customerQuery?: string
dateFrom?: string
dateTo?: string
minTotal?: number
maxTotal?: number
}
export interface CreateAdminOrderPayment {
type: 'pos' | 'cash' | 'transfer' | 'e_payment_gate'
amount: number
posType?: string
gatewayType?: string
transferAccount?: string
transferRefNumber?: string
notes?: string
}
export interface CreateAdminOrderPayload {
customerUserId: string
items: { storeItemVariantId: string; quantity: number }[]
customerNotes?: string
adminNotes?: string
status?: OrderStatus
payments?: CreateAdminOrderPayment[]
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/orders${suffix}`
}
export async function listOrders(params: ListOrdersParams = {}, signal?: AbortSignal) {
const q = new URLSearchParams()
if (params.page !== undefined) q.set('page', String(params.page))
if (params.pageSize !== undefined) q.set('pageSize', String(params.pageSize))
if (params.status) q.set('status', params.status)
if (params.orderNumber) q.set('orderNumber', params.orderNumber)
if (params.customerQuery) q.set('customerQuery', params.customerQuery)
if (params.dateFrom) q.set('dateFrom', params.dateFrom)
if (params.dateTo) q.set('dateTo', params.dateTo)
if (params.minTotal !== undefined) q.set('minTotal', String(params.minTotal))
if (params.maxTotal !== undefined) q.set('maxTotal', String(params.maxTotal))
const query = q.toString()
const path = `${businessPath()}${query ? `?${query}` : ''}`
return apiRequest<OrdersListResponse>(path, { auth: true, signal })
}
export async function getOrder(orderId: string, signal?: AbortSignal) {
return apiRequest<{ order: Order }>(businessPath(`/${orderId}`), { auth: true, signal })
}
export async function createAdminOrder(payload: CreateAdminOrderPayload) {
return apiRequest<{ message: string; order: Order }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function removeOrder(orderId: string) {
return apiRequest<{ message: string }>(businessPath(`/${orderId}`), {
method: 'DELETE',
auth: true,
})
}
export interface UpdateOrderPayload {
processStepId?: string
status?: OrderStatus
adminNotes?: string
}
export async function updateOrder(orderId: string, payload: UpdateOrderPayload) {
return apiRequest<{ message: string; order: Order }>(businessPath(`/${orderId}`), {
method: 'PATCH',
auth: true,
body: payload,
})
}
@@ -0,0 +1,76 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
CategoryFormData,
CreateCategoryPayload,
ProductCategoriesResponse,
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
const ENTITY_TYPE = 'portfolio' as const
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/categories${suffix}`
}
export async function listPortfolioCategories(signal?: AbortSignal) {
const data = await apiRequest<ProductCategoriesResponse>(
`${businessPath()}?entityType=${ENTITY_TYPE}`,
{ auth: true, signal },
)
return data.items
}
export async function createPortfolioCategory(payload: CreateCategoryPayload) {
return apiRequest<{ message: string; category: ProductCategory }>(businessPath(), {
method: 'POST',
auth: true,
body: { ...payload, entityType: ENTITY_TYPE },
})
}
export async function updatePortfolioCategory(categoryId: string, payload: UpdateCategoryPayload) {
return apiRequest<{ message: string; category: ProductCategory }>(
businessPath(`/${categoryId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export async function deletePortfolioCategory(categoryId: string) {
return apiRequest<{ message: string; deletedIds: string[] }>(
businessPath(`/${categoryId}`),
{
method: 'DELETE',
auth: true,
},
)
}
export function toPortfolioCategoryFormPayload(data: CategoryFormData): CreateCategoryPayload {
return {
parentId: data.parentId || undefined,
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
}
}
export function mapPortfolioCategoryToUi(category: ProductCategory) {
return {
id: category.id,
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
}
}
@@ -0,0 +1,210 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import { extractFirstImageUrl } from '../utils/htmlImages'
import type { Portfolio, PortfolioDetail, PortfolioGalleryItem, PortfolioStatus } from '../types/portfolio'
export const PORTFOLIOS_PER_PAGE = 12
export interface PortfolioApi {
id: string
businessId: string
title: string
slug: string
abstract: string
mainTextHtml: string
status: PortfolioStatus
categoryId: string | null
categoryName: string
tags: string[]
titleImageUrl: string | null
featuredMediaId: string | null
gallery: PortfolioGalleryItem[]
galleryMediaIds: string[]
sortOrder: number
commentCount: number
publishedAt: string | null
createdAt: string
updatedAt: string
}
export interface PortfoliosListResponse {
items: PortfolioApi[]
total: number
page: number
pageSize: number
}
export interface ListPortfoliosParams {
page?: number
pageSize?: number
status?: PortfolioStatus
categoryId?: string
title?: string
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/portfolios${suffix}`
}
export interface CreatePortfolioPayload {
title: string
abstract?: string
mainTextHtml?: string
categoryId?: string
status?: PortfolioStatus
featuredMediaId?: string
galleryMediaIds?: string[]
tags?: string[]
sortOrder?: number
}
export interface UpdatePortfolioPayload {
title?: string
abstract?: string | null
mainTextHtml?: string | null
categoryId?: string | null
status?: PortfolioStatus
featuredMediaId?: string | null
galleryMediaIds?: string[]
tags?: string[]
sortOrder?: number
}
export function resolvePortfolioTitleImageUrl(portfolio: PortfolioApi): string | null {
const featured = portfolio.titleImageUrl?.trim()
if (featured) return featured
const galleryUrl = portfolio.gallery?.map((item) => item.url?.trim()).find(Boolean)
if (galleryUrl) return galleryUrl
return extractFirstImageUrl(portfolio.mainTextHtml) || null
}
export function mapPortfolioApiToUi(portfolio: PortfolioApi): Portfolio {
return {
id: portfolio.id,
title: portfolio.title,
slug: portfolio.slug,
abstract: portfolio.abstract,
status: portfolio.status,
categoryId: portfolio.categoryId,
categoryName: portfolio.categoryName,
tags: portfolio.tags,
titleImageUrl: resolvePortfolioTitleImageUrl(portfolio),
commentCount: portfolio.commentCount,
publishedAt: portfolio.publishedAt,
createdAt: portfolio.createdAt,
updatedAt: portfolio.updatedAt,
}
}
export function formatPortfolioCardDate(value: string | null): string {
if (!value) return 'Not published'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export function formatPortfolioDate(value: string | null): string {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export function mapPortfolioDetailFromApi(portfolio: PortfolioApi): PortfolioDetail {
return {
...mapPortfolioApiToUi(portfolio),
mainTextHtml: portfolio.mainTextHtml,
gallery: portfolio.gallery ?? [],
}
}
export function mapPortfolioApiToFormState(portfolio: PortfolioApi) {
return {
title: portfolio.title,
abstract: portfolio.abstract,
mainTextHtml: portfolio.mainTextHtml,
categoryId: portfolio.categoryId ?? '',
tags: portfolio.tags,
titleImage: resolvePortfolioTitleImageUrl(portfolio),
featuredMediaId: portfolio.featuredMediaId,
images: portfolio.gallery.map((item) => item.url),
galleryMediaIds: portfolio.galleryMediaIds,
}
}
export async function listPortfolios(
page = 1,
pageSize = PORTFOLIOS_PER_PAGE,
signal?: AbortSignal,
filters?: Omit<ListPortfoliosParams, 'page' | 'pageSize'>,
) {
const q = new URLSearchParams()
q.set('page', String(page))
q.set('pageSize', String(pageSize))
if (filters?.status) q.set('status', filters.status)
if (filters?.categoryId) q.set('categoryId', filters.categoryId)
if (filters?.title) q.set('title', filters.title)
const data = await apiRequest<PortfoliosListResponse>(`${businessPath()}?${q.toString()}`, {
auth: true,
signal,
})
return {
...data,
items: data.items.map(mapPortfolioApiToUi),
}
}
export async function getPortfolio(portfolioId: string, signal?: AbortSignal) {
const data = await apiRequest<{ portfolio: PortfolioApi }>(businessPath(`/${portfolioId}`), {
auth: true,
signal,
})
return data.portfolio
}
export async function getPortfolioDetail(portfolioId: string, signal?: AbortSignal) {
const portfolio = await getPortfolio(portfolioId, signal)
return mapPortfolioDetailFromApi(portfolio)
}
export async function createPortfolio(payload: CreatePortfolioPayload) {
return apiRequest<{ message: string; portfolio: PortfolioApi }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updatePortfolio(portfolioId: string, payload: UpdatePortfolioPayload) {
return apiRequest<{ message: string; portfolio: PortfolioApi }>(
businessPath(`/${portfolioId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export async function deletePortfolio(portfolioId: string) {
return apiRequest<{ message: string }>(businessPath(`/${portfolioId}`), {
method: 'DELETE',
auth: true,
})
}
@@ -0,0 +1,27 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type { ProductApi } from './productService'
export type ProductAiLanguage = 'en' | 'fa'
export interface CreateProductByAiPayload {
categoryId: string
name: string
language: ProductAiLanguage
}
export async function createProductByAi(payload: CreateProductByAiPayload) {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return apiRequest<{ message: string; product: ProductApi }>(
`/businesses/${businessId}/products/ai-create`,
{
method: 'POST',
auth: true,
body: payload,
},
)
}
@@ -0,0 +1,90 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
CategoryFormData,
CreateCategoryPayload,
ProductCategoriesResponse,
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
const ENTITY_TYPE = 'product' as const
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/categories${suffix}`
}
export async function listProductCategories(signal?: AbortSignal) {
const data = await apiRequest<ProductCategoriesResponse>(
`${businessPath()}?entityType=${ENTITY_TYPE}`,
{ auth: true, signal },
)
return data.items
}
export async function createProductCategory(payload: CreateCategoryPayload) {
return apiRequest<{ message: string; category: ProductCategory }>(businessPath(), {
method: 'POST',
auth: true,
body: { ...payload, entityType: ENTITY_TYPE },
})
}
export async function updateProductCategory(
categoryId: string,
payload: UpdateCategoryPayload,
) {
return apiRequest<{ message: string; category: ProductCategory }>(
businessPath(`/${categoryId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export async function deleteProductCategory(categoryId: string) {
return apiRequest<{ message: string; deletedIds: string[] }>(
businessPath(`/${categoryId}`),
{
method: 'DELETE',
auth: true,
},
)
}
export async function generateProductCategoriesByAi(prompt: string) {
return apiRequest<{ message: string; categories: ProductCategory[] }>(
`${businessPath()}/ai-generate`,
{
method: 'POST',
auth: true,
body: { prompt },
},
)
}
export function toCategoryFormPayload(data: CategoryFormData): CreateCategoryPayload {
return {
parentId: data.parentId || undefined,
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
}
}
export function mapProductCategoryToUi(category: ProductCategory) {
return {
id: category.id,
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
}
}
@@ -0,0 +1,207 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type { Product } from '../types/product'
export const PRODUCTS_PER_PAGE = 12
export interface ProductApi {
id: string
businessId: string
title: string
nameFa: string
summary: string
descriptionHtml: string
slug: string
status: 'draft' | 'published' | 'archived'
categoryId: string | null
categoryName: string
brandId: string | null
brand: {
id: string
nameEn: string
nameFa: string | null
slug: string
imageUrl: string | null
} | null
tags: string[]
thumbnailUrl: string | null
thumbnailMediaId: string | null
image: string
thumbnail: string
images: { mediaId: string; url: string }[]
galleryMediaIds: string[]
commentCount: number
variantCount: number
createdAt: string
updatedAt: string
}
export interface ProductsListResponse {
items: ProductApi[]
total: number
page: number
pageSize: number
}
export interface CreateProductPayload {
title: string
nameFa?: string
summary?: string
descriptionHtml?: string
categoryId?: string
brandId?: string
status?: 'draft' | 'published' | 'archived'
featuredMediaId?: string
galleryMediaIds?: string[]
tags?: string[]
}
export interface UpdateProductPayload {
title?: string
nameFa?: string | null
summary?: string | null
descriptionHtml?: string | null
categoryId?: string | null
brandId?: string | null
status?: 'draft' | 'published' | 'archived'
featuredMediaId?: string | null
galleryMediaIds?: string[]
tags?: string[]
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/products${suffix}`
}
export function mapProductApiToUi(product: ProductApi): Product {
return {
id: product.id,
nameEn: product.title,
nameFa: product.nameFa,
categoryId: product.categoryId ?? '',
category: product.categoryName,
summary: product.summary,
description: product.descriptionHtml,
image: product.image || product.thumbnail || '',
thumbnail: product.thumbnail || product.thumbnailUrl || '',
images: getProductGalleryUrls(product),
tags: product.tags,
status: product.status === 'published' ? 'active' : 'draft',
commentCount: product.commentCount,
variantCount: product.variantCount ?? 0,
}
}
export function getProductGalleryUrls(product: ProductApi): string[] {
const urls: string[] = []
const featured = product.thumbnailUrl || product.thumbnail || product.image
if (featured) {
urls.push(featured)
}
for (const item of product.images) {
if (item.url && !urls.includes(item.url)) {
urls.push(item.url)
}
}
return urls
}
export function mapProductApiToFormState(product: ProductApi) {
return {
categoryId: product.categoryId ?? '',
brandId: product.brandId ?? '',
nameFa: product.nameFa,
nameEn: product.title,
summary: product.summary,
description: product.descriptionHtml,
thumbnail: product.thumbnailUrl ?? product.thumbnail ?? null,
images: product.images.map((item) => item.url),
tags: product.tags,
thumbnailMediaId: product.thumbnailMediaId,
galleryMediaIds: product.galleryMediaIds,
}
}
export async function listProducts(
page = 1,
pageSize = PRODUCTS_PER_PAGE,
signal?: AbortSignal,
filters?: { name?: string },
) {
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
})
if (filters?.name?.trim()) {
params.set('name', filters.name.trim())
}
const data = await apiRequest<ProductsListResponse>(
`${businessPath()}?${params.toString()}`,
{ auth: true, signal },
)
return {
...data,
items: data.items.map(mapProductApiToUi),
}
}
export async function listAllProducts(signal?: AbortSignal) {
const pageSize = 100
let page = 1
let items: ProductApi[] = []
let total = Infinity
while (items.length < total) {
const data = await apiRequest<ProductsListResponse>(
`${businessPath()}?page=${page}&pageSize=${pageSize}`,
{ auth: true, signal },
)
items = items.concat(data.items)
total = data.total
page += 1
}
return items
}
export async function getProduct(productId: string, signal?: AbortSignal) {
const data = await apiRequest<{ product: ProductApi }>(
businessPath(`/${productId}`),
{ auth: true, signal },
)
return data.product
}
export async function createProduct(payload: CreateProductPayload) {
return apiRequest<{ message: string; product: ProductApi }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateProduct(productId: string, payload: UpdateProductPayload) {
return apiRequest<{ message: string; product: ProductApi }>(
businessPath(`/${productId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export async function deleteProduct(productId: string) {
return apiRequest<{ message: string }>(businessPath(`/${productId}`), {
method: 'DELETE',
auth: true,
})
}
@@ -0,0 +1,70 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
CategoryTechnicalForm,
ProductTechnicalFormValues,
ProductTechnicalValue,
} from '../types/technicalForm'
function businessPath(productId: string) {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/products/${productId}/technical-info`
}
export interface ProductTechnicalInfoResponse {
form: CategoryTechnicalForm | null
values: ProductTechnicalValue[]
message?: string
}
export function valuesToFormState(
form: CategoryTechnicalForm | null,
values: ProductTechnicalValue[],
): ProductTechnicalFormValues {
const state: ProductTechnicalFormValues = {}
if (form) {
for (const field of form.fields) {
state[field.key] = field.type === 'multi_select' ? [] : ''
}
}
for (const item of values) {
if (item.value === null || item.value === undefined) continue
state[item.fieldKey] = item.value
}
return state
}
export function formStateToPayload(values: ProductTechnicalFormValues) {
return {
values: Object.entries(values)
.filter(([, value]) => {
if (Array.isArray(value)) return value.length > 0
return value !== ''
})
.map(([fieldKey, value]) => ({ fieldKey, value })),
}
}
export async function getProductTechnicalInfo(productId: string, signal?: AbortSignal) {
return apiRequest<ProductTechnicalInfoResponse>(businessPath(productId), {
auth: true,
signal,
})
}
export async function saveProductTechnicalInfo(
productId: string,
values: ProductTechnicalFormValues,
) {
return apiRequest<ProductTechnicalInfoResponse>(businessPath(productId), {
method: 'PUT',
auth: true,
body: formStateToPayload(values),
})
}
@@ -0,0 +1,80 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type { ProductVariant } from '../types/productVariant'
import type { CategoryVariationApi } from './categoryVariationsService'
export interface ProductVariantApi {
id: string
selections: {
attributeId: string
attributeName: string
optionId: string
value: string
}[]
label: string
sortOrder: number
}
export interface CreateProductVariantPayload {
variationId: string
optionId: string
}
function productVariantsPath(productId: string, suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/products/${productId}/variants${suffix}`
}
export function mapApiVariantToUi(item: ProductVariantApi): ProductVariant {
return {
id: item.id,
selections: item.selections,
label: item.label,
}
}
export function mapApiVariationsToAttributes(items: CategoryVariationApi[]) {
return items.map((item) => ({
id: item.id,
name: item.name,
type: item.type,
options: item.options,
}))
}
export type ProductVariantAttribute = ReturnType<
typeof mapApiVariationsToAttributes
>[number]
export async function listProductVariants(productId: string, signal?: AbortSignal) {
const data = await apiRequest<{ items: ProductVariantApi[] }>(
productVariantsPath(productId),
{ auth: true, signal },
)
return data.items.map(mapApiVariantToUi)
}
export async function createProductVariant(
productId: string,
payload: CreateProductVariantPayload,
) {
const data = await apiRequest<{ message: string; variant: ProductVariantApi }>(
productVariantsPath(productId),
{
method: 'POST',
auth: true,
body: payload,
},
)
return mapApiVariantToUi(data.variant)
}
export async function deleteProductVariant(productId: string, variantId: string) {
return apiRequest<{ message: string }>(productVariantsPath(productId, `/${variantId}`), {
method: 'DELETE',
auth: true,
})
}
@@ -0,0 +1,71 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type { VariationType } from '../types/variation'
export interface ProductVariationOption {
id: string
label: string
value: string
colorHex: string | null
sortOrder: number
}
export interface ProductVariationSelection {
id: string
name: string
type: VariationType
sortOrder: number
options: ProductVariationOption[]
selectedOptionIds: string[]
}
export interface ProductVariationValuesResponse {
variations: ProductVariationSelection[]
message?: string
}
export interface ProductVariationSelectionPayload {
variationId: string
optionIds: string[]
}
function businessPath(productId: string) {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/products/${productId}/variations`
}
export function countSelectedOptions(variations: ProductVariationSelection[]) {
return variations.reduce((total, variation) => total + variation.selectedOptionIds.length, 0)
}
export function selectionsToPayload(
variations: ProductVariationSelection[],
): { selections: ProductVariationSelectionPayload[] } {
return {
selections: variations.map((variation) => ({
variationId: variation.id,
optionIds: variation.selectedOptionIds,
})),
}
}
export async function getProductVariationValues(productId: string, signal?: AbortSignal) {
return apiRequest<ProductVariationValuesResponse>(businessPath(productId), {
auth: true,
signal,
})
}
export async function saveProductVariationValues(
productId: string,
variations: ProductVariationSelection[],
) {
return apiRequest<ProductVariationValuesResponse>(businessPath(productId), {
method: 'PUT',
auth: true,
body: selectionsToPayload(variations),
})
}
@@ -0,0 +1,72 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
export interface BrandingSettings {
primaryColor: BusinessPrimaryColorId
}
export interface DashboardSettings {
comments: { autoApprove: boolean }
expertReviews: { autoApprove: boolean }
}
export interface OrderProcessStep {
id: string
label: string
color: string
}
export interface StoreSettings {
onlineSellEnabled: boolean
orderProcessSteps: OrderProcessStep[]
}
export interface BusinessSettings {
branding: BrandingSettings
dashboard: DashboardSettings
store: StoreSettings
}
export interface SettingsResponse {
businessId: string
settings: BusinessSettings
}
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
{ id: 'processing', label: 'Under processing', color: '#3B82F6' },
{ id: 'ready-for-shipping', label: 'Ready for shipping', color: '#F59E0B' },
{ id: 'shipped', label: 'Shipped', color: '#8B5CF6' },
{ id: 'delivered', label: 'Delivered', color: '#22C55E' },
]
function settingsPath() {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/settings`
}
export async function getSettings(signal?: AbortSignal) {
return apiRequest<SettingsResponse>(settingsPath(), { auth: true, signal })
}
export async function updateDashboardSettings(
dashboard: Partial<DashboardSettings>,
) {
return apiRequest<SettingsResponse>(settingsPath(), {
method: 'PATCH',
auth: true,
body: { dashboard },
})
}
export async function updateStoreSettings(store: Partial<StoreSettings>) {
return apiRequest<SettingsResponse>(settingsPath(), {
method: 'PATCH',
auth: true,
body: { store },
})
}
@@ -0,0 +1,110 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
export interface ShoppingCardCustomer {
id: string
firstName: string | null
lastName: string | null
cellNumber: string
email: string | null
}
export interface ShoppingCardItem {
id: string
storeItemVariantId: string | null
productId: string
productTitle: string
variantSku: string | null
unitPrice: number
compareAtPrice: number | null
quantity: number
lineTotal: number
selections: {
variationId: string
variationName: string
optionId: string
value: string
}[]
}
export interface ShoppingCard {
id: string
businessId: string
subtotal: number
total: number
createdBy: string | null
createdAt: string
updatedAt: string
customer: ShoppingCardCustomer
items: ShoppingCardItem[]
}
export interface ShoppingCardsListResponse {
items: ShoppingCard[]
total: number
page: number
pageSize: number
}
export interface ListShoppingCardsParams {
page?: number
pageSize?: number
customerQuery?: string
dateFrom?: string
dateTo?: string
minTotal?: number
maxTotal?: number
}
export interface CreateShoppingCardPayload {
customerUserId: string
items: { storeItemVariantId: string; quantity: number }[]
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/shopping-cards${suffix}`
}
export async function listShoppingCards(
params: ListShoppingCardsParams = {},
signal?: AbortSignal,
) {
const q = new URLSearchParams()
if (params.page !== undefined) q.set('page', String(params.page))
if (params.pageSize !== undefined) q.set('pageSize', String(params.pageSize))
if (params.customerQuery) q.set('customerQuery', params.customerQuery)
if (params.dateFrom) q.set('dateFrom', params.dateFrom)
if (params.dateTo) q.set('dateTo', params.dateTo)
if (params.minTotal !== undefined) q.set('minTotal', String(params.minTotal))
if (params.maxTotal !== undefined) q.set('maxTotal', String(params.maxTotal))
const query = q.toString()
const path = `${businessPath()}${query ? `?${query}` : ''}`
return apiRequest<ShoppingCardsListResponse>(path, { auth: true, signal })
}
export async function getShoppingCard(cardId: string, signal?: AbortSignal) {
return apiRequest<{ card: ShoppingCard }>(businessPath(`/${cardId}`), {
auth: true,
signal,
})
}
export async function createShoppingCard(payload: CreateShoppingCardPayload) {
return apiRequest<{ message: string; card: ShoppingCard }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function removeShoppingCard(cardId: string) {
return apiRequest<{ message: string }>(businessPath(`/${cardId}`), {
method: 'DELETE',
auth: true,
})
}
@@ -0,0 +1,161 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
export interface StoreItemSelection {
variationId: string
variationName: string
optionId: string
value: string
}
export interface StoreItem {
id: string
storeItemId?: string
productId: string
productTitle: string
productNameFa: string
productImage: string | null
productTotalStock: number
selections: StoreItemSelection[]
label: string
price: number | null
discountedPrice: number | null
stockQuantity: number | null
rewardPoints: number | null
isFestival: boolean
sortOrder: number
createdAt: string
}
export interface StoreItemsListResponse {
items: StoreItem[]
total: number
page: number
pageSize: number
}
export interface CreateStoreItemPayload {
selections: { variationId: string; optionId: string }[]
price?: number
stockQuantity?: number
}
export interface BatchCreateStoreItemsPayload {
productId: string
items: CreateStoreItemPayload[]
}
export interface UpdateStoreItemPayload {
price?: number
stockQuantity?: number
discountedPrice?: number | null
isFestival?: boolean
}
export interface BatchUpdateStoreItemDiscountsPayload {
productId: string
items: { id: string; discountedPrice: number | null }[]
}
export interface SyncStoreItemRowPayload {
id?: string
selections: { variationId: string; optionId: string }[]
price: number
stockQuantity: number
}
export interface SyncProductStoreItemsPayload {
productId: string
items: SyncStoreItemRowPayload[]
removedIds: string[]
}
export interface BatchUpdateFestivalRewardsPayload {
productId: string
items: { id: string; rewardPoints: number | null }[]
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/store-items${suffix}`
}
export async function listStoreItems(page = 1, pageSize = 20, signal?: AbortSignal) {
return apiRequest<StoreItemsListResponse>(
`${businessPath()}?page=${page}&pageSize=${pageSize}`,
{ auth: true, signal },
)
}
export async function listAllStoreItems(signal?: AbortSignal) {
const pageSize = 100
let page = 1
let items: StoreItem[] = []
let total = Infinity
while (items.length < total) {
const data = await listStoreItems(page, pageSize, signal)
items = items.concat(data.items)
total = data.total
page += 1
}
return items
}
export async function batchCreateStoreItems(payload: BatchCreateStoreItemsPayload) {
return apiRequest<{ message: string; items: StoreItem[] }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateStoreItem(itemId: string, payload: UpdateStoreItemPayload) {
return apiRequest<{ message: string; item: StoreItem }>(businessPath(`/${itemId}`), {
method: 'PATCH',
auth: true,
body: payload,
})
}
export async function updateStoreItemDiscounts(payload: BatchUpdateStoreItemDiscountsPayload) {
return apiRequest<{ message: string; items: StoreItem[] }>(businessPath('/discounts'), {
method: 'PUT',
auth: true,
body: payload,
})
}
export async function syncProductStoreItems(payload: SyncProductStoreItemsPayload) {
return apiRequest<{ message: string; items: StoreItem[] }>(businessPath('/sync'), {
method: 'PUT',
auth: true,
body: payload,
})
}
export async function updateStoreItemFestivalRewards(payload: BatchUpdateFestivalRewardsPayload) {
return apiRequest<{ message: string; items: StoreItem[] }>(businessPath('/festival-rewards'), {
method: 'PUT',
auth: true,
body: payload,
})
}
export async function deleteStoreItem(itemId: string) {
return apiRequest<{ message: string }>(businessPath(`/${itemId}`), {
method: 'DELETE',
auth: true,
})
}
export async function deleteStoreItemsByProduct(productId: string) {
return apiRequest<{ message: string }>(businessPath(`/by-product/${productId}`), {
method: 'DELETE',
auth: true,
})
}
@@ -0,0 +1,62 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type { StoreSpecial } from '../types/storeSpecial'
export interface StoreSpecialsListResponse {
items: StoreSpecial[]
total: number
page: number
pageSize: number
}
export interface CreateStoreSpecialPayload {
title: string
storeItemIds?: string[]
sortOrder?: number
isActive?: boolean
}
export interface UpdateStoreSpecialPayload {
title?: string
storeItemIds?: string[]
sortOrder?: number
isActive?: boolean
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/store-specials${suffix}`
}
export async function listStoreSpecials(page = 1, pageSize = 50, signal?: AbortSignal) {
return apiRequest<StoreSpecialsListResponse>(
`${businessPath()}?page=${page}&pageSize=${pageSize}`,
{ auth: true, signal },
)
}
export async function createStoreSpecial(payload: CreateStoreSpecialPayload) {
return apiRequest<{ message: string; special: StoreSpecial }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateStoreSpecial(specialId: string, payload: UpdateStoreSpecialPayload) {
return apiRequest<{ message: string; special: StoreSpecial }>(businessPath(`/${specialId}`), {
method: 'PATCH',
auth: true,
body: payload,
})
}
export async function deleteStoreSpecial(specialId: string) {
return apiRequest<{ message: string }>(businessPath(`/${specialId}`), {
method: 'DELETE',
auth: true,
})
}
@@ -0,0 +1,16 @@
import { apiRequest } from '../lib/api'
export interface ResolvedTenant {
id: string
name: string
nameFa: string | null
slug: string
domain: string
logoUrl?: string | null
faviconUrl?: string | null
}
export async function resolveTenantByDomain(host: string) {
const encodedHost = encodeURIComponent(host)
return apiRequest<ResolvedTenant>(`/tenants/${encodedHost}`)
}
@@ -0,0 +1,49 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
CreateWebsiteBrandGroupPayload,
UpdateWebsiteBrandGroupPayload,
WebsiteBrandGroup,
WebsiteBrandGroupsListResponse,
} from '../types/websiteBrandGroup'
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/website/brand-groups${suffix}`
}
export async function listWebsiteBrandGroups(page = 1, pageSize = 50, signal?: AbortSignal) {
return apiRequest<WebsiteBrandGroupsListResponse>(
`${businessPath()}?page=${page}&pageSize=${pageSize}`,
{ auth: true, signal },
)
}
export async function createWebsiteBrandGroup(payload: CreateWebsiteBrandGroupPayload) {
return apiRequest<{ message: string; group: WebsiteBrandGroup }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateWebsiteBrandGroup(
groupId: string,
payload: UpdateWebsiteBrandGroupPayload,
) {
return apiRequest<{ message: string; group: WebsiteBrandGroup }>(businessPath(`/${groupId}`), {
method: 'PATCH',
auth: true,
body: payload,
})
}
export async function deleteWebsiteBrandGroup(groupId: string) {
return apiRequest<{ message: string }>(businessPath(`/${groupId}`), {
method: 'DELETE',
auth: true,
})
}
@@ -0,0 +1,52 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
CreateWebsiteCategoryGroupPayload,
UpdateWebsiteCategoryGroupPayload,
WebsiteCategoryGroup,
WebsiteCategoryGroupsListResponse,
} from '../types/websiteCategoryGroup'
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/website/category-groups${suffix}`
}
export async function listWebsiteCategoryGroups(page = 1, pageSize = 50, signal?: AbortSignal) {
return apiRequest<WebsiteCategoryGroupsListResponse>(
`${businessPath()}?page=${page}&pageSize=${pageSize}`,
{ auth: true, signal },
)
}
export async function createWebsiteCategoryGroup(payload: CreateWebsiteCategoryGroupPayload) {
return apiRequest<{ message: string; group: WebsiteCategoryGroup }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateWebsiteCategoryGroup(
groupId: string,
payload: UpdateWebsiteCategoryGroupPayload,
) {
return apiRequest<{ message: string; group: WebsiteCategoryGroup }>(
businessPath(`/${groupId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export async function deleteWebsiteCategoryGroup(groupId: string) {
return apiRequest<{ message: string }>(businessPath(`/${groupId}`), {
method: 'DELETE',
auth: true,
})
}
@@ -0,0 +1,62 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
CreateWebsiteSliderPayload,
UpdateWebsiteSliderPayload,
WebsiteSlider,
WebsiteSlidersListResponse,
} from '../types/websiteSlider'
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/website/sliders${suffix}`
}
export async function listWebsiteSliders(page = 1, pageSize = 50, signal?: AbortSignal) {
return apiRequest<WebsiteSlidersListResponse>(
`${businessPath()}?page=${page}&pageSize=${pageSize}`,
{ auth: true, signal },
)
}
export async function createWebsiteSlider(payload: CreateWebsiteSliderPayload) {
return apiRequest<{ message: string; slider: WebsiteSlider }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateWebsiteSlider(sliderId: string, payload: UpdateWebsiteSliderPayload) {
return apiRequest<{ message: string; slider: WebsiteSlider }>(businessPath(`/${sliderId}`), {
method: 'PATCH',
auth: true,
body: payload,
})
}
export async function deleteWebsiteSlider(sliderId: string) {
return apiRequest<{ message: string }>(businessPath(`/${sliderId}`), {
method: 'DELETE',
auth: true,
})
}
export function slidesToPayload(
slides: Array<{
imageMediaId: string
title?: string | null
linkUrl?: string | null
isActive?: boolean
}>,
) {
return slides.map((slide) => ({
imageMediaId: slide.imageMediaId,
...(slide.title ? { title: slide.title } : {}),
...(slide.linkUrl ? { linkUrl: slide.linkUrl } : {}),
isActive: slide.isActive ?? true,
}))
}