Files
dashboards/apps/business/src/services/mediaService.ts
T
Alireza HassaniandCursor 6d85a217e1 Add OTP-first customer signup and product catalog PDF field.
Registration collects name and phone, verifies SMS, then asks for a password only for new Meshkee accounts. Product add/edit supports an optional catalog PDF.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 08:57:08 +03:30

198 lines
5.4 KiB
TypeScript

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,
}))
}
const CATALOG_PDF_MAX_BYTES = 2 * 1024 * 1024
export async function uploadCatalogPdf(file: File, signal?: AbortSignal) {
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
throw new Error('Catalog file must be a PDF')
}
if (file.size > CATALOG_PDF_MAX_BYTES) {
throw new Error('Catalog PDF must be 2MB or smaller')
}
const uploaded = await uploadMediaFiles([file], signal)
const item = uploaded[0]
if (!item) {
throw new Error('Catalog upload failed')
}
return item
}
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 function galleryMediaIdsAfterChange(
previousImages: string[],
previousIds: string[],
nextImages: string[],
): string[] {
const prevUrlToId = new Map(
previousImages.map((url, index) => [url, previousIds[index] ?? '']),
)
return nextImages.map((url) =>
isDataUrl(url) ? '' : prevUrlToId.get(url) ?? '',
)
}
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
}