diff --git a/frontend/src/screens/products/ProductsPage.tsx b/frontend/src/screens/products/ProductsPage.tsx
index b8de3f6..ace39e4 100644
--- a/frontend/src/screens/products/ProductsPage.tsx
+++ b/frontend/src/screens/products/ProductsPage.tsx
@@ -3,6 +3,8 @@
import { useEffect, useState } from "react";
import {
dialectLabel,
+ EXPORT_STATUSES,
+ exportUrl,
getProductsSummary,
listRuns,
type ProductsSummary,
@@ -63,12 +65,31 @@ export default function ProductsPage() {
{!empty && · {summary.product_count.toLocaleString()}}
-
-
- Export arrives in a coming release
-
+ {empty ? (
+
+
+ Export arrives when you have products
+
+ ) : (
+
+
+ Export
+
+
+ {EXPORT_STATUSES.map((s) => (
+ -
+ {/* A real download: the browser navigates to the streamed
+ endpoint and saves the attachment (PUC-9). */}
+
+ {s.label}
+
+
+ ))}
+
+
+ )}
Import products
diff --git a/frontend/src/screens/products/exportMenu.test.ts b/frontend/src/screens/products/exportMenu.test.ts
new file mode 100644
index 0000000..63e4e79
--- /dev/null
+++ b/frontend/src/screens/products/exportMenu.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { EXPORT_STATUSES, exportUrl } from "../../productsApi";
+import { isExportEnabled } from "./exportMenu";
+
+describe("export menu", () => {
+ it("disables export for an empty catalog and enables it once there are products", () => {
+ expect(isExportEnabled(0)).toBe(false);
+ expect(isExportEnabled(3)).toBe(true);
+ });
+
+ it("builds the four status download URLs, 'all' first", () => {
+ expect(EXPORT_STATUSES.map((s) => exportUrl(s.value))).toEqual([
+ "/api/products/export?status=all",
+ "/api/products/export?status=active",
+ "/api/products/export?status=draft",
+ "/api/products/export?status=archived",
+ ]);
+ });
+});
diff --git a/frontend/src/screens/products/exportMenu.ts b/frontend/src/screens/products/exportMenu.ts
new file mode 100644
index 0000000..6640c8a
--- /dev/null
+++ b/frontend/src/screens/products/exportMenu.ts
@@ -0,0 +1,11 @@
+// Pure logic behind the Products page Export menu (SD-0002 §5.2, PUC-9). The
+// status list + URL builder live in productsApi.ts (re-used by the component);
+// this is the one decision the menu turns on — whether export is offered at all.
+// The disclosure JSX is verified by the E2E suite, matching SLICE-2/3's pattern
+// of pure-logic unit tests + E2E for screens.
+
+// Export is offered only when the catalog has products (PUC-9: an empty catalog
+// shows a disabled button + note instead).
+export function isExportEnabled(count: number): boolean {
+ return count > 0;
+}
diff --git a/frontend/src/styles/products.css b/frontend/src/styles/products.css
index d59d556..57f976f 100644
--- a/frontend/src/styles/products.css
+++ b/frontend/src/styles/products.css
@@ -53,9 +53,52 @@
}
.products__count { color: var(--text-on-dark-mute); font-weight: var(--weight-medium); }
.products__actions { display: flex; gap: 12px; align-items: center; }
-/* Disabled Export + its visible "coming release" caption, stacked. */
+/* Disabled Export + its visible "no products yet" caption, stacked (empty catalog). */
.products__export { display: flex; flex-direction: column; gap: 4px; align-items: center; }
.products__export .note { font-size: 11.5px; }
+
+/* Export status menu (SD-0002 §5.2 — PUC-9). A native
disclosure so
+ it's keyboard-accessible with no extra JS (§6.6). Tokens align with the design
+ bundle (--surface-raised / --border-card / --radius-panel); the fallbacks keep
+ it working regardless. */
+.products__export.menu {
+ position: relative;
+ display: inline-block;
+}
+.products__export.menu > summary {
+ list-style: none;
+ cursor: pointer;
+}
+.products__export.menu > summary::-webkit-details-marker {
+ display: none;
+}
+.menu__list {
+ position: absolute;
+ right: 0;
+ z-index: 10;
+ margin: 0.25rem 0 0;
+ padding: 0.25rem;
+ list-style: none;
+ background: var(--surface-raised, #fff);
+ border: 1px solid var(--border-card, #d8d3c8);
+ border-radius: var(--radius-panel, 8px);
+ box-shadow: var(--shadow-soft, 0 6px 20px rgba(0, 0, 0, 0.12));
+ min-width: 10rem;
+}
+.menu__item {
+ display: block;
+ padding: 0.5rem 0.75rem;
+ border-radius: var(--radius-sm, 6px);
+ text-decoration: none;
+ color: var(--text-on-dark-soft, inherit);
+ font-family: var(--wv-font-display);
+ font-size: 14px;
+}
+.menu__item:hover,
+.menu__item:focus {
+ background: var(--surface-raised-hi, #f3efe7);
+ color: var(--wv-starlight);
+}
.products .btn-primary { width: auto; text-decoration: none; }
.products .empty { margin: 24px auto 0; }