@burdenoff/microfe-coolandlovely 2026.1031.1 → 2026.1031.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"useFiles.js","names":[],"sources":["../../../src/coolandlovely/hooks/useFiles.ts"],"sourcesContent":["/**\n * File hooks — image upload + display via wspace-files-svc (NO duplication).\n * @module coolandlovely/hooks\n *\n * CoolAndLovely never stores blobs. Brand logos/covers, collection covers,\n * style-item photos, look photos, wardrobe photos and lookbook covers are\n * wspace-files `File` records referenced by id (the coo* inputs take *FileId;\n * the coo* types expose logo/cover/image: File resolved via federation).\n *\n * Upload uses the SAME graphqlFetch transport as every other coo* call — NOT\n * Apollo — driving the wspace-files 3-step presigned flow directly:\n * ensureFolderByPath -> initiateUpload -> HTTP PUT -> completeUpload\n * Display resolves a File id to a short-lived presigned URL via\n * getFileDownloadUrl.\n *\n * Two fleet gotchas are handled here:\n * (a) `fileSize` is REQUIRED on completeUpload even though this MFE's generated\n * `CompleteUploadInput` is stale and omits it. Because we send a hand-written\n * query with `Record<string, unknown>` variables (not typed against the\n * generated input) we simply include it and it works at runtime.\n * (b) `getFileDownloadUrl(expiresIn)` must be <= 604800 (S3 7-day cap → 500).\n * We use 21600 (6h); do NOT raise it.\n */\n\nimport { useQuery } from '@tanstack/react-query';\nimport { graphqlFetch } from '@burdenoff/fe-libs/shared/graphql';\nimport { clearGraphqlFetchResponseCache } from '@burdenoff/fe-libs/shared/graphql/fetch';\n\nimport { useCoolAndLovely } from '../context';\n\n/**\n * 6 hours. getFileDownloadUrl rejects TTLs > 604800 (S3 7-day cap → 500); the\n * safe fleet ceiling is 21600. Do NOT raise this.\n */\nconst DOWNLOAD_URL_TTL_SECONDS = 21_600;\n\n/** Per-entity folders in wspace-files. */\nexport const COO_FILE_FOLDERS = {\n brands: '/CoolAndLovely/Brands',\n collections: '/CoolAndLovely/Collections',\n items: '/CoolAndLovely/Items',\n looks: '/CoolAndLovely/Looks',\n wardrobe: '/CoolAndLovely/Wardrobe',\n lookbooks: '/CoolAndLovely/Lookbooks',\n} as const;\n\nexport type CooFileFolder = (typeof COO_FILE_FOLDERS)[keyof typeof COO_FILE_FOLDERS];\n\nexport interface UploadedImage {\n id: string;\n url: string;\n name: string;\n}\n\n/** Thin wrapper over the workspace gateway using the MFE's proven transport. */\nasync function filesFetch<TData>(\n query: string,\n variables: Record<string, unknown>,\n baseUrl?: string\n): Promise<TData> {\n const response = await graphqlFetch<TData>({\n gateway: 'workspace',\n baseUrl,\n query,\n variables,\n workspaceToken: true,\n });\n if (response.errors?.length) {\n throw new Error(response.errors[0]?.message ?? 'Files request failed');\n }\n if (!response.data) {\n throw new Error('Files response did not include data');\n }\n // Drop fe-libs' ~15s query response cache after a mutation so a follow-up\n // getFileDownloadUrl reads fresh (mirrors coolQuery in useCoolAndLovelyApi).\n if (/^\\s*mutation\\b/.test(query)) {\n clearGraphqlFetchResponseCache();\n }\n return response.data;\n}\n\nconst ENSURE_FOLDER = `\n mutation CooEnsureFolder($path: String!) {\n ensureFolderByPath(path: $path) { id }\n }\n`;\n\nconst INITIATE_UPLOAD = `\n mutation CooInitiateUpload($input: InitiateUploadInput!) {\n initiateUpload(input: $input) {\n uploadUrl\n session { id }\n }\n }\n`;\n\nconst COMPLETE_UPLOAD = `\n mutation CooCompleteUpload($input: CompleteUploadInput!) {\n completeUpload(input: $input) {\n fileId\n file { id name }\n }\n }\n`;\n\nconst GET_FILE_DOWNLOAD_URL = `\n query CooGetFileDownloadUrl($id: ID!, $expiresIn: Int) {\n getFileDownloadUrl(id: $id, expiresIn: $expiresIn)\n }\n`;\n\nfunction fileExtension(name: string, mimeType: string): string {\n const dot = name.lastIndexOf('.');\n if (dot > -1 && dot < name.length - 1) return name.slice(dot + 1).toLowerCase();\n return (mimeType.split('/')[1] ?? 'bin').toLowerCase();\n}\n\n/** Upload progress emitted by {@link uploadImageToFilesWithProgress}. */\nexport interface UploadProgress {\n /** Bytes transferred so far. */\n loaded: number;\n /** Total bytes to transfer. */\n total: number;\n /** Rounded percentage complete, 0–100. */\n pct: number;\n}\n\n/**\n * PUT the bytes straight to storage via XMLHttpRequest so `upload.onprogress`\n * can surface a real progress bar (the presigned upload URL points at object\n * storage, so a plain PUT is all that is needed — no auth header). Content-Type\n * must match what was sent to `initiateUpload`.\n */\nfunction putWithProgress(\n uploadUrl: string,\n file: File,\n mimeType: string,\n onProgress?: (progress: UploadProgress) => void\n): Promise<void> {\n return new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest();\n xhr.open('PUT', uploadUrl, true);\n xhr.setRequestHeader('Content-Type', mimeType);\n if (onProgress) {\n xhr.upload.onprogress = (event: ProgressEvent) => {\n const total = event.lengthComputable ? event.total : file.size;\n const loaded = Math.min(event.loaded, total);\n const pct = total > 0 ? Math.round((loaded / total) * 100) : 0;\n onProgress({ loaded, total, pct });\n };\n }\n xhr.onload = () => {\n if (xhr.status >= 200 && xhr.status < 300) {\n onProgress?.({ loaded: file.size, total: file.size, pct: 100 });\n resolve();\n } else {\n reject(new Error(`Upload to storage failed (${xhr.status}).`));\n }\n };\n xhr.onerror = () => reject(new Error('Upload to storage failed — a network error occurred.'));\n xhr.onabort = () => reject(new Error('Upload was cancelled.'));\n xhr.send(file);\n });\n}\n\n/**\n * The full presigned upload flow (ensureFolder -> initiateUpload -> PUT ->\n * completeUpload -> download URL). Shared by both public upload helpers; the\n * PUT step reports progress when `onProgress` is supplied.\n */\nasync function runUpload(\n file: File,\n baseUrl?: string,\n folderPath: string = COO_FILE_FOLDERS.items,\n onProgress?: (progress: UploadProgress) => void\n): Promise<UploadedImage> {\n const mimeType = file.type || 'application/octet-stream';\n const fileSize = String(file.size); // BigInt scalar — serialize as string.\n\n // 1. Resolve (or create) the destination folder. Best-effort: fall back to\n // the files root (folderId is optional on both initiate + complete).\n let folderId: string | undefined;\n try {\n const folder = await filesFetch<{ ensureFolderByPath?: { id: string } | null }>(\n ENSURE_FOLDER,\n { path: folderPath },\n baseUrl\n );\n folderId = folder.ensureFolderByPath?.id ?? undefined;\n } catch {\n folderId = undefined;\n }\n\n // 2. Open a presigned upload session.\n const initiated = await filesFetch<{\n initiateUpload?: { uploadUrl?: string | null; session: { id: string } } | null;\n }>(\n INITIATE_UPLOAD,\n {\n input: {\n fileName: file.name,\n extension: fileExtension(file.name, mimeType),\n fileSize, // required, BigInt-as-string\n mimeType,\n ...(folderId ? { folderId } : {}),\n },\n },\n baseUrl\n );\n const uploadUrl = initiated.initiateUpload?.uploadUrl;\n const sessionId = initiated.initiateUpload?.session.id;\n if (!uploadUrl || !sessionId) {\n throw new Error('The files service did not return an upload URL.');\n }\n\n // 3. PUT the bytes straight to storage (XHR → progress). Content-Type must\n // match initiateUpload.\n await putWithProgress(uploadUrl, file, mimeType, onProgress);\n\n // 4. Finalize — creates the File record. fileSize is REQUIRED here even though\n // the MFE's generated CompleteUploadInput is stale and omits it.\n const completed = await filesFetch<{\n completeUpload?: { fileId: string; file: { id: string; name: string } } | null;\n }>(\n COMPLETE_UPLOAD,\n {\n input: {\n sessionId,\n fileSize, // <-- stale-type gotcha: the live schema requires this\n ...(folderId ? { folderId } : {}),\n },\n },\n baseUrl\n );\n const record = completed.completeUpload?.file;\n const newId = completed.completeUpload?.fileId ?? record?.id;\n if (!newId) {\n throw new Error('The upload could not be finalized.');\n }\n\n // 5. Best-effort presigned URL for immediate preview.\n let url = '';\n try {\n const urlData = await filesFetch<{ getFileDownloadUrl?: string | null }>(\n GET_FILE_DOWNLOAD_URL,\n { id: newId, expiresIn: DOWNLOAD_URL_TTL_SECONDS },\n baseUrl\n );\n url = urlData.getFileDownloadUrl ?? '';\n } catch {\n url = '';\n }\n\n return { id: newId, url, name: record?.name ?? file.name };\n}\n\n/**\n * Upload an image to wspace-files-svc and return its id + a presigned URL. The\n * caller persists `id` (e.g. cooUpdateBrand({ logoFileId })); `url` is for an\n * immediate preview. `baseUrl` is the shell's apiGatewayUrl.\n */\nexport async function uploadImageToFiles(\n file: File,\n baseUrl?: string,\n folderPath: string = COO_FILE_FOLDERS.items\n): Promise<UploadedImage> {\n return runUpload(file, baseUrl, folderPath);\n}\n\n/**\n * Same as {@link uploadImageToFiles} but reports transfer progress via\n * `onProgress` (XHR `upload.onprogress`) so callers can render a live progress\n * bar. `baseUrl` is the shell's apiGatewayUrl.\n */\nexport async function uploadImageToFilesWithProgress(\n file: File,\n baseUrl?: string,\n folderPath: string = COO_FILE_FOLDERS.items,\n onProgress?: (progress: UploadProgress) => void\n): Promise<UploadedImage> {\n return runUpload(file, baseUrl, folderPath, onProgress);\n}\n\n/** Resolve a wspace-files File id to a presigned URL for display. */\nexport function useFileUrl(fileId?: string | null) {\n const { apiGatewayUrl } = useCoolAndLovely();\n return useQuery({\n queryKey: ['coolandlovely', 'file-url', fileId ?? ''],\n enabled: Boolean(fileId),\n staleTime: 20 * 60 * 1000, // refetch well inside the 6h TTL\n retry: 1,\n queryFn: async () => {\n const data = await filesFetch<{ getFileDownloadUrl?: string | null }>(\n GET_FILE_DOWNLOAD_URL,\n { id: fileId, expiresIn: DOWNLOAD_URL_TTL_SECONDS },\n apiGatewayUrl\n );\n return data.getFileDownloadUrl ?? null;\n },\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"useFiles.js","names":[],"sources":["../../../src/coolandlovely/hooks/useFiles.ts"],"sourcesContent":["/**\n * File hooks — image upload + display via wspace-files-svc (NO duplication).\n * @module coolandlovely/hooks\n *\n * CoolAndLovely never stores blobs. Brand logos/covers, collection covers,\n * style-item photos, look photos, wardrobe photos and lookbook covers are\n * wspace-files `File` records referenced by id (the coo* inputs take *FileId;\n * the coo* types expose logo/cover/image: File resolved via federation).\n *\n * Upload uses the SAME graphqlFetch transport as every other coo* call — NOT\n * Apollo — driving the wspace-files 3-step presigned flow directly:\n * ensureFolderByPath -> initiateUpload -> HTTP PUT -> completeUpload\n * Display resolves a File id to a short-lived presigned URL via\n * getFileDownloadUrl.\n *\n * Two fleet gotchas are handled here:\n * (a) `fileSize` is REQUIRED on completeUpload even though this MFE's generated\n * `CompleteUploadInput` is stale and omits it. Because we send a hand-written\n * query with `Record<string, unknown>` variables (not typed against the\n * generated input) we simply include it and it works at runtime.\n * (b) `getFileDownloadUrl(expiresIn)` must be <= 604800 (S3 7-day cap → 500).\n * We use 21600 (6h); do NOT raise it.\n */\n\nimport { useQuery } from '@tanstack/react-query';\nimport { graphqlFetch } from '@burdenoff/fe-libs/shared/graphql';\nimport { clearGraphqlFetchResponseCache } from '@burdenoff/fe-libs/shared/graphql/fetch';\n\nimport { useCoolAndLovely } from '../context';\n\n/**\n * 6 hours. getFileDownloadUrl rejects TTLs > 604800 (S3 7-day cap → 500); the\n * safe fleet ceiling is 21600. Do NOT raise this.\n */\nconst DOWNLOAD_URL_TTL_SECONDS = 21_600;\n\n/** Per-entity folders in wspace-files. */\nexport const COO_FILE_FOLDERS = {\n brands: '/CoolAndLovely/Brands',\n collections: '/CoolAndLovely/Collections',\n items: '/CoolAndLovely/Items',\n looks: '/CoolAndLovely/Looks',\n wardrobe: '/CoolAndLovely/Wardrobe',\n lookbooks: '/CoolAndLovely/Lookbooks',\n} as const;\n\nexport type CooFileFolder = (typeof COO_FILE_FOLDERS)[keyof typeof COO_FILE_FOLDERS];\n\nexport interface UploadedImage {\n id: string;\n url: string;\n name: string;\n}\n\n/** Thin wrapper over the workspace gateway using the MFE's proven transport. */\nasync function filesFetch<TData>(\n query: string,\n variables: Record<string, unknown>,\n baseUrl?: string\n): Promise<TData> {\n const response = await graphqlFetch<TData>({\n gateway: 'workspace',\n baseUrl,\n query,\n variables,\n workspaceToken: true,\n });\n if (response.errors?.length) {\n throw new Error(response.errors[0]?.message ?? 'Files request failed');\n }\n if (!response.data) {\n throw new Error('Files response did not include data');\n }\n // Drop fe-libs' ~15s query response cache after a mutation so a follow-up\n // getFileDownloadUrl reads fresh (mirrors coolQuery in useCoolAndLovelyApi).\n if (/^\\s*mutation\\b/.test(query)) {\n clearGraphqlFetchResponseCache();\n }\n return response.data;\n}\n\nconst ENSURE_FOLDER = `\n mutation CooEnsureFolder($path: String!) {\n ensureFolderByPath(path: $path) { id }\n }\n`;\n\nconst INITIATE_UPLOAD = `\n mutation CooInitiateUpload($input: InitiateUploadInput!) {\n initiateUpload(input: $input) {\n uploadUrl\n session { id }\n }\n }\n`;\n\nconst COMPLETE_UPLOAD = `\n mutation CooCompleteUpload($input: CompleteUploadInput!) {\n completeUpload(input: $input) {\n fileId\n file { id name }\n }\n }\n`;\n\nconst GET_FILE_DOWNLOAD_URL = `\n query CooGetFileDownloadUrl($id: ID!, $expiresIn: Int) {\n getFileDownloadUrl(id: $id, expiresIn: $expiresIn)\n }\n`;\n\nfunction fileExtension(name: string, mimeType: string): string {\n const dot = name.lastIndexOf('.');\n if (dot > -1 && dot < name.length - 1) return name.slice(dot + 1).toLowerCase();\n return (mimeType.split('/')[1] ?? 'bin').toLowerCase();\n}\n\n/** Upload progress emitted by {@link uploadImageToFilesWithProgress}. */\nexport interface UploadProgress {\n /** Bytes transferred so far. */\n loaded: number;\n /** Total bytes to transfer. */\n total: number;\n /** Rounded percentage complete, 0–100. */\n pct: number;\n}\n\n/**\n * PUT the bytes straight to storage via XMLHttpRequest so `upload.onprogress`\n * can surface a real progress bar (the presigned upload URL points at object\n * storage, so a plain PUT is all that is needed — no auth header). Content-Type\n * must match what was sent to `initiateUpload`.\n */\nfunction putWithProgress(\n uploadUrl: string,\n file: File,\n mimeType: string,\n onProgress?: (progress: UploadProgress) => void\n): Promise<void> {\n return new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest();\n xhr.open('PUT', uploadUrl, true);\n xhr.setRequestHeader('Content-Type', mimeType);\n if (onProgress) {\n xhr.upload.onprogress = (event: ProgressEvent) => {\n const total = event.lengthComputable ? event.total : file.size;\n const loaded = Math.min(event.loaded, total);\n const pct = total > 0 ? Math.round((loaded / total) * 100) : 0;\n onProgress({ loaded, total, pct });\n };\n }\n xhr.onload = () => {\n if (xhr.status >= 200 && xhr.status < 300) {\n onProgress?.({ loaded: file.size, total: file.size, pct: 100 });\n resolve();\n } else {\n reject(new Error(`Upload to storage failed (${xhr.status}).`));\n }\n };\n xhr.onerror = () => reject(new Error('Upload to storage failed — a network error occurred.'));\n xhr.onabort = () => reject(new Error('Upload was cancelled.'));\n xhr.send(file);\n });\n}\n\n/**\n * The full presigned upload flow (ensureFolder -> initiateUpload -> PUT ->\n * completeUpload -> download URL). Shared by both public upload helpers; the\n * PUT step reports progress when `onProgress` is supplied.\n */\nasync function runUpload(\n file: File,\n baseUrl?: string,\n folderPath: string = COO_FILE_FOLDERS.items,\n onProgress?: (progress: UploadProgress) => void\n): Promise<UploadedImage> {\n const mimeType = file.type || 'application/octet-stream';\n const fileSize = String(file.size); // BigInt scalar — serialize as string.\n\n // 1. Resolve (or create) the destination folder. Best-effort: fall back to\n // the files root (folderId is optional on both initiate + complete).\n let folderId: string | undefined;\n try {\n const folder = await filesFetch<{ ensureFolderByPath?: { id: string } | null }>(\n ENSURE_FOLDER,\n { path: folderPath },\n baseUrl\n );\n folderId = folder.ensureFolderByPath?.id ?? undefined;\n } catch {\n folderId = undefined;\n }\n\n // 2. Open a presigned upload session.\n const initiated = await filesFetch<{\n initiateUpload?: { uploadUrl?: string | null; session: { id: string } } | null;\n }>(\n INITIATE_UPLOAD,\n {\n input: {\n fileName: file.name,\n extension: fileExtension(file.name, mimeType),\n fileSize, // required, BigInt-as-string\n mimeType,\n ...(folderId ? { folderId } : {}),\n },\n },\n baseUrl\n );\n const uploadUrl = initiated.initiateUpload?.uploadUrl;\n const sessionId = initiated.initiateUpload?.session.id;\n if (!uploadUrl || !sessionId) {\n throw new Error('The files service did not return an upload URL.');\n }\n\n // 3. PUT the bytes straight to storage (XHR → progress). Content-Type must\n // match initiateUpload.\n await putWithProgress(uploadUrl, file, mimeType, onProgress);\n\n // 4. Finalize — creates the File record. fileSize is REQUIRED here even though\n // the MFE's generated CompleteUploadInput is stale and omits it.\n const completed = await filesFetch<{\n completeUpload?: { fileId: string; file: { id: string; name: string } } | null;\n }>(\n COMPLETE_UPLOAD,\n {\n input: {\n sessionId,\n fileSize, // <-- stale-type gotcha: the live schema requires this\n ...(folderId ? { folderId } : {}),\n },\n },\n baseUrl\n );\n const record = completed.completeUpload?.file;\n const newId = completed.completeUpload?.fileId ?? record?.id;\n if (!newId) {\n throw new Error('The upload could not be finalized.');\n }\n\n // 5. Best-effort presigned URL for immediate preview.\n let url = '';\n try {\n const urlData = await filesFetch<{ getFileDownloadUrl?: string | null }>(\n GET_FILE_DOWNLOAD_URL,\n { id: newId, expiresIn: DOWNLOAD_URL_TTL_SECONDS },\n baseUrl\n );\n url = urlData.getFileDownloadUrl ?? '';\n } catch {\n url = '';\n }\n\n return { id: newId, url, name: record?.name ?? file.name };\n}\n\n/**\n * Upload an image to wspace-files-svc and return its id + a presigned URL. The\n * caller persists `id` (e.g. cooUpdateBrand({ logoFileId })); `url` is for an\n * immediate preview. `baseUrl` is the shell's apiGatewayUrl.\n */\nexport async function uploadImageToFiles(\n file: File,\n baseUrl?: string,\n folderPath: string = COO_FILE_FOLDERS.items\n): Promise<UploadedImage> {\n return runUpload(file, baseUrl, folderPath);\n}\n\n/**\n * Same as {@link uploadImageToFiles} but reports transfer progress via\n * `onProgress` (XHR `upload.onprogress`) so callers can render a live progress\n * bar. `baseUrl` is the shell's apiGatewayUrl.\n */\nexport async function uploadImageToFilesWithProgress(\n file: File,\n baseUrl?: string,\n folderPath: string = COO_FILE_FOLDERS.items,\n onProgress?: (progress: UploadProgress) => void\n): Promise<UploadedImage> {\n return runUpload(file, baseUrl, folderPath, onProgress);\n}\n\n/** Resolve a wspace-files File id to a presigned URL for display. */\nexport function useFileUrl(fileId?: string | null) {\n const { apiGatewayUrl } = useCoolAndLovely();\n return useQuery({\n queryKey: ['coolandlovely', 'file-url', fileId ?? ''],\n enabled: Boolean(fileId),\n staleTime: 20 * 60 * 1000, // refetch well inside the 6h TTL\n retry: 1,\n queryFn: async () => {\n const data = await filesFetch<{ getFileDownloadUrl?: string | null }>(\n GET_FILE_DOWNLOAD_URL,\n { id: fileId, expiresIn: DOWNLOAD_URL_TTL_SECONDS },\n apiGatewayUrl\n );\n return data.getFileDownloadUrl ?? null;\n },\n });\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"BrandsListPage.d.ts","sourceRoot":"","sources":["../../../../src/coolandlovely/pages/brands/BrandsListPage.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAuBH,wBAAgB,cAAc,gCAuJ7B"}
1
+ {"version":3,"file":"BrandsListPage.d.ts","sourceRoot":"","sources":["../../../../src/coolandlovely/pages/brands/BrandsListPage.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAuBH,wBAAgB,cAAc,gCAiK7B"}
@@ -8,113 +8,125 @@ import { ErrorView as a, LoadingView as o } from "../../components/StateViews.js
8
8
  import { EmptyState as s } from "../../components/EmptyState.js";
9
9
  import { BrandCard as c } from "../../components/BrandCard.js";
10
10
  import { useMemo as l, useState as u } from "react";
11
- import { ChevronLeft as d, ChevronRight as f, Plus as p, Star as m, Store as h } from "lucide-react";
12
- import { Fragment as g, jsx as _, jsxs as v } from "react/jsx-runtime";
11
+ import { ChevronLeft as d, ChevronRight as f, Download as p, Plus as m, Star as h, Store as g } from "lucide-react";
12
+ import { Fragment as _, jsx as v, jsxs as y } from "react/jsx-runtime";
13
13
  //#region src/coolandlovely/pages/brands/BrandsListPage.tsx
14
- var y = 24, b = [
14
+ var b = 24, x = [
15
15
  "PUBLISHED",
16
16
  "DRAFT",
17
17
  "PENDING_REVIEW",
18
18
  "SUSPENDED",
19
19
  "ARCHIVED"
20
20
  ];
21
- function x() {
22
- let { navigateTo: x } = e(), S = i(), [C, w] = u("ALL"), [T, E] = u(!1), [D, O] = u(0), k = l(() => [{
21
+ function S() {
22
+ let { navigateTo: S } = e(), C = i(), [w, T] = u("ALL"), [E, D] = u(!1), [O, k] = u(0), A = l(() => [{
23
23
  value: "ALL",
24
- label: S("coolandlovely.filters.all", "All")
25
- }, ...b.map((e) => ({
24
+ label: C("coolandlovely.filters.all", "All")
25
+ }, ...x.map((e) => ({
26
26
  value: e,
27
- label: S(`coolandlovely.status.${e.toLowerCase()}`, e.replace(/_/g, " ").replace(/\b\w/g, (e) => e.toUpperCase()))
28
- }))], [S]), A = r({
29
- status: C === "ALL" ? void 0 : C,
30
- featured: T ? !0 : void 0,
27
+ label: C(`coolandlovely.status.${e.toLowerCase()}`, e.replace(/_/g, " ").replace(/\b\w/g, (e) => e.toUpperCase()))
28
+ }))], [C]), j = r({
29
+ status: w === "ALL" ? void 0 : w,
30
+ featured: E ? !0 : void 0,
31
31
  pagination: {
32
- limit: y,
33
- offset: D
32
+ limit: b,
33
+ offset: O
34
34
  }
35
- }), j = A.data?.items ?? [], M = A.data?.pageInfo.hasMore ?? !1;
36
- return /* @__PURE__ */ v(n, {
37
- title: S("coolandlovely.page.brandsTitle", "Brands"),
38
- description: S("coolandlovely.page.brandsSubtitle", "Storefronts on the platform with a real publish lifecycle"),
39
- actions: /* @__PURE__ */ v("button", {
40
- type: "button",
41
- onClick: () => x("/brands/new"),
42
- className: "inline-flex items-center gap-1.5 rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text transition-colors hover:bg-action-primary-bg/90",
43
- children: [
44
- /* @__PURE__ */ _(p, { className: "h-4 w-4" }),
45
- " ",
46
- S("coolandlovely.actions.newBrand", "New brand")
47
- ]
35
+ }), M = j.data?.items ?? [], N = j.data?.pageInfo.hasMore ?? !1;
36
+ return /* @__PURE__ */ y(n, {
37
+ title: C("coolandlovely.page.brandsTitle", "Brands"),
38
+ description: C("coolandlovely.page.brandsSubtitle", "Storefronts on the platform with a real publish lifecycle"),
39
+ actions: /* @__PURE__ */ y("div", {
40
+ className: "flex flex-wrap gap-2",
41
+ children: [/* @__PURE__ */ y("button", {
42
+ type: "button",
43
+ onClick: () => S("/brands/import"),
44
+ className: "inline-flex items-center gap-1.5 rounded-lg border border-border-seam px-4 py-2 text-sm font-medium text-text-primary transition-colors hover:bg-bg-sunken",
45
+ children: [
46
+ /* @__PURE__ */ v(p, { className: "h-4 w-4" }),
47
+ " ",
48
+ C("coolandlovely.actions.importCatalog", "Import catalog")
49
+ ]
50
+ }), /* @__PURE__ */ y("button", {
51
+ type: "button",
52
+ onClick: () => S("/brands/new"),
53
+ className: "inline-flex items-center gap-1.5 rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text transition-colors hover:bg-action-primary-bg/90",
54
+ children: [
55
+ /* @__PURE__ */ v(m, { className: "h-4 w-4" }),
56
+ " ",
57
+ C("coolandlovely.actions.newBrand", "New brand")
58
+ ]
59
+ })]
48
60
  }),
49
- children: [/* @__PURE__ */ v("div", {
61
+ children: [/* @__PURE__ */ y("div", {
50
62
  className: "mb-6 flex flex-wrap items-center gap-2",
51
- children: [k.map((e) => /* @__PURE__ */ _("button", {
63
+ children: [A.map((e) => /* @__PURE__ */ v("button", {
52
64
  type: "button",
53
65
  onClick: () => {
54
- w(e.value), O(0);
66
+ T(e.value), k(0);
55
67
  },
56
- className: t("rounded-full px-3 py-1.5 text-sm font-medium transition-colors", C === e.value ? "bg-action-primary-bg text-action-primary-text" : "bg-bg-sunken text-text-secondary hover:text-text-primary"),
68
+ className: t("rounded-full px-3 py-1.5 text-sm font-medium transition-colors", w === e.value ? "bg-action-primary-bg text-action-primary-text" : "bg-bg-sunken text-text-secondary hover:text-text-primary"),
57
69
  children: e.label
58
- }, e.value)), /* @__PURE__ */ v("button", {
70
+ }, e.value)), /* @__PURE__ */ y("button", {
59
71
  type: "button",
60
72
  onClick: () => {
61
- E((e) => !e), O(0);
73
+ D((e) => !e), k(0);
62
74
  },
63
- className: t("ml-auto inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors", T ? "bg-status-warning-bg-subtle text-status-warning-text" : "bg-bg-sunken text-text-secondary hover:text-text-primary"),
75
+ className: t("ml-auto inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors", E ? "bg-status-warning-bg-subtle text-status-warning-text" : "bg-bg-sunken text-text-secondary hover:text-text-primary"),
64
76
  children: [
65
- /* @__PURE__ */ _(m, { className: t("h-3.5 w-3.5", T && "fill-status-warning-text") }),
77
+ /* @__PURE__ */ v(h, { className: t("h-3.5 w-3.5", E && "fill-status-warning-text") }),
66
78
  " ",
67
- S("coolandlovely.filters.featured", "Featured")
79
+ C("coolandlovely.filters.featured", "Featured")
68
80
  ]
69
81
  })]
70
- }), A.isLoading ? /* @__PURE__ */ _(o, { label: S("coolandlovely.loading.brands", "Loading brands…") }) : A.isError ? /* @__PURE__ */ _(a, { onRetry: () => void A.refetch() }) : j.length === 0 ? /* @__PURE__ */ _(s, {
82
+ }), j.isLoading ? /* @__PURE__ */ v(o, { label: C("coolandlovely.loading.brands", "Loading brands…") }) : j.isError ? /* @__PURE__ */ v(a, { onRetry: () => void j.refetch() }) : M.length === 0 ? /* @__PURE__ */ v(s, {
71
83
  illustration: "empty-data",
72
- icon: /* @__PURE__ */ _(h, { className: "h-10 w-10" }),
73
- title: S("coolandlovely.empty.brandsTitle", "No brands yet"),
74
- description: S("coolandlovely.empty.brandsDescription", "Launch the first storefront to start building the catalogue."),
75
- action: /* @__PURE__ */ v("button", {
84
+ icon: /* @__PURE__ */ v(g, { className: "h-10 w-10" }),
85
+ title: C("coolandlovely.empty.brandsTitle", "No brands yet"),
86
+ description: C("coolandlovely.empty.brandsDescription", "Launch the first storefront to start building the catalogue."),
87
+ action: /* @__PURE__ */ y("button", {
76
88
  type: "button",
77
- onClick: () => x("/brands/new"),
89
+ onClick: () => S("/brands/new"),
78
90
  className: "inline-flex items-center gap-1.5 rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text",
79
91
  children: [
80
- /* @__PURE__ */ _(p, { className: "h-4 w-4" }),
92
+ /* @__PURE__ */ v(m, { className: "h-4 w-4" }),
81
93
  " ",
82
- S("coolandlovely.actions.newBrand", "New brand")
94
+ C("coolandlovely.actions.newBrand", "New brand")
83
95
  ]
84
96
  })
85
- }) : /* @__PURE__ */ v(g, { children: [/* @__PURE__ */ _("div", {
97
+ }) : /* @__PURE__ */ y(_, { children: [/* @__PURE__ */ v("div", {
86
98
  className: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3",
87
- children: j.map((e) => /* @__PURE__ */ _(c, {
99
+ children: M.map((e) => /* @__PURE__ */ v(c, {
88
100
  brand: e,
89
- onClick: () => x(`/brands/${e.id}`)
101
+ onClick: () => S(`/brands/${e.id}`)
90
102
  }, e.id))
91
- }), (D > 0 || M) && /* @__PURE__ */ v("div", {
103
+ }), (O > 0 || N) && /* @__PURE__ */ y("div", {
92
104
  className: "mt-6 flex items-center justify-center gap-2",
93
105
  children: [
94
- /* @__PURE__ */ v("button", {
106
+ /* @__PURE__ */ y("button", {
95
107
  type: "button",
96
- disabled: D === 0,
97
- onClick: () => O((e) => Math.max(0, e - y)),
108
+ disabled: O === 0,
109
+ onClick: () => k((e) => Math.max(0, e - b)),
98
110
  className: "inline-flex items-center gap-1 rounded-lg border border-border-seam px-3 py-1.5 text-sm font-medium text-text-primary transition-colors hover:bg-bg-sunken disabled:cursor-not-allowed disabled:opacity-40",
99
111
  children: [
100
- /* @__PURE__ */ _(d, { className: "h-4 w-4" }),
112
+ /* @__PURE__ */ v(d, { className: "h-4 w-4" }),
101
113
  " ",
102
- S("coolandlovely.pagination.previous", "Previous")
114
+ C("coolandlovely.pagination.previous", "Previous")
103
115
  ]
104
116
  }),
105
- /* @__PURE__ */ _("span", {
117
+ /* @__PURE__ */ v("span", {
106
118
  className: "text-sm text-text-secondary",
107
- children: S("coolandlovely.pagination.page", "Page {{page}}", { page: D / y + 1 })
119
+ children: C("coolandlovely.pagination.page", "Page {{page}}", { page: O / b + 1 })
108
120
  }),
109
- /* @__PURE__ */ v("button", {
121
+ /* @__PURE__ */ y("button", {
110
122
  type: "button",
111
- disabled: !M,
112
- onClick: () => O((e) => e + y),
123
+ disabled: !N,
124
+ onClick: () => k((e) => e + b),
113
125
  className: "inline-flex items-center gap-1 rounded-lg border border-border-seam px-3 py-1.5 text-sm font-medium text-text-primary transition-colors hover:bg-bg-sunken disabled:cursor-not-allowed disabled:opacity-40",
114
126
  children: [
115
- S("coolandlovely.pagination.next", "Next"),
127
+ C("coolandlovely.pagination.next", "Next"),
116
128
  " ",
117
- /* @__PURE__ */ _(f, { className: "h-4 w-4" })
129
+ /* @__PURE__ */ v(f, { className: "h-4 w-4" })
118
130
  ]
119
131
  })
120
132
  ]
@@ -122,6 +134,6 @@ function x() {
122
134
  });
123
135
  }
124
136
  //#endregion
125
- export { x as BrandsListPage };
137
+ export { S as BrandsListPage };
126
138
 
127
139
  //# sourceMappingURL=BrandsListPage.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"BrandsListPage.js","names":[],"sources":["../../../../src/coolandlovely/pages/brands/BrandsListPage.tsx"],"sourcesContent":["/**\n * Brands List Page\n * @module coolandlovely/pages/brands\n *\n * Lists brand storefronts (cursor-paginated `cooListBrands`) with status &\n * featured filters. Each card links to the brand detail page.\n */\n\nimport { useMemo, useState } from 'react';\nimport { Store, Plus, ChevronLeft, ChevronRight, Star } from 'lucide-react';\nimport { PageLayout } from '../../components/PageLayout';\nimport { EmptyState } from '../../components/EmptyState';\nimport { LoadingView, ErrorView } from '../../components/StateViews';\nimport { useCoolAndLovely } from '../../context/CoolAndLovelyContext';\nimport { useBrands, useTr } from '../../hooks';\nimport { BrandCard } from '../../components/BrandCard';\nimport { cn } from '../../utils/cn';\nimport type { CooBrandStatus } from '../../types';\n\nconst PAGE_SIZE = 24;\n\nconst STATUS_ORDER: CooBrandStatus[] = [\n 'PUBLISHED',\n 'DRAFT',\n 'PENDING_REVIEW',\n 'SUSPENDED',\n 'ARCHIVED',\n];\n\nexport function BrandsListPage() {\n const { navigateTo } = useCoolAndLovely();\n const tr = useTr();\n const [status, setStatus] = useState<CooBrandStatus | 'ALL'>('ALL');\n const [featuredOnly, setFeaturedOnly] = useState(false);\n const [offset, setOffset] = useState(0);\n\n const statusOptions = useMemo(\n () => [\n { value: 'ALL' as const, label: tr('coolandlovely.filters.all', 'All') },\n ...STATUS_ORDER.map((value) => ({\n value,\n label: tr(\n `coolandlovely.status.${value.toLowerCase()}`,\n value.replace(/_/g, ' ').replace(/\\b\\w/g, (c) => c.toUpperCase())\n ),\n })),\n ],\n [tr]\n );\n\n const query = useBrands({\n status: status === 'ALL' ? undefined : status,\n featured: featuredOnly ? true : undefined,\n pagination: { limit: PAGE_SIZE, offset },\n });\n\n const brands = query.data?.items ?? [];\n const hasMore = query.data?.pageInfo.hasMore ?? false;\n\n return (\n <PageLayout\n title={tr('coolandlovely.page.brandsTitle', 'Brands')}\n description={tr(\n 'coolandlovely.page.brandsSubtitle',\n 'Storefronts on the platform with a real publish lifecycle'\n )}\n actions={\n <button\n type=\"button\"\n onClick={() => navigateTo('/brands/new')}\n className=\"inline-flex items-center gap-1.5 rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text transition-colors hover:bg-action-primary-bg/90\"\n >\n <Plus className=\"h-4 w-4\" /> {tr('coolandlovely.actions.newBrand', 'New brand')}\n </button>\n }\n >\n {/* Filters */}\n <div className=\"mb-6 flex flex-wrap items-center gap-2\">\n {statusOptions.map((opt) => (\n <button\n key={opt.value}\n type=\"button\"\n onClick={() => {\n setStatus(opt.value);\n setOffset(0);\n }}\n className={cn(\n 'rounded-full px-3 py-1.5 text-sm font-medium transition-colors',\n status === opt.value\n ? 'bg-action-primary-bg text-action-primary-text'\n : 'bg-bg-sunken text-text-secondary hover:text-text-primary'\n )}\n >\n {opt.label}\n </button>\n ))}\n <button\n type=\"button\"\n onClick={() => {\n setFeaturedOnly((v) => !v);\n setOffset(0);\n }}\n className={cn(\n 'ml-auto inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors',\n featuredOnly\n ? 'bg-status-warning-bg-subtle text-status-warning-text'\n : 'bg-bg-sunken text-text-secondary hover:text-text-primary'\n )}\n >\n <Star className={cn('h-3.5 w-3.5', featuredOnly && 'fill-status-warning-text')} />{' '}\n {tr('coolandlovely.filters.featured', 'Featured')}\n </button>\n </div>\n\n {query.isLoading ? (\n <LoadingView label={tr('coolandlovely.loading.brands', 'Loading brands…')} />\n ) : query.isError ? (\n <ErrorView onRetry={() => void query.refetch()} />\n ) : brands.length === 0 ? (\n <EmptyState\n illustration=\"empty-data\"\n icon={<Store className=\"h-10 w-10\" />}\n title={tr('coolandlovely.empty.brandsTitle', 'No brands yet')}\n description={tr(\n 'coolandlovely.empty.brandsDescription',\n 'Launch the first storefront to start building the catalogue.'\n )}\n action={\n <button\n type=\"button\"\n onClick={() => navigateTo('/brands/new')}\n className=\"inline-flex items-center gap-1.5 rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text\"\n >\n <Plus className=\"h-4 w-4\" /> {tr('coolandlovely.actions.newBrand', 'New brand')}\n </button>\n }\n />\n ) : (\n <>\n <div className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n {brands.map((brand) => (\n <BrandCard\n key={brand.id}\n brand={brand}\n onClick={() => navigateTo(`/brands/${brand.id}`)}\n />\n ))}\n </div>\n\n {/* Pagination */}\n {(offset > 0 || hasMore) && (\n <div className=\"mt-6 flex items-center justify-center gap-2\">\n <button\n type=\"button\"\n disabled={offset === 0}\n onClick={() => setOffset((o) => Math.max(0, o - PAGE_SIZE))}\n className=\"inline-flex items-center gap-1 rounded-lg border border-border-seam px-3 py-1.5 text-sm font-medium text-text-primary transition-colors hover:bg-bg-sunken disabled:cursor-not-allowed disabled:opacity-40\"\n >\n <ChevronLeft className=\"h-4 w-4\" />{' '}\n {tr('coolandlovely.pagination.previous', 'Previous')}\n </button>\n <span className=\"text-sm text-text-secondary\">\n {tr('coolandlovely.pagination.page', 'Page {{page}}', {\n page: offset / PAGE_SIZE + 1,\n })}\n </span>\n <button\n type=\"button\"\n disabled={!hasMore}\n onClick={() => setOffset((o) => o + PAGE_SIZE)}\n className=\"inline-flex items-center gap-1 rounded-lg border border-border-seam px-3 py-1.5 text-sm font-medium text-text-primary transition-colors hover:bg-bg-sunken disabled:cursor-not-allowed disabled:opacity-40\"\n >\n {tr('coolandlovely.pagination.next', 'Next')} <ChevronRight className=\"h-4 w-4\" />\n </button>\n </div>\n )}\n </>\n )}\n </PageLayout>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"BrandsListPage.js","names":[],"sources":["../../../../src/coolandlovely/pages/brands/BrandsListPage.tsx"],"sourcesContent":["/**\n * Brands List Page\n * @module coolandlovely/pages/brands\n *\n * Lists brand storefronts (cursor-paginated `cooListBrands`) with status &\n * featured filters. Each card links to the brand detail page.\n */\n\nimport { useMemo, useState } from 'react';\nimport { Store, Plus, ChevronLeft, ChevronRight, Star, Download } from 'lucide-react';\nimport { PageLayout } from '../../components/PageLayout';\nimport { EmptyState } from '../../components/EmptyState';\nimport { LoadingView, ErrorView } from '../../components/StateViews';\nimport { useCoolAndLovely } from '../../context/CoolAndLovelyContext';\nimport { useBrands, useTr } from '../../hooks';\nimport { BrandCard } from '../../components/BrandCard';\nimport { cn } from '../../utils/cn';\nimport type { CooBrandStatus } from '../../types';\n\nconst PAGE_SIZE = 24;\n\nconst STATUS_ORDER: CooBrandStatus[] = [\n 'PUBLISHED',\n 'DRAFT',\n 'PENDING_REVIEW',\n 'SUSPENDED',\n 'ARCHIVED',\n];\n\nexport function BrandsListPage() {\n const { navigateTo } = useCoolAndLovely();\n const tr = useTr();\n const [status, setStatus] = useState<CooBrandStatus | 'ALL'>('ALL');\n const [featuredOnly, setFeaturedOnly] = useState(false);\n const [offset, setOffset] = useState(0);\n\n const statusOptions = useMemo(\n () => [\n { value: 'ALL' as const, label: tr('coolandlovely.filters.all', 'All') },\n ...STATUS_ORDER.map((value) => ({\n value,\n label: tr(\n `coolandlovely.status.${value.toLowerCase()}`,\n value.replace(/_/g, ' ').replace(/\\b\\w/g, (c) => c.toUpperCase())\n ),\n })),\n ],\n [tr]\n );\n\n const query = useBrands({\n status: status === 'ALL' ? undefined : status,\n featured: featuredOnly ? true : undefined,\n pagination: { limit: PAGE_SIZE, offset },\n });\n\n const brands = query.data?.items ?? [];\n const hasMore = query.data?.pageInfo.hasMore ?? false;\n\n return (\n <PageLayout\n title={tr('coolandlovely.page.brandsTitle', 'Brands')}\n description={tr(\n 'coolandlovely.page.brandsSubtitle',\n 'Storefronts on the platform with a real publish lifecycle'\n )}\n actions={\n <div className=\"flex flex-wrap gap-2\">\n <button\n type=\"button\"\n onClick={() => navigateTo('/brands/import')}\n className=\"inline-flex items-center gap-1.5 rounded-lg border border-border-seam px-4 py-2 text-sm font-medium text-text-primary transition-colors hover:bg-bg-sunken\"\n >\n <Download className=\"h-4 w-4\" />{' '}\n {tr('coolandlovely.actions.importCatalog', 'Import catalog')}\n </button>\n <button\n type=\"button\"\n onClick={() => navigateTo('/brands/new')}\n className=\"inline-flex items-center gap-1.5 rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text transition-colors hover:bg-action-primary-bg/90\"\n >\n <Plus className=\"h-4 w-4\" /> {tr('coolandlovely.actions.newBrand', 'New brand')}\n </button>\n </div>\n }\n >\n {/* Filters */}\n <div className=\"mb-6 flex flex-wrap items-center gap-2\">\n {statusOptions.map((opt) => (\n <button\n key={opt.value}\n type=\"button\"\n onClick={() => {\n setStatus(opt.value);\n setOffset(0);\n }}\n className={cn(\n 'rounded-full px-3 py-1.5 text-sm font-medium transition-colors',\n status === opt.value\n ? 'bg-action-primary-bg text-action-primary-text'\n : 'bg-bg-sunken text-text-secondary hover:text-text-primary'\n )}\n >\n {opt.label}\n </button>\n ))}\n <button\n type=\"button\"\n onClick={() => {\n setFeaturedOnly((v) => !v);\n setOffset(0);\n }}\n className={cn(\n 'ml-auto inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors',\n featuredOnly\n ? 'bg-status-warning-bg-subtle text-status-warning-text'\n : 'bg-bg-sunken text-text-secondary hover:text-text-primary'\n )}\n >\n <Star className={cn('h-3.5 w-3.5', featuredOnly && 'fill-status-warning-text')} />{' '}\n {tr('coolandlovely.filters.featured', 'Featured')}\n </button>\n </div>\n\n {query.isLoading ? (\n <LoadingView label={tr('coolandlovely.loading.brands', 'Loading brands…')} />\n ) : query.isError ? (\n <ErrorView onRetry={() => void query.refetch()} />\n ) : brands.length === 0 ? (\n <EmptyState\n illustration=\"empty-data\"\n icon={<Store className=\"h-10 w-10\" />}\n title={tr('coolandlovely.empty.brandsTitle', 'No brands yet')}\n description={tr(\n 'coolandlovely.empty.brandsDescription',\n 'Launch the first storefront to start building the catalogue.'\n )}\n action={\n <button\n type=\"button\"\n onClick={() => navigateTo('/brands/new')}\n className=\"inline-flex items-center gap-1.5 rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text\"\n >\n <Plus className=\"h-4 w-4\" /> {tr('coolandlovely.actions.newBrand', 'New brand')}\n </button>\n }\n />\n ) : (\n <>\n <div className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n {brands.map((brand) => (\n <BrandCard\n key={brand.id}\n brand={brand}\n onClick={() => navigateTo(`/brands/${brand.id}`)}\n />\n ))}\n </div>\n\n {/* Pagination */}\n {(offset > 0 || hasMore) && (\n <div className=\"mt-6 flex items-center justify-center gap-2\">\n <button\n type=\"button\"\n disabled={offset === 0}\n onClick={() => setOffset((o) => Math.max(0, o - PAGE_SIZE))}\n className=\"inline-flex items-center gap-1 rounded-lg border border-border-seam px-3 py-1.5 text-sm font-medium text-text-primary transition-colors hover:bg-bg-sunken disabled:cursor-not-allowed disabled:opacity-40\"\n >\n <ChevronLeft className=\"h-4 w-4\" />{' '}\n {tr('coolandlovely.pagination.previous', 'Previous')}\n </button>\n <span className=\"text-sm text-text-secondary\">\n {tr('coolandlovely.pagination.page', 'Page {{page}}', {\n page: offset / PAGE_SIZE + 1,\n })}\n </span>\n <button\n type=\"button\"\n disabled={!hasMore}\n onClick={() => setOffset((o) => o + PAGE_SIZE)}\n className=\"inline-flex items-center gap-1 rounded-lg border border-border-seam px-3 py-1.5 text-sm font-medium text-text-primary transition-colors hover:bg-bg-sunken disabled:cursor-not-allowed disabled:opacity-40\"\n >\n {tr('coolandlovely.pagination.next', 'Next')} <ChevronRight className=\"h-4 w-4\" />\n </button>\n </div>\n )}\n </>\n )}\n </PageLayout>\n );\n}\n"],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Import Catalog Page (BOFF-3384)
3
+ * @module coolandlovely/pages/brands
4
+ *
5
+ * Lets a brand bring its existing catalog over from Shopify, WooCommerce or
6
+ * BigCommerce in three steps:
7
+ *
8
+ * 1. Connect — pick the platform and paste read-only API credentials. They
9
+ * are sent once with `cooPreviewExternalCatalog` and never stored.
10
+ * 2. Review — the catalog comes back mapped onto a brand, collections and
11
+ * style items. The merchant confirms the brand name/handle and unticks
12
+ * anything they do not want.
13
+ * 3. Import — product images are copied into wspace-files and everything is
14
+ * created in one transaction with `cooBulkImportCatalog`.
15
+ */
16
+ export declare function ImportCatalogPage(): import("react").JSX.Element;
17
+ //# sourceMappingURL=ImportCatalogPage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ImportCatalogPage.d.ts","sourceRoot":"","sources":["../../../../src/coolandlovely/pages/brands/ImportCatalogPage.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AA6GH,wBAAgB,iBAAiB,gCA6ehC"}