Includes business, customer, and super-admin apps with shared packages and production deploy scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
168 lines
4.6 KiB
TypeScript
168 lines
4.6 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,
|
|
}))
|
|
}
|
|
|
|
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
|
|
}
|