function enc(value) { return encodeURIComponent(String(value)); } function buildUrl(baseUrl, path) { return new URL(path, baseUrl || window.location.origin).href; } function buildPath(path, query) { if (!query) return path; const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { if (value == null) continue; params.set(key, String(value)); } const qs = params.toString(); return qs ? `${path}?${qs}` : path; } /** * Thin HTTP client for the dashback API (cookie auth, JSON / blob / empty). */ export class ApiClient { #baseUrl; constructor(baseUrl = window.location.origin) { this.#baseUrl = baseUrl; this.auth = { /** POST /api/auth/login */ login: (body) => this.#json('POST', '/api/auth/login', { body }), /** POST /api/auth/logout */ logout: () => this.#json('POST', '/api/auth/logout'), /** GET /api/auth/me */ me: () => this.#json('GET', '/api/auth/me'), /** PUT /api/auth/selected-company */ selectedCompany: (body) => this.#json('PUT', '/api/auth/selected-company', { body }), }; this.health = { /** GET /api/health */ get: () => this.#json('GET', '/api/health'), }; this.company = { /** GET /api/company */ list: () => this.#json('GET', '/api/company'), /** POST /api/company */ create: (body) => this.#json('POST', '/api/company', { body }), /** PUT /api/company/{id} */ update: (id, body) => this.#json('PUT', `/api/company/${enc(id)}`, { body }), /** DELETE /api/company/{id} */ delete: (id) => this.#empty('DELETE', `/api/company/${enc(id)}`), }; this.unit = { /** GET /api/unit */ list: () => this.#json('GET', '/api/unit'), /** PUT /api/unit */ upsert: (body) => this.#json('PUT', '/api/unit', { body }), /** DELETE /api/unit?si=… */ delete: (si) => this.#empty('DELETE', '/api/unit', { query: { si } }), }; this.brand = { /** GET /api/brand */ list: () => this.#json('GET', '/api/brand'), /** PUT /api/brand */ upsert: (body) => this.#json('PUT', '/api/brand', { body }), /** DELETE /api/brand/{id} */ delete: (id) => this.#empty('DELETE', `/api/brand/${enc(id)}`), }; this.category = { /** GET /api/category/tree */ tree: () => this.#json('GET', '/api/category/tree'), /** GET /api/category/export.zip */ exportZip: () => this.#blob('GET', '/api/category/export.zip'), /** POST /api/category */ create: (body) => this.#json('POST', '/api/category', { body }), /** GET /api/category/{id}/articles */ articles: (id) => this.#json('GET', `/api/category/${enc(id)}/articles`), /** PATCH /api/category/{id} */ update: (id, body) => this.#json('PATCH', `/api/category/${enc(id)}`, { body }), /** DELETE /api/category/{id} */ delete: (id) => this.#empty('DELETE', `/api/category/${enc(id)}`), /** PUT /api/category/order */ order: (body) => this.#json('PUT', '/api/category/order', { body }), /** POST /api/category/{id}/image — raw AVIF body */ addImage: (id, avifBytes) => this.#json('POST', `/api/category/${enc(id)}/image`, { body: avifBytes, contentType: 'image/avif', raw: true, }), /** DELETE /api/category/{id}/image */ deleteImage: (id) => this.#json('DELETE', `/api/category/${enc(id)}/image`), }; this.article = { /** GET /api/articleIndex */ index: () => this.#json('GET', '/api/articleIndex'), /** POST /api/article */ create: (body) => this.#json('POST', '/api/article', { body }), /** GET /api/article/export.zip */ exportZip: (query = {}) => this.#blob('GET', '/api/article/export.zip', { query }), /** GET /api/article/{sku} */ get: (sku) => this.#json('GET', `/api/article/${enc(sku)}`), /** PATCH /api/article/{sku} */ update: (sku, body) => this.#json('PATCH', `/api/article/${enc(sku)}`, { body }), /** DELETE /api/article/{sku} */ delete: (sku) => this.#empty('DELETE', `/api/article/${enc(sku)}`), /** PUT /api/article/{sku}/prices */ setPrices: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/prices`, { body }), /** POST /api/article/{sku}/image — raw AVIF body */ addImage: (sku, avifBytes) => this.#json('POST', `/api/article/${enc(sku)}/image`, { body: avifBytes, contentType: 'image/avif', raw: true, }), /** DELETE /api/article/{sku}/image/{fullHash} */ deleteImage: (sku, fullHash) => this.#empty('DELETE', `/api/article/${enc(sku)}/image/${enc(fullHash)}`), /** PUT /api/article/{sku}/images/order */ orderImages: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/images/order`, { body }), /** PUT /api/article/{sku}/eans */ setEans: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/eans`, { body }), /** PUT /api/article/{sku}/brand */ setBrand: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/brand`, { body }), /** PUT /api/article/{sku}/weight */ setWeight: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/weight`, { body }), /** PUT /api/article/{sku}/categories */ setCategories: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/categories`, { body }), }; this.posterminal = { /** GET /api/posterminal */ list: () => this.#json('GET', '/api/posterminal'), /** PUT /api/posterminal */ upsert: (body) => this.#json('PUT', '/api/posterminal', { body }), /** DELETE /api/posterminal/{id} */ delete: (id) => this.#empty('DELETE', `/api/posterminal/${enc(id)}`), /** PUT /api/posterminal/{id}/category/{categoryId} */ setOn: (id, categoryId) => this.#json('PUT', `/api/posterminal/${enc(id)}/category/${enc(categoryId)}`), /** DELETE /api/posterminal/{id}/category/{categoryId} */ setOff: (id, categoryId) => this.#json('DELETE', `/api/posterminal/${enc(id)}/category/${enc(categoryId)}`), /** PUT /api/posterminal/{id}/all */ setOnAll: (id) => this.#json('PUT', `/api/posterminal/${enc(id)}/all`), /** DELETE /api/posterminal/{id}/all */ setOffAll: (id) => this.#json('DELETE', `/api/posterminal/${enc(id)}/all`), }; this.image = { /** GET /api/image/{fullHash} → AVIF blob */ get: (fullHash) => this.#blob('GET', `/api/image/${enc(fullHash)}`), /** Absolute URL for (same-origin / cookie auth). */ url: (fullHash) => buildUrl(this.#baseUrl, `/api/image/${enc(fullHash)}`), }; this.thumb = { /** GET /api/thumb/{fullHash} → AVIF blob */ get: (fullHash) => this.#blob('GET', `/api/thumb/${enc(fullHash)}`), /** Absolute URL for (same-origin / cookie auth). */ url: (fullHash) => buildUrl(this.#baseUrl, `/api/thumb/${enc(fullHash)}`), }; } /** Absolute URL for a same-origin API path (e.g. SSE). */ urlFor(path) { return buildUrl(this.#baseUrl, path); } async #json(method, path, { body, query, contentType, raw } = {}) { const url = buildPath(path, query); const headers = {}; let payload; if (body !== undefined) { if (raw) { headers['Content-Type'] = contentType; payload = body; } else { headers['Content-Type'] = contentType || 'application/json'; payload = JSON.stringify(body); } } const res = await fetch(url, { method, headers, credentials: 'include', body: payload, }); if (!res.ok) { let message = res.statusText; let data; try { data = await res.json(); message = data.error || data.message || message; } catch { message = (await res.text().catch(() => message)) || message; } const err = new Error(message || `Request failed (${res.status})`); err.status = res.status; err.path = url; err.method = method; err.data = data; throw err; } if (res.status === 204) return null; return res.json(); } async #empty(method, path, { query } = {}) { await this.#json(method, path, { query }); return null; } async #blob(method, path, { query } = {}) { const url = buildPath(path, query); const res = await fetch(url, { method, credentials: 'include', }); if (!res.ok) { let message = res.statusText; let data; try { data = await res.json(); message = data.error || data.message || message; } catch { message = (await res.text().catch(() => message)) || message; } const err = new Error(message || `Request failed (${res.status})`); err.status = res.status; err.path = url; err.method = method; err.data = data; throw err; } return res.blob(); } } export default ApiClient;