diff --git a/apps/webapp/app/components/assets/bulk-actions-dropdown.tsx b/apps/webapp/app/components/assets/bulk-actions-dropdown.tsx
index f4aaa41f1a..174a2efb5e 100644
--- a/apps/webapp/app/components/assets/bulk-actions-dropdown.tsx
+++ b/apps/webapp/app/components/assets/bulk-actions-dropdown.tsx
@@ -245,7 +245,7 @@ function ConditionalDropdown() {
width="full"
>
- Download QR Codes
+ Export QR labels
diff --git a/apps/webapp/app/components/assets/bulk-download-qr-dialog.test.tsx b/apps/webapp/app/components/assets/bulk-download-qr-dialog.test.tsx
deleted file mode 100644
index bd0bff2128..0000000000
--- a/apps/webapp/app/components/assets/bulk-download-qr-dialog.test.tsx
+++ /dev/null
@@ -1,290 +0,0 @@
-/**
- * Regression tests for {@link BulkDownloadQrDialog}.
- *
- * The dialog is permanently mounted on the asset index (only the `isDialogOpen`
- * prop toggles), so its fetch state survives the user closing it, changing the
- * active filter/selection, and reopening it. Two distinct bugs are guarded here:
- *
- * 1. Stale-cache reuse: a second "Download QR codes" after a filter change must
- * fetch fresh data for the now-current filters, not reuse the first response.
- * 2. Superseded slow response: dismissing the loading dialog mid-fetch and
- * starting a new download must NOT let the first (slow) response complete and
- * zip the previous filter's assets — the newest request always wins.
- *
- * Observable, implementation-agnostic signals: each download issues a fetch
- * whose URL reflects the then-current params, and only the latest request's
- * assets are ever rasterized into the zip.
- *
- * @see {@link file://./bulk-download-qr-dialog.tsx}
- */
-
-import { act, render, screen, waitFor } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { Provider, createStore } from "jotai";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { selectedBulkItemsAtom } from "~/atoms/list";
-import type { ListItemData } from "~/components/list/list-item";
-import type { BulkQrDownloadLoaderData } from "~/routes/api+/assets.get-assets-for-bulk-qr-download";
-import BulkDownloadQrDialog from "./bulk-download-qr-dialog";
-
-/**
- * Hoisted, mutable state read by the (hoisted) `vi.mock` factories:
- * - `searchParams`: what the mocked `useSearchParams` returns (the active filter)
- * - `renderedTitles`: titles of every asset actually rasterized into a zip, so a
- * test can assert which request's assets were processed.
- */
-const hoisted = vi.hoisted(() => ({
- searchParams: new URLSearchParams(),
- renderedTitles: [] as string[],
-}));
-
-// why: the dialog imports `useSearchParams` from this cookie/org-context-aware
-// wrapper; in a unit test we only need it to surface the current filter params
-// so the dialog folds them into the request URL.
-vi.mock("~/hooks/search-params", () => ({
- useSearchParams: () => [hoisted.searchParams, vi.fn()] as const,
-}));
-
-// why: replace only `useLoaderData` so the dialog reads a small `totalItems`
-// (keeping the "more than 100" branch off) without running a real route loader.
-vi.mock("react-router", async (importActual) => {
- const actual = await importActual>();
- return { ...actual, useLoaderData: () => ({ totalItems: 5 }) };
-});
-
-// why: happy-dom cannot rasterize a DOM node to an image, so html-to-image's
-// toBlob would throw. Resolve a tiny blob so the download path completes.
-vi.mock("html-to-image", () => ({
- toBlob: vi.fn(() => Promise.resolve(new Blob(["x"], { type: "image/jpeg" }))),
-}));
-
-// why: avoids rendering via renderToStaticMarkup (pulls in QR-codec
-// deps irrelevant to this test). It also records the title of each asset that
-// reaches rasterization, which is how the race test proves WHICH request's
-// assets were zipped.
-vi.mock("~/utils/component-to-html", () => ({
- generateHtmlFromComponent: (element: { props?: { title?: string } }) => {
- if (element?.props?.title) hoisted.renderedTitles.push(element.props.title);
- return document.createElement("div");
- },
-}));
-
-// why: QrLabel is only ever passed to the (mocked) generateHtmlFromComponent,
-// never rendered here, and its module chain (AddBarcodeDialog -> scan-barcode-tab
-// -> scanner -> lottie-web) touches canvas at import time and crashes under
-// happy-dom. A stub cuts that chain.
-vi.mock("~/components/code-preview/code-preview", () => ({
- QrLabel: (props: { title?: string }) => props as unknown as null,
-}));
-
-// why: zip generation is irrelevant to the assertions and its Blob plumbing is
-// unreliable under happy-dom; a no-op archive lets processDownload reach its
-// success state deterministically.
-vi.mock("jszip", () => {
- class FakeZip {
- folder() {
- return { file: () => undefined };
- }
- file() {
- return undefined;
- }
- generateAsync() {
- return Promise.resolve(new Blob(["zip"]));
- }
- }
- return { default: FakeZip };
-});
-
-/** Records every URL the dialog requests. */
-const fetchSpy = vi.fn();
-
-/**
- * Controls how the mocked `fetch` resolves:
- * - "auto": resolve immediately with a payload matching the URL's assetIds.
- * - "manual": defer; the test resolves `pending[i]` by hand to control ordering.
- */
-const fetchControl = {
- mode: "auto" as "auto" | "manual",
- pending: [] as Array<{
- url: string;
- resolve: (data: BulkQrDownloadLoaderData) => void;
- }>,
-};
-
-/** Builds a valid loader payload whose assets match the requested ids. */
-function payloadFor(assetIds: string[]): BulkQrDownloadLoaderData {
- return {
- assets: assetIds.map((id) => ({
- id,
- title: `Asset ${id}`,
- sequentialId: null,
- createdAt: new Date("2026-01-01T00:00:00.000Z"),
- qr: {
- id: `qr-${id}`,
- src: "data:image/png;base64,xxx",
- size: "medium" as const,
- },
- })),
- qrIdDisplayPreference: "QR_ID",
- showShelfBranding: true,
- };
-}
-
-beforeEach(() => {
- fetchControl.mode = "auto";
- fetchControl.pending = [];
- hoisted.searchParams = new URLSearchParams();
- hoisted.renderedTitles = [];
-
- // why: install the fetch spy AFTER MSW's interception (mirrors
- // use-api-query.test.ts) so the dialog's request is captured here and never
- // reaches MSW (which errors on unhandled requests).
- vi.spyOn(globalThis, "fetch").mockImplementation(((
- input: RequestInfo | URL
- ) => {
- const url = String(input);
- fetchSpy(url);
- const ids = new URL(url, "http://localhost").searchParams.getAll(
- "assetIds"
- );
- if (fetchControl.mode === "manual") {
- return new Promise((resolve) => {
- fetchControl.pending.push({
- url,
- resolve: (data) =>
- resolve({ json: () => Promise.resolve(data) } as Response),
- });
- });
- }
- return Promise.resolve({
- json: () => Promise.resolve(payloadFor(ids)),
- } as Response);
- }) as typeof fetch);
- fetchSpy.mockClear();
-
- // why: happy-dom does not implement object URLs; processDownload calls both.
- globalThis.URL.createObjectURL = vi.fn(() => "blob:mock");
- globalThis.URL.revokeObjectURL = vi.fn();
-});
-
-afterEach(() => {
- vi.restoreAllMocks();
-});
-
-/**
- * Renders the dialog under a dedicated jotai store (so selection state persists
- * across rerenders) with the dialog kept open for the whole test — both bugs
- * only reproduce while component state survives close/reopen.
- */
-function renderDialog() {
- const store = createStore();
- const onClose = vi.fn();
- const utils = render(
-
-
-
- );
- return { store, onClose, ...utils };
-}
-
-/** Sets the active filter params + selected assets, flushing React effects. */
-async function setFilterAndSelection(
- store: ReturnType,
- filterQuery: string,
- assetIds: string[]
-) {
- hoisted.searchParams = new URLSearchParams(filterQuery);
- await act(async () => {
- store.set(
- selectedBulkItemsAtom,
- assetIds.map((id) => ({ id }) as unknown as ListItemData)
- );
- // Flush microtask-scheduled effects (useMemo recompute) so the dialog
- // observes the new filter + selection before we interact with it.
- await Promise.resolve();
- });
-}
-
-describe("BulkDownloadQrDialog", () => {
- it("refetches with the current params on a second download after filters change", async () => {
- const user = userEvent.setup();
- const { store } = renderDialog();
-
- /* ---------- Download #1: category A, assets a1 + a2 ---------- */
- await setFilterAndSelection(store, "category=cat-A", ["a1", "a2"]);
-
- await user.click(await screen.findByRole("button", { name: "Download" }));
- await screen.findByText(/successfully downloaded qr codes/i);
-
- expect(fetchSpy).toHaveBeenCalledTimes(1);
- const firstUrl = fetchSpy.mock.calls[0][0] as string;
- expect(firstUrl).toContain("category=cat-A");
- expect(firstUrl).toContain("assetIds=a1");
- expect(firstUrl).toContain("assetIds=a2");
-
- /* ---------- Close to re-show the Download button (state persists) ---------- */
- await user.click(screen.getByRole("button", { name: "Close" }));
-
- /* ---------- Change filter to tag A, assets b9 ---------- */
- await setFilterAndSelection(store, "tag=tag-A", ["b9"]);
-
- /* ---------- Download #2 ---------- */
- await user.click(await screen.findByRole("button", { name: "Download" }));
-
- // Fixed code issues a fresh fetch for the new params; the original bug
- // reused the cached response and never fetched again.
- await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(2));
-
- const secondUrl = fetchSpy.mock.calls[1][0] as string;
- expect(secondUrl).toContain("tag=tag-A");
- expect(secondUrl).toContain("assetIds=b9");
- expect(secondUrl).not.toContain("category=cat-A");
- expect(secondUrl).not.toContain("assetIds=a1");
- expect(secondUrl).not.toEqual(firstUrl);
- });
-
- it("ignores a slow superseded response and only zips the latest request's assets", async () => {
- // Manually control fetch resolution to simulate a slow first request that
- // resolves AFTER the user dismissed it and started a second download.
- fetchControl.mode = "manual";
- const user = userEvent.setup();
- const { store } = renderDialog();
-
- /* ---------- Download #1: category A (will resolve LATE) ---------- */
- await setFilterAndSelection(store, "category=cat-A", ["a1", "a2"]);
- await user.click(await screen.findByRole("button", { name: "Download" }));
- await waitFor(() => expect(fetchControl.pending).toHaveLength(1));
-
- /* ---------- Dismiss the loading dialog mid-flight via the header X ---------- */
- // The header close button is not gated by the loading state (unlike the body
- // Close/Download buttons), so it — like Escape/backdrop — can cancel an
- // in-flight download.
- await user.click(screen.getByRole("button", { name: /close dialog/i }));
-
- /* ---------- Download #2: tag A (the current request) ---------- */
- await setFilterAndSelection(store, "tag=tag-A", ["b9"]);
- await user.click(await screen.findByRole("button", { name: "Download" }));
- await waitFor(() => expect(fetchControl.pending).toHaveLength(2));
-
- /* ---------- The superseded request resolves FIRST, then the current one ---------- */
- await act(async () => {
- fetchControl.pending[0].resolve(payloadFor(["a1", "a2"]));
- await Promise.resolve();
- });
- await act(async () => {
- fetchControl.pending[1].resolve(payloadFor(["b9"]));
- await Promise.resolve();
- });
-
- await waitFor(() =>
- expect(
- screen.getByText(/successfully downloaded qr codes/i)
- ).toBeInTheDocument()
- );
-
- // Only the latest request's assets are rasterized; the stale ones never are.
- expect(hoisted.renderedTitles).toContain("Asset b9");
- expect(hoisted.renderedTitles).not.toContain("Asset a1");
- expect(hoisted.renderedTitles).not.toContain("Asset a2");
- });
-});
diff --git a/apps/webapp/app/components/assets/bulk-download-qr-dialog.tsx b/apps/webapp/app/components/assets/bulk-download-qr-dialog.tsx
index 38fb3477cc..87273dfc70 100644
--- a/apps/webapp/app/components/assets/bulk-download-qr-dialog.tsx
+++ b/apps/webapp/app/components/assets/bulk-download-qr-dialog.tsx
@@ -1,20 +1,36 @@
-import { useState, useMemo, useCallback, useRef } from "react";
-import { toBlob } from "html-to-image";
+/**
+ * Bulk QR Export dialog — the two-journey hub.
+ *
+ * Opened from Actions ▸ "Export QR labels" on the asset index. Fetches the
+ * selected assets' resolved label data, then offers two opinionated journeys:
+ * - **Print labels** — `` (react-to-print) plain-paper sheet. Most users.
+ * - **Export for my label printer** — a zip of vector `.svg` labels + a
+ * `manifest.csv`, built from {@link buildLabelZipEntries}. Label-printer users.
+ *
+ * Replaces the old raster path entirely: no `html-to-image`, no `changedpi`, no
+ * `.jpg`, no 100-item cap. Labels are vector and the codes are resolver-driven.
+ *
+ * @see {@link file://./qr-label-sheet.tsx}
+ * @see {@link file://./../../modules/qr/label.ts}
+ * @see {@link file://./../../routes/api+/assets.get-assets-for-bulk-qr-download.ts}
+ */
+import { useMemo, useRef, useState } from "react";
import { useAtomValue } from "jotai";
import JSZip from "jszip";
-import { DownloadIcon } from "lucide-react";
+import { DownloadIcon, FileText, Printer, Sparkles } from "lucide-react";
import { useLoaderData } from "react-router";
import { selectedBulkItemsAtom } from "~/atoms/list";
+import { QrLabelSheet } from "~/components/assets/qr-label-sheet";
+import { UpgradeMessage } from "~/components/marketing/upgrade-message";
import { useSearchParams } from "~/hooks/search-params";
+import useApiQuery from "~/hooks/use-api-query";
+import { buildLabelZipEntries } from "~/modules/qr/label";
+import type { AssetIndexLoaderData } from "~/routes/_layout+/assets._index";
import type { BulkQrDownloadLoaderData } from "~/routes/api+/assets.get-assets-for-bulk-qr-download";
-import { generateHtmlFromComponent } from "~/utils/component-to-html";
import { isSelectingAllItems } from "~/utils/list";
-import { sanitizeFilename } from "~/utils/misc";
-import { QrLabel } from "../code-preview/code-preview";
import { Dialog, DialogPortal } from "../layout/dialog";
import { Button } from "../shared/button";
import { Spinner } from "../shared/spinner";
-import When from "../when/when";
type BulkDownloadQrDialogProps = {
className?: string;
@@ -22,305 +38,249 @@ type BulkDownloadQrDialogProps = {
onClose: () => void;
};
-type DownloadState =
+type ZipState =
| { status: "idle" }
- | { status: "loading" }
- | { status: "success" }
+ | { status: "building" }
+ | { status: "done" }
| { status: "error"; error: string };
+/**
+ * @param props.isDialogOpen - controls visibility
+ * @param props.onClose - close handler (resets internal view)
+ */
export default function BulkDownloadQrDialog({
className,
isDialogOpen,
onClose,
}: BulkDownloadQrDialogProps) {
- const { totalItems } = useLoaderData<{ totalItems: number }>();
-
- const [downloadState, setDownloadState] = useState({
- status: "idle",
- });
+ const [view, setView] = useState<"choose" | "pdf">("choose");
+ const [zip, setZip] = useState({ status: "idle" });
const [searchParams] = useSearchParams();
- /**
- * Monotonically increasing id of the most recent download request.
- *
- * The dialog stays mounted while the user filters/re-selects behind it (only
- * `isDialogOpen` toggles), and a download can be dismissed (header X, Escape,
- * backdrop) while its fetch is still in flight. A single boolean can't tell
- * which request a late response belongs to once a newer request has started,
- * so every click (and every close) bumps this token; a resolution is only
- * acted on while its captured id still matches the latest one.
- */
- const requestIdRef = useRef(0);
-
- /**
- * AbortController for the in-flight request, so starting a new download (or
- * closing the dialog) aborts the previous fetch instead of leaving it to
- * resolve and zip stale assets.
- */
- const abortControllerRef = useRef(null);
+ // Paid feature: gated behind the asset-export entitlement (same as CSV export).
+ const { canExportAssets } = useLoaderData();
const selectedAssets = useAtomValue(selectedBulkItemsAtom);
const allAssetsSelected = isSelectingAllItems(selectedAssets);
- const isSelectingMoreThan100 =
- selectedAssets.length > 100 || (allAssetsSelected && totalItems > 100);
-
- const disabled =
- selectedAssets.length === 0 || downloadState.status === "loading";
-
- function handleClose() {
- // Supersede and abort any in-flight request so a late resolution cannot
- // trigger a download after the dialog has been dismissed.
- requestIdRef.current += 1;
- abortControllerRef.current?.abort();
- abortControllerRef.current = null;
- setDownloadState({ status: "idle" });
- onClose();
- }
-
- // Prepare API query parameters
+ // Build the query: current filters + each selected asset id (ALL_SELECTED_KEY
+ // included when selecting all, so the loader re-applies the index filters).
const apiSearchParams = useMemo(() => {
if (selectedAssets.length === 0) return undefined;
-
const query = new URLSearchParams(searchParams);
- selectedAssets.forEach((asset) => {
- query.append("assetIds", asset.id);
- });
+ selectedAssets.forEach((asset) => query.append("assetIds", asset.id));
return query;
}, [selectedAssets, searchParams]);
- /**
- * Builds the QR zip from the assets returned for THIS request and triggers the
- * browser download.
- *
- * The freshly fetched payload is passed in as an argument and never read from
- * the query cache, so the zip always contains the assets matching the request
- * the user just made — not a response cached from a previous filter/selection.
- *
- * @param data - Asset + QR payload for the current selection/filters
- * @param requestId - Token of the request that produced `data`; the browser
- * download is skipped if a newer request has since superseded this one.
- */
- const processDownload = useCallback(
- async (data: BulkQrDownloadLoaderData, requestId: number) => {
- try {
- const { assets, qrIdDisplayPreference, showShelfBranding } = data;
-
- const zip = new JSZip();
- const qrFolder = zip.folder("qr-codes");
-
- /* Converting our React component to html so that we can later convert it into an image */
- const qrNodes = assets.map((asset) =>
- generateHtmlFromComponent(
-
- )
- );
-
- const toBlobOptions = {
- width: 300,
- height: 300,
- backgroundColor: "white",
- style: {
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- textAlign: "center",
- },
- };
-
- /**
- * We are converting first qr to image separately because toBlob will cache the font
- * and will not make further network requests for other qr codes.
- */
- const firstQrImage = await toBlob(qrNodes[0], toBlobOptions);
-
- /* Converting all qr nodes into images */
- const qrImages = await Promise.all(
- qrNodes.slice(1).map((qrNode) => toBlob(qrNode, toBlobOptions))
- );
-
- /* Appending qr code image to zip file */
- [firstQrImage, ...qrImages].forEach((qrImage, index) => {
- const asset = assets[index];
-
- // Generate filename based on preference
- let filename: string;
- if (qrIdDisplayPreference === "SAM_ID" && asset.sequentialId) {
- filename = `${asset.sequentialId}_${sanitizeFilename(
- asset.title
- )}_${asset.qr.id}.jpg`;
- } else {
- filename = `${sanitizeFilename(asset.title)}_${asset.qr.id}.jpg`;
- }
-
- if (!qrImage) {
- return;
- }
-
- if (qrFolder) {
- qrFolder.file(filename, qrImage);
- } else {
- zip.file(filename, qrImage);
- }
- });
-
- const zipBlob = await zip.generateAsync({ type: "blob" });
-
- // A newer request may have superseded this one while we were
- // rasterizing; if so, drop this download silently.
- if (requestId !== requestIdRef.current) return;
-
- const downloadLink = document.createElement("a");
-
- downloadLink.href = URL.createObjectURL(zipBlob);
- downloadLink.download = `qr-codes-${new Date().getTime()}.zip`;
-
- downloadLink.click();
-
- setTimeout(() => {
- URL.revokeObjectURL(downloadLink.href);
- }, 4e4);
-
- setDownloadState({ status: "success" });
- } catch (error) {
- // A superseded request must not clobber the UI with its own error.
- if (requestId !== requestIdRef.current) return;
- setDownloadState({
- status: "error",
- error:
- error instanceof Error ? error.message : "Something went wrong.",
- });
- }
- },
- []
- );
-
- /**
- * Starts a download for the CURRENT selection/filters.
- *
- * Each click supersedes any in-flight request (bumping the token and aborting
- * the previous fetch) and fetches fresh data directly, so a slower earlier
- * response can never be processed in place of this one. We fetch imperatively
- * here — rather than via `useApiQuery` — precisely because this flow needs
- * per-request cancellation that a shared, cache-retaining query hook can't
- * provide.
- */
- async function handleBulkDownloadQr() {
- if (isSelectingMoreThan100 || !apiSearchParams) {
- return;
- }
+ const { data, isLoading } = useApiQuery({
+ api: "/api/assets/get-assets-for-bulk-qr-download",
+ searchParams: apiSearchParams,
+ // Don't even fetch for free users — the loader would 403; show the upsell instead.
+ enabled: isDialogOpen && canExportAssets && !!apiSearchParams,
+ });
- // Supersede any in-flight request before starting a fresh one.
- requestIdRef.current += 1;
- const requestId = requestIdRef.current;
- abortControllerRef.current?.abort();
- const controller = new AbortController();
- abortControllerRef.current = controller;
+ // Bumped on every close so an in-flight zip build that resolves AFTER the
+ // dialog was dismissed can't trigger a stray download or flip state to "done".
+ const buildTokenRef = useRef(0);
- setDownloadState({ status: "loading" });
+ function handleClose() {
+ buildTokenRef.current += 1;
+ setView("choose");
+ setZip({ status: "idle" });
+ onClose();
+ }
+ async function downloadSvgZip() {
+ if (!data) return;
+ const token = buildTokenRef.current;
+ setZip({ status: "building" });
try {
- const response = await fetch(
- `/api/assets/get-assets-for-bulk-qr-download?${apiSearchParams.toString()}`,
- { signal: controller.signal }
- );
- const data = (await response.json()) as BulkQrDownloadLoaderData;
-
- // Ignore the response if a newer request superseded this one in flight.
- if (requestId !== requestIdRef.current) {
- return;
- }
-
- await processDownload(data, requestId);
- } catch (error) {
- // Aborted/superseded requests resolve here too; only the latest one may
- // surface an error.
- if (requestId !== requestIdRef.current) {
- return;
- }
- setDownloadState({
+ const archive = new JSZip();
+ buildLabelZipEntries({
+ assets: data.assets,
+ qrBaseUrl: data.qrBaseUrl,
+ showBranding: data.showBranding,
+ }).forEach((entry) => archive.file(entry.path, entry.content));
+
+ const blob = await archive.generateAsync({ type: "blob" });
+ // Closed mid-build: abandon silently rather than download after dismissal.
+ if (token !== buildTokenRef.current) return;
+ const link = document.createElement("a");
+ link.href = URL.createObjectURL(blob);
+ link.download = `qr-codes-${Date.now()}.zip`;
+ link.click();
+ setTimeout(() => URL.revokeObjectURL(link.href), 4e4);
+ setZip({ status: "done" });
+ } catch (cause) {
+ if (token !== buildTokenRef.current) return;
+ setZip({
status: "error",
- error: error instanceof Error ? error.message : "Something went wrong.",
+ error: cause instanceof Error ? cause.message : "Something went wrong.",
});
}
}
+ // The loader can return an error payload (e.g. a select-all over the export
+ // limit). useApiQuery surfaces it as `data` without an `assets` array, so
+ // guard before reading `data.assets` instead of crashing.
+ const hasAssets = Array.isArray((data as { assets?: unknown })?.assets);
+
+ // Only read `data.assets.length` once we know `data` is a success payload —
+ // an error payload is truthy but has no `assets`, so `data?.assets` alone
+ // would throw on `.length` before the `hasAssets` guard below can apply.
+ const count = allAssetsSelected
+ ? hasAssets
+ ? data!.assets.length
+ : 0
+ : selectedAssets.length;
+
+ const apiErrorMessage =
+ data && !hasAssets
+ ? (data as { error?: { message?: string } }).error?.message ??
+ "Something went wrong preparing the labels."
+ : null;
+
return (