Add customer and business user-product flows across dashboards.

Ship my-products / customer-products UI with gallery uploads, status controls, module gating, and related shared UI polish.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-10 00:23:03 +03:30
co-authored by Cursor
parent c5bdaad16b
commit 1271d96539
96 changed files with 10532 additions and 345 deletions
@@ -18,3 +18,17 @@ export async function listCitiesByProvinceSlug(parentSlug: string, signal?: Abor
)
return data.items
}
export async function listCountries(signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>('/cities?level=country', { signal })
return data.items
}
export async function listCitiesByCountrySlug(parentSlug: string, signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>(
`/cities?level=city&parentSlug=${encodeURIComponent(parentSlug)}`,
{ signal },
)
return data.items
}
@@ -0,0 +1,218 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type { UserProductListItem, UserProductStatus } from '../types/userProduct'
export type UserProductCondition = 'new' | 'stock' | 'needs_repair' | 'scrap'
export type UserProductPriceCurrency = 'IRT' | 'USD' | 'EUR' | 'AED'
export type TechnicalFieldType = 'text' | 'textarea' | 'select' | 'multi_select'
export interface UserProductCategoryOption {
id: string
name: string
nameFa: string | null
parentId: string | null
}
export interface TechnicalFormFieldOption {
id: string
label: string
value: string
sortOrder: number
}
export interface TechnicalFormField {
id: string
label: string
key: string
type: TechnicalFieldType
isRequired: boolean
sortOrder: number
options: TechnicalFormFieldOption[]
}
export interface CategoryTechnicalForm {
id: string
categoryId: string
fields: TechnicalFormField[]
}
export type TechnicalFormValues = Record<string, string | string[]>
export interface UserProductTechnicalValueInput {
fieldId: string
textValue?: string
optionId?: string
optionIds?: string[]
}
export interface CreateUserProductInput {
titleFa: string
titleEn?: string
description?: string
categoryId: string
price?: number
priceCurrency?: UserProductPriceCurrency
priceByExpert?: boolean
countryId: string
cityId: string
deliveryNote?: string
condition: UserProductCondition
technicalNotes?: string
technicalValues?: UserProductTechnicalValueInput[]
featuredMediaId?: string
galleryMediaIds?: string[]
}
export type UpdateUserProductInput = CreateUserProductInput
export interface UserProductDetail extends UserProductListItem {
countryId: string
cityId: string
countrySlug: string
featuredMediaId: string | null
galleryMediaIds: string[]
images: Array<{ mediaId: string; url: string }>
technicalValues: UserProductTechnicalValueInput[]
deliveryNote?: string | null
technicalNotes?: string | null
description?: string | null
titleEn?: string | null
priceCurrency?: string | null
priceByExpert?: boolean
promoted?: boolean
condition?: string | null
categoryId?: string | null
}
export interface ListUserProductsParams {
page?: number
pageSize?: number
status?: UserProductStatus
}
export interface UserProductsListResponse {
items: UserProductListItem[]
total: number
page: number
pageSize: number
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/user-products${suffix}`
}
export function buildTechnicalValuesPayload(
fields: TechnicalFormField[],
values: TechnicalFormValues,
): UserProductTechnicalValueInput[] {
const payload: UserProductTechnicalValueInput[] = []
for (const field of fields) {
const value = values[field.id]
if (field.type === 'text' || field.type === 'textarea') {
const text = typeof value === 'string' ? value.trim() : ''
if (!text) continue
payload.push({ fieldId: field.id, textValue: text })
continue
}
if (field.type === 'select') {
const optionId = typeof value === 'string' ? value.trim() : ''
if (!optionId) continue
payload.push({ fieldId: field.id, optionId })
continue
}
const optionIds = Array.isArray(value) ? value.filter(Boolean) : []
if (!optionIds.length) continue
payload.push({ fieldId: field.id, optionIds })
}
return payload
}
export async function listUserProducts(
params: ListUserProductsParams = {},
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)
const query = q.toString()
return apiRequest<UserProductsListResponse>(
`${businessPath()}${query ? `?${query}` : ''}`,
{ auth: true, signal },
)
}
export async function getUserProduct(productId: string, signal?: AbortSignal) {
return apiRequest<{ product: UserProductDetail }>(
businessPath(`/${productId}`),
{ auth: true, signal },
)
}
export async function createUserProduct(input: CreateUserProductInput) {
return apiRequest<{ message: string; product: UserProductListItem }>(
businessPath(),
{ method: 'POST', auth: true, body: input },
)
}
export async function updateUserProduct(
productId: string,
input: UpdateUserProductInput,
) {
return apiRequest<{ message: string; product: UserProductListItem }>(
businessPath(`/${productId}`),
{ method: 'PATCH', auth: true, body: input },
)
}
export async function updateUserProductStatus(
productId: string,
status: UserProductStatus,
) {
return apiRequest<{ message: string; product: UserProductListItem }>(
businessPath(`/${productId}/status`),
{ method: 'PATCH', auth: true, body: { status } },
)
}
export async function promoteUserProduct(productId: string) {
return apiRequest<{ message: string; product: UserProductListItem }>(
businessPath(`/${productId}/promote`),
{ method: 'POST', auth: true },
)
}
export async function deleteUserProduct(productId: string) {
return apiRequest<{ message: string }>(businessPath(`/${productId}`), {
method: 'DELETE',
auth: true,
})
}
export async function listUserProductCategories(signal?: AbortSignal) {
return apiRequest<{ items: UserProductCategoryOption[] }>(
businessPath('/categories'),
{ auth: true, signal },
)
}
export async function getUserProductCategoryTechnicalForm(
categoryId: string,
signal?: AbortSignal,
) {
return apiRequest<{ form: CategoryTechnicalForm | null }>(
businessPath(`/categories/${categoryId}/technical-form`),
{ auth: true, signal },
)
}