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
+21 -2
View File
@@ -3,14 +3,33 @@ import type { CityOption } from '@meshkee/dashboard-ui'
export type { CityOption }
export async function listIranProvinces(signal?: AbortSignal) {
export async function listCountries(signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>('/cities?level=country', { signal })
return data.items
}
/** Cities under a country (direct + via provinces). Province is optional in the tree. */
export async function listCitiesByCountrySlug(parentSlug: string, signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>(
'/cities?level=province&parentSlug=iran',
`/cities?level=city&parentSlug=${encodeURIComponent(parentSlug)}`,
{ signal },
)
return data.items
}
export async function listProvincesByCountrySlug(parentSlug: string, signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>(
`/cities?level=province&parentSlug=${encodeURIComponent(parentSlug)}`,
{ signal },
)
return data.items
}
/** @deprecated Prefer listProvincesByCountrySlug('iran') */
export async function listIranProvinces(signal?: AbortSignal) {
return listProvincesByCountrySlug('iran', signal)
}
export async function listCitiesByProvinceSlug(parentSlug: string, signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>(
`/cities?level=city&parentSlug=${encodeURIComponent(parentSlug)}`,
+155
View File
@@ -0,0 +1,155 @@
import {
clearTokens,
getAccessToken,
setTokens,
} from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import { ensureJpegUploadFile, ensureUploadFile } from '../utils/imageUpload'
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api/v1'
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 businessMediaPath() {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/my-user-products/media`
}
async function refreshAccessToken() {
const refreshToken = localStorage.getItem('meshkee_customer_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 uploadMyUserProductMedia(
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}${businessMediaPath()}`, {
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 uploadMyUserProductMedia([file])
return uploaded[0]?.id ?? null
}
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,
`user-product-image-${index + 1}.jpg`,
)
const uploaded = await uploadMyUserProductMedia([file])
if (uploaded[0]) resolved.push(uploaded[0].id)
} else if (existingMediaIds[index]) {
resolved.push(existingMediaIds[index])
}
}
return resolved
}
@@ -1,6 +1,9 @@
import { apiRequest } from '../lib/api'
import type { DashboardLocale } from '@meshkee/dashboard-core'
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
import type { BusinessModuleId } from '../utils/businessModules'
export type DashboardThemeMode = 'light' | 'dark'
export interface ResolvedTenant {
id: string
@@ -10,6 +13,8 @@ export interface ResolvedTenant {
domain: string
primaryColor: BusinessPrimaryColorId
defaultLocale?: DashboardLocale
themeMode?: DashboardThemeMode
enabledModules?: BusinessModuleId[]
logoUrl?: string | null
faviconUrl?: string | null
}
@@ -0,0 +1,213 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type { UserProductListItem } 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
}
export interface ListMyUserProductsParams {
page?: number
pageSize?: number
}
export interface MyUserProductsListResponse {
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}/my-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 listMyUserProducts(
params: ListMyUserProductsParams = {},
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))
const query = q.toString()
return apiRequest<MyUserProductsListResponse>(
`${businessPath()}${query ? `?${query}` : ''}`,
{ auth: true, signal },
)
}
export async function createMyUserProduct(input: CreateUserProductInput) {
return apiRequest<{ message: string; product: UserProductListItem }>(
businessPath(),
{
method: 'POST',
auth: true,
body: input,
},
)
}
export async function getMyUserProduct(
productId: string,
signal?: AbortSignal,
) {
return apiRequest<{ product: UserProductDetail }>(
businessPath(`/${productId}`),
{ auth: true, signal },
)
}
export async function updateMyUserProduct(
productId: string,
input: UpdateUserProductInput,
) {
return apiRequest<{ message: string; product: UserProductListItem }>(
businessPath(`/${productId}`),
{
method: 'PATCH',
auth: true,
body: input,
},
)
}
export async function deleteMyUserProduct(productId: string) {
return apiRequest<{ message: string }>(businessPath(`/${productId}`), {
method: 'DELETE',
auth: true,
})
}
export async function promoteMyUserProduct(productId: string) {
return apiRequest<{ message: string; product: UserProductListItem }>(
businessPath(`/${productId}/promote`),
{
method: 'POST',
auth: true,
},
)
}
export async function listMyUserProductCategories(signal?: AbortSignal) {
return apiRequest<{ items: UserProductCategoryOption[] }>(
businessPath('/categories'),
{ auth: true, signal },
)
}
export async function getMyUserProductCategoryTechnicalForm(
categoryId: string,
signal?: AbortSignal,
) {
return apiRequest<{ form: CategoryTechnicalForm | null }>(
businessPath(`/categories/${categoryId}/technical-form`),
{ auth: true, signal },
)
}