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:
co-authored by
Cursor
parent
c5bdaad16b
commit
1271d96539
@@ -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)}`,
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user