import { useMemo, useRef, useState } from 'react';
const API_BASE = 'https://api.bizmori.com/api/v2';
const API_TOKEN = import.meta.env.VITE_MORI_API_TOKEN;
const ACCEPT_FORMATS = '.jpg,.jpeg,.png,.webp,.tiff,.bmp,.pdf';
const MAX_WATERMARKS = 10;
const MAX_TEXT_LENGTH = 1000;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function api(path, { method = 'GET', body, idempotencyKey } = {}) {
// Retries reuse the caller's idempotency key, so a retried create can never
// produce a second order. Auth failures surface as `AUTH_*` codes — retrying
// those will not help, so they fall through to the throw below.
for (let attempt = 0; ; attempt++) {
const res = await fetch(`${API_BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${API_TOKEN}`,
...(body && { 'Content-Type': 'application/json' }),
},
body: body && JSON.stringify({ idempotencyKey, ...body }),
});
if (!res.ok) {
const { code } = await res.json().catch(() => ({}));
// 429 covers two cases: a transient rate limit, and PLAN_LIMIT_EXCEEDED,
// which means the plan quota is spent and will never clear on retry.
if (res.status === 429 && code !== 'PLAN_LIMIT_EXCEEDED' && attempt < 3) {
await sleep(2 ** attempt * 1000);
continue;
}
throw new Error(code ?? `HTTP_${res.status}`);
}
return (await res.json()).data;
}
}
function putFile(file, uploadUrl, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', uploadUrl);
xhr.setRequestHeader('Content-Type', file.type);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress(event.loaded / event.total);
};
xhr.onload = () =>
xhr.status < 300 ? resolve() : reject(new Error(`UPLOAD_FAILED_${xhr.status}`));
xhr.onerror = () => reject(new Error('UPLOAD_NETWORK_ERROR'));
xhr.send(file);
});
}
async function pollOrder(orderId, { onStatus, timeoutMs = 10 * 60 * 1000 } = {}) {
const deadline = Date.now() + timeoutMs;
let delay = 2000;
while (Date.now() < deadline) {
const order = await api(`/orders/${orderId}`);
onStatus?.(order.status);
if (['complete', 'failed', 'expired'].includes(order.status)) return order;
await sleep(delay);
delay = Math.min(delay * 1.5, 15000);
}
throw new Error('ORDER_POLL_TIMEOUT');
}
export default function WatermarkEmbedUploader() {
const [file, setFile] = useState(null);
// Lazy initializer: without it, crypto.randomUUID() would run on every render.
const [watermarks, setWatermarks] = useState(() => [{ id: crypto.randomUUID(), text: '' }]);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState('idle');
const [downloadUrl, setDownloadUrl] = useState(null);
const [error, setError] = useState(null);
const [orderId, setOrderId] = useState(null);
const inFlight = useRef(false);
const busy = status !== 'idle' && status !== 'complete';
// Flags every text after the first occurrence as a duplicate. Blank rows
// are exempt — they're already caught by the "no blank text" rule below.
const duplicateIds = useMemo(() => {
const seen = new Map();
const duplicates = new Set();
watermarks.forEach((watermark) => {
const text = watermark.text.trim();
if (text === '') return;
if (seen.has(text)) duplicates.add(watermark.id);
else seen.set(text, watermark.id);
});
return duplicates;
}, [watermarks]);
const canSubmit =
file !== null &&
watermarks.length > 0 &&
watermarks.every((watermark) => watermark.text.trim() !== '') &&
duplicateIds.size === 0 &&
!busy;
function updateWatermark(id, text) {
setWatermarks((prev) => prev.map((watermark) => (watermark.id === id ? { ...watermark, text } : watermark)));
}
function addWatermark() {
if (watermarks.length >= MAX_WATERMARKS) return;
setWatermarks((prev) => [...prev, { id: crypto.randomUUID(), text: '' }]);
}
function removeWatermark(id) {
setWatermarks((prev) => prev.filter((watermark) => watermark.id !== id));
}
async function embedWatermarks(event) {
event.preventDefault();
if (inFlight.current || !canSubmit) return;
inFlight.current = true;
// Snapshot the selection. The user can edit the form while requests are
// in flight, and the upload must use the values as they were on submit.
const selectedFile = file;
const selectedWatermarks = watermarks;
setError(null);
setOrderId(null);
setDownloadUrl(null);
setProgress(0);
try {
setStatus('creating order');
const order = await api('/orders/wtr-embed', {
method: 'POST',
idempotencyKey: crypto.randomUUID(),
body: {
files: [
{
fileName: selectedFile.name,
watermarks: selectedWatermarks.map((watermark) => ({ text: watermark.text.trim() })),
},
],
},
});
setOrderId(order.orderId);
setStatus('uploading');
await putFile(selectedFile, order.files[0].uploadUrl, setProgress);
// No confirm step — processing starts automatically once the upload completes.
const finished = await pollOrder(order.orderId, { onStatus: setStatus });
// `expired` is terminal too: the cleanup job drops the files after 7 days.
if (finished.status !== 'complete') throw new Error(`ORDER_${finished.status.toUpperCase()}`);
setStatus('fetching download URL');
const { url } = await api(`/orders/${order.orderId}/download`);
setDownloadUrl(url);
setStatus('complete');
} catch (caught) {
setError(caught.message);
setStatus('idle');
} finally {
inFlight.current = false;
}
}
return (
<form onSubmit={embedWatermarks}>
<input
type="file"
accept={ACCEPT_FORMATS}
disabled={busy}
onChange={(event) => {
setFile(event.target.files[0] ?? null);
setError(null);
setProgress(0);
setDownloadUrl(null);
}}
/>
<ul>
{watermarks.map((watermark, index) => (
<li key={watermark.id}>
<input
type="text"
value={watermark.text}
maxLength={MAX_TEXT_LENGTH}
disabled={busy}
placeholder={`Watermark text #${index + 1}`}
onChange={(event) => updateWatermark(watermark.id, event.target.value)}
style={{ borderColor: duplicateIds.has(watermark.id) ? 'red' : undefined }}
/>
<button type="button" disabled={busy || watermarks.length === 1} onClick={() => removeWatermark(watermark.id)}>
Remove
</button>
</li>
))}
</ul>
<button type="button" disabled={busy || watermarks.length >= MAX_WATERMARKS} onClick={addWatermark}>
Add watermark text ({watermarks.length}/{MAX_WATERMARKS})
</button>
<button type="submit" disabled={!canSubmit}>
Embed watermark{watermarks.length === 1 ? '' : 's'}
</button>
{status !== 'idle' && <p>Status: {status}</p>}
{status === 'uploading' && <p>Upload progress: {Math.round(progress * 100)}%</p>}
{downloadUrl && (
<a href={downloadUrl} download>
Download watermarked image
</a>
)}
{error && <p role="alert">Failed: {error}{orderId && ` (orderId: ${orderId})`}</p>}
</form>
);
}