@pagamio/frontend-commons-lib 0.8.350 → 0.8.352

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,5 +1,19 @@
1
1
  import * as React from 'react';
2
2
  import type { UsePagamioComboboxReturn } from '../../shared/hooks/usePagamioCombobox';
3
+ /**
4
+ * Optional in-popover action — replaces the old "+ Create New X" sentinel
5
+ * option pattern that some `<Select>` callers used to inline a "create a
6
+ * new item" affordance. Rendered as a button row pinned to the top of the
7
+ * popover, above the options list. Clicking it closes the popover and
8
+ * fires `onClick` (typically opening a quick-create modal). The handler
9
+ * is responsible for the side effect; the combobox does NOT update its
10
+ * `value` automatically.
11
+ */
12
+ export interface SearchableComboboxActionButton {
13
+ /** Label shown on the action button (e.g. "+ New category"). */
14
+ label: React.ReactNode;
15
+ onClick: () => void;
16
+ }
3
17
  export interface PagamioSearchableComboboxProps<T extends {
4
18
  id: string;
5
19
  }> {
@@ -8,6 +22,12 @@ export interface PagamioSearchableComboboxProps<T extends {
8
22
  /** Controlled selected id, or null when nothing is selected. */
9
23
  value: string | null;
10
24
  onChange: (id: string | null) => void;
25
+ /**
26
+ * Optional action button rendered at the top of the popover. Used to
27
+ * surface a "+ Create new …" affordance inside the picker itself,
28
+ * matching where the old static-select sentinel option lived.
29
+ */
30
+ actionButton?: SearchableComboboxActionButton;
11
31
  /**
12
32
  * Optional pre-resolved selected option. When set, the trigger shows its
13
33
  * label without needing to fetch via `restoreIds`. If both this and the
@@ -39,4 +59,4 @@ export interface PagamioSearchableComboboxProps<T extends {
39
59
  }
40
60
  export declare function PagamioSearchableCombobox<T extends {
41
61
  id: string;
42
- }>({ combobox, value, onChange, selectedOption, placeholder, searchPlaceholder, emptyMessage, cappedMessage, loadingMessage, disabled, required, name, getLabel, renderOption, renderSelected, className, triggerClassName, contentClassName, }: PagamioSearchableComboboxProps<T>): import("react/jsx-runtime").JSX.Element;
62
+ }>({ combobox, value, onChange, actionButton, selectedOption, placeholder, searchPlaceholder, emptyMessage, cappedMessage, loadingMessage, disabled, required, name, getLabel, renderOption, renderSelected, className, triggerClassName, contentClassName, }: PagamioSearchableComboboxProps<T>): import("react/jsx-runtime").JSX.Element;
@@ -19,7 +19,7 @@ function defaultGetLabel(option) {
19
19
  const o = option;
20
20
  return o.name ?? option.id;
21
21
  }
22
- export function PagamioSearchableCombobox({ combobox, value, onChange, selectedOption, placeholder = 'Select…', searchPlaceholder = 'Search...', emptyMessage = 'No options found', cappedMessage = 'Showing top results — refine your search.', loadingMessage = 'Loading…', disabled, required, name, getLabel = defaultGetLabel, renderOption, renderSelected, className, triggerClassName, contentClassName, }) {
22
+ export function PagamioSearchableCombobox({ combobox, value, onChange, actionButton, selectedOption, placeholder = 'Select…', searchPlaceholder = 'Search...', emptyMessage = 'No options found', cappedMessage = 'Showing top results — refine your search.', loadingMessage = 'Loading…', disabled, required, name, getLabel = defaultGetLabel, renderOption, renderSelected, className, triggerClassName, contentClassName, }) {
23
23
  const [open, setOpen] = React.useState(false);
24
24
  const inputRef = React.useRef(null);
25
25
  // Focus the search input when the popover opens — matches the Select's
@@ -52,11 +52,18 @@ export function PagamioSearchableCombobox({ combobox, value, onChange, selectedO
52
52
  : placeholder }), _jsx(ChevronDownIcon, { className: "ml-2 h-4 w-4 opacity-50 shrink-0" })] }) }), _jsxs(PopoverContent, { align: "start", sideOffset: 4,
53
53
  // Mirrors SelectContent: same shadow + animation classes, popover
54
54
  // background, popover-foreground text. Width matches trigger.
55
- className: cn('p-0 w-[var(--radix-popover-trigger-width)] min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md', contentClassName), children: [_jsx("div", { className: "p-2 border-b border-border", children: _jsx("input", { ref: inputRef, type: "text", placeholder: searchPlaceholder, value: combobox.search, onChange: (e) => combobox.setSearch(e.target.value), onKeyDown: (e) => e.stopPropagation(), className: "w-full px-2 py-1 text-sm border border-input rounded bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" }) }), _jsx("div", { className: "p-1 max-h-[200px] overflow-y-auto", children: combobox.isLoading && combobox.options.length === 0 ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-muted-foreground", children: loadingMessage })) : combobox.error ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-destructive", children: combobox.error.message || 'Failed to load options.' })) : combobox.options.length === 0 ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-muted-foreground", children: emptyMessage })) : (_jsxs(_Fragment, { children: [combobox.options.map((option) => {
56
- const isSelected = option.id === value;
57
- return (_jsx("button", { type: "button", role: "option", "aria-selected": isSelected, onClick: () => handleSelect(option.id), className: cn(
58
- // Mirrors SelectItem styling: padding, hover via
59
- // focus:bg-muted, selected via bg-accent.
60
- 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none text-left', 'hover:bg-muted focus:bg-muted focus:text-foreground', isSelected && 'bg-accent text-foreground'), children: renderOption ? (renderOption(option, isSelected)) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "line-clamp-1", children: getLabel(option) }), isSelected ? (_jsx("span", { className: "absolute right-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(CheckIcon, { className: "h-4 w-4" }) })) : null] })) }, option.id));
61
- }), combobox.isCapped ? (_jsx("div", { className: "mt-1 border-t px-2 py-1.5 text-xs text-muted-foreground", children: cappedMessage })) : null] })) })] })] }), name ? _jsx("input", { type: "hidden", name: name, value: value ?? '' }) : null] }));
55
+ className: cn('p-0 w-[var(--radix-popover-trigger-width)] min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md', contentClassName), children: [_jsx("div", { className: "p-2 border-b border-border", children: _jsx("input", { ref: inputRef, type: "text", placeholder: searchPlaceholder, value: combobox.search, onChange: (e) => combobox.setSearch(e.target.value), onKeyDown: (e) => e.stopPropagation(), className: "w-full px-2 py-1 text-sm border border-input rounded bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" }) }), _jsxs("div", { className: "p-1 max-h-[200px] overflow-y-auto", children: [actionButton ? (_jsx("button", { type: "button", onClick: () => {
56
+ setOpen(false);
57
+ actionButton.onClick();
58
+ }, className: cn(
59
+ // Same row geometry as an option row so the picker reads
60
+ // as one consistent list. Primary-coloured label keeps it
61
+ // visually distinct from real options.
62
+ 'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none text-left font-medium text-primary', 'hover:bg-muted focus:bg-muted'), children: actionButton.label }, "__action__")) : null, combobox.isLoading && combobox.options.length === 0 ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-muted-foreground", children: loadingMessage })) : combobox.error ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-destructive", children: combobox.error.message || 'Failed to load options.' })) : combobox.options.length === 0 ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-muted-foreground", children: emptyMessage })) : (_jsxs(_Fragment, { children: [combobox.options.map((option) => {
63
+ const isSelected = option.id === value;
64
+ return (_jsx("button", { type: "button", role: "option", "aria-selected": isSelected, onClick: () => handleSelect(option.id), className: cn(
65
+ // Mirrors SelectItem styling: padding, hover via
66
+ // focus:bg-muted, selected via bg-accent.
67
+ 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none text-left', 'hover:bg-muted focus:bg-muted focus:text-foreground', isSelected && 'bg-accent text-foreground'), children: renderOption ? (renderOption(option, isSelected)) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "line-clamp-1", children: getLabel(option) }), isSelected ? (_jsx("span", { className: "absolute right-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(CheckIcon, { className: "h-4 w-4" }) })) : null] })) }, option.id));
68
+ }), combobox.isCapped ? (_jsx("div", { className: "mt-1 border-t px-2 py-1.5 text-xs text-muted-foreground", children: cappedMessage })) : null] }))] })] })] }), name ? _jsx("input", { type: "hidden", name: name, value: value ?? '' }) : null] }));
62
69
  }
@@ -34,7 +34,7 @@ const SearchableComboboxInput = forwardRef(({ field, value, onChange }, ref) =>
34
34
  debounceMs: source.debounceMs,
35
35
  restoreIds,
36
36
  });
37
- return (_jsxs("div", { ref: ref, children: [field.label && (_jsx("label", { htmlFor: field.name, className: "block text-sm font-medium text-foreground mb-1", children: field.label })), _jsx(PagamioSearchableCombobox, { combobox: combobox, value: value ?? null, onChange: (id) => onChange?.(id), placeholder: field.placeholder, disabled: field.disabled, required: field.validation?.required ? true : undefined, name: field.name, getLabel: source.getLabel })] }));
37
+ return (_jsxs("div", { ref: ref, children: [field.label && (_jsx("label", { htmlFor: field.name, className: "block text-sm font-medium text-foreground mb-1", children: field.label })), _jsx(PagamioSearchableCombobox, { combobox: combobox, value: value ?? null, onChange: (id) => onChange?.(id), actionButton: field.actionButton, placeholder: field.placeholder, disabled: field.disabled, required: field.validation?.required ? true : undefined, name: field.name, getLabel: source.getLabel })] }));
38
38
  });
39
39
  SearchableComboboxInput.displayName = 'SearchableComboboxInput';
40
40
  export default SearchableComboboxInput;
@@ -215,6 +215,17 @@ export interface Field {
215
215
  * Tells the form engine how to fetch lookup results.
216
216
  */
217
217
  source?: ComboboxSourceSpec;
218
+ /**
219
+ * Optional in-picker action for `searchable-combobox`. Renders as the
220
+ * first row of the popover (above the options) — the same place a
221
+ * "+ Create new …" sentinel option used to live in the old static
222
+ * `<Select>`. Clicking it closes the popover and fires `onClick`; the
223
+ * handler owns the side effect (e.g. opens a quick-create modal).
224
+ */
225
+ actionButton?: {
226
+ label: string;
227
+ onClick: () => void;
228
+ };
218
229
  }
219
230
  export interface DependentFieldUpdate {
220
231
  field: string;
@@ -1,4 +1,3 @@
1
- import 'jspdf-autotable';
2
1
  import { type MRT_ColumnDef } from 'mantine-react-table';
3
2
  interface ThemeColors {
4
3
  primary: {
@@ -36,9 +35,9 @@ interface CsvExportOptions {
36
35
  delimiter?: string;
37
36
  filename?: string;
38
37
  }
39
- export declare const exportToCsv: <T extends Record<string, any>>(data: T[], columns: MRT_ColumnDef<T>[], options?: CsvExportOptions) => void;
40
- export declare const exportToXlsx: <T extends Record<string, any>>(data: T[], columns: MRT_ColumnDef<T>[], options?: XlsxExportOptions) => void;
41
- export declare const exportToPdf: <T extends Record<string, any>>(data: T[], columns: MRT_ColumnDef<T>[], options?: PdfExportOptions) => Promise<void>;
38
+ export declare const exportToCsv: <T extends Record<string, any>>(data: T[], allColumns: MRT_ColumnDef<T>[], options?: CsvExportOptions) => void;
39
+ export declare const exportToXlsx: <T extends Record<string, any>>(data: T[], allColumns: MRT_ColumnDef<T>[], options?: XlsxExportOptions) => void;
40
+ export declare const exportToPdf: <T extends Record<string, any>>(data: T[], allColumns: MRT_ColumnDef<T>[], options?: PdfExportOptions) => Promise<void>;
42
41
  /**
43
42
  * Sends an export file to the user via email using multipart/form-data
44
43
  * Automatically includes Authorization header from cookies if available
@@ -1,7 +1,10 @@
1
- import jsPDF from 'jspdf';
2
- import 'jspdf-autotable';
1
+ import { jsPDF } from 'jspdf';
2
+ import { applyPlugin } from 'jspdf-autotable';
3
3
  import Papa from 'papaparse';
4
4
  import * as XLSX from 'xlsx';
5
+ // jspdf-autotable v5 no longer auto-attaches `autoTable` to jsPDF instances.
6
+ // applyPlugin restores the `doc.autoTable(...)` call style used below.
7
+ applyPlugin(jsPDF);
5
8
  // Helper function to convert RGB string to RGB values
6
9
  const parseRgbString = (rgbString) => {
7
10
  // Handle rgb(r, g, b) format
@@ -42,6 +45,50 @@ const loadImageAsBase64 = async (url) => {
42
45
  return null;
43
46
  }
44
47
  };
48
+ /**
49
+ * jsPDF's addImage only embeds raster formats (PNG/JPEG/WEBP), not SVG. Many
50
+ * logos are SVGs, so rasterise any SVG data URL onto a canvas and return a PNG
51
+ * data URL. Raster inputs pass through unchanged.
52
+ */
53
+ const ensureRasterImage = async (dataUrl,
54
+ // High render width so SVG logos stay crisp once scaled down into the PDF
55
+ // (the logo prints at ~30mm; rendering at 600px gives ample DPI headroom).
56
+ targetWidthPx = 600) => {
57
+ const isSvg = dataUrl.startsWith('data:image/svg');
58
+ if (!isSvg) {
59
+ const match = /^data:image\/(png|jpeg|jpg|webp)/i.exec(dataUrl);
60
+ if (!match)
61
+ return null;
62
+ const fmt = match[1].toUpperCase();
63
+ return {
64
+ dataUrl,
65
+ format: fmt === 'JPG' ? 'JPEG' : fmt,
66
+ };
67
+ }
68
+ return new Promise((resolve) => {
69
+ const img = new Image();
70
+ img.onload = () => {
71
+ // SVGs may report 0 intrinsic size; fall back to a 2:1 logo ratio so the
72
+ // canvas is never degenerate (which would yield a blank/blurry raster).
73
+ const ratio = img.naturalWidth > 0 ? img.naturalHeight / img.naturalWidth : 0.5;
74
+ const canvas = document.createElement('canvas');
75
+ canvas.width = targetWidthPx;
76
+ canvas.height = Math.max(1, Math.round(targetWidthPx * ratio));
77
+ const ctx = canvas.getContext('2d');
78
+ if (!ctx)
79
+ return resolve(null);
80
+ ctx.imageSmoothingEnabled = true;
81
+ ctx.imageSmoothingQuality = 'high';
82
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
83
+ resolve({ dataUrl: canvas.toDataURL('image/png'), format: 'PNG' });
84
+ };
85
+ img.onerror = () => resolve(null);
86
+ // Force the SVG to rasterise at the target size rather than its (often
87
+ // missing) intrinsic size.
88
+ img.width = targetWidthPx;
89
+ img.src = dataUrl;
90
+ });
91
+ };
45
92
  // Helper functions for formatting different data types
46
93
  const formatStatusObject = (value) => {
47
94
  if ('label' in value && typeof value.label === 'string') {
@@ -224,8 +271,10 @@ const formatCellContentForExcel = (value, columnKey) => {
224
271
  return String(value);
225
272
  };
226
273
  // CSV Export
227
- export const exportToCsv = (data, columns, options = {}) => {
274
+ export const exportToCsv = (data, allColumns, options = {}) => {
228
275
  const { title = 'Data Export', includeTimestamp = true, includeHeaders = true, delimiter = ',', filename } = options;
276
+ // Drop display-only columns (no accessorKey) so keys/headers stay aligned.
277
+ const columns = allColumns.filter((col) => Boolean(col.accessorKey));
229
278
  const columnKeys = columns.map((col) => col.accessorKey);
230
279
  const headers = columns.map((col) => formatHeaderText(col.header || ''));
231
280
  // Prepare data with proper formatting
@@ -471,7 +520,9 @@ const addMergedCells = (worksheet, title, hasSubtitle, headers) => {
471
520
  }
472
521
  };
473
522
  // XLSX Export
474
- export const exportToXlsx = (data, columns, options = {}) => {
523
+ export const exportToXlsx = (data, allColumns, options = {}) => {
524
+ // Drop display-only columns (no accessorKey) so keys/headers stay aligned.
525
+ const columns = allColumns.filter((col) => Boolean(col.accessorKey));
475
526
  const { title = 'Data Export', subtitle, colors, sheetName = 'Data', includeTimestamp = true, autoFitColumns = true, } = options;
476
527
  const columnKeys = columns.map((col) => col.accessorKey);
477
528
  const headers = columns.map((col) => formatHeaderText(col.header || ''));
@@ -509,6 +560,11 @@ export const exportToXlsx = (data, columns, options = {}) => {
509
560
  };
510
561
  // Helper function to check if a column likely contains images
511
562
  const isImageColumn = (columnKey, data) => {
563
+ // Display-only columns (e.g. an "actions" column) have no accessorKey, so
564
+ // columnKey can be undefined here. They hold no exportable data.
565
+ if (!columnKey) {
566
+ return false;
567
+ }
512
568
  const imageKeywords = ['image', 'photo', 'picture', 'avatar', 'logo'];
513
569
  const keyLower = columnKey.toLowerCase();
514
570
  // Check if column key suggests images
@@ -579,7 +635,11 @@ const prepareTableDataForPdf = async (data, columns, doc) => {
579
635
  return { tableData, imageInfo };
580
636
  };
581
637
  // PDF Export
582
- export const exportToPdf = async (data, columns, options = {}) => {
638
+ export const exportToPdf = async (data, allColumns, options = {}) => {
639
+ // Only data-bound columns are exportable; display-only columns (e.g. an
640
+ // "actions" column) have no accessorKey and would otherwise produce
641
+ // undefined keys/headers that crash downstream formatting.
642
+ const columns = allColumns.filter((col) => Boolean(col.accessorKey));
583
643
  const { title = 'Data Export', subtitle = `Generated on ${new Date().toLocaleDateString('en-US', {
584
644
  year: 'numeric',
585
645
  month: 'long',
@@ -606,10 +666,11 @@ export const exportToPdf = async (data, columns, options = {}) => {
606
666
  if (logo?.url) {
607
667
  try {
608
668
  const logoBase64 = await loadImageAsBase64(logo.url);
609
- if (logoBase64) {
669
+ const raster = logoBase64 ? await ensureRasterImage(logoBase64) : null;
670
+ if (raster) {
610
671
  const logoWidth = logo.width || 30;
611
672
  const logoHeight = logo.height || 15;
612
- doc.addImage(logoBase64, 'PNG', margin, currentY, logoWidth, logoHeight);
673
+ doc.addImage(raster.dataUrl, raster.format, margin, currentY, logoWidth, logoHeight);
613
674
  currentY += logoHeight + 5;
614
675
  }
615
676
  }
@@ -638,7 +699,6 @@ export const exportToPdf = async (data, columns, options = {}) => {
638
699
  const columnHeaders = columns.map((col) => formatHeaderText(col.header || String(col.accessorKey)));
639
700
  // Format table data with enhanced handling for images, status, etc.
640
701
  const { tableData, imageInfo } = await prepareTableDataForPdf(data, columns, doc);
641
- console.log(`PDF export: Processing ${tableData.length} rows with ${imageInfo.size} images`);
642
702
  // Calculate optimal column widths
643
703
  const availableWidth = pageWidth - 2 * margin;
644
704
  const numColumns = columnHeaders.length;
@@ -20,11 +20,23 @@ export const createPdfExportOptions = (config, title) => {
20
20
  primary: config.theme.colors.primary,
21
21
  core: config.theme.colors.core,
22
22
  },
23
- logo: {
24
- url: config.branding.navbarLogo?.path || config.branding.logo.path,
25
- width: config.branding.navbarLogo?.width || config.branding.logo.width,
26
- height: config.branding.navbarLogo?.height || config.branding.logo.height,
27
- },
23
+ logo: scalePdfLogo(config.branding.navbarLogo?.path || config.branding.logo.path, config.branding.navbarLogo?.width || config.branding.logo.width, config.branding.navbarLogo?.height || config.branding.logo.height),
24
+ };
25
+ };
26
+ /**
27
+ * Branding logo dimensions in `config` are CSS pixels (sized for the navbar).
28
+ * `exportToPdf` interprets logo width/height as millimetres, so passing the raw
29
+ * pixel values renders a giant logo that fills the page. Scale to a fixed print
30
+ * width (mm) while preserving the source aspect ratio.
31
+ */
32
+ const PDF_LOGO_WIDTH_MM = 32;
33
+ const PDF_LOGO_FALLBACK_HEIGHT_MM = 12;
34
+ const scalePdfLogo = (url, pxWidth, pxHeight) => {
35
+ const aspect = pxWidth && pxHeight && pxWidth > 0 ? pxHeight / pxWidth : undefined;
36
+ return {
37
+ url,
38
+ width: PDF_LOGO_WIDTH_MM,
39
+ height: aspect ? Math.round(PDF_LOGO_WIDTH_MM * aspect) : PDF_LOGO_FALLBACK_HEIGHT_MM,
28
40
  };
29
41
  };
30
42
  /**
@@ -3,3 +3,4 @@ export * from './useMediaQueries';
3
3
  export * from './useSessionTimer';
4
4
  export * from './usePagamioTable';
5
5
  export * from './usePagamioCombobox';
6
+ export * from './useInfiniteCursor';
@@ -3,3 +3,4 @@ export * from './useMediaQueries';
3
3
  export * from './useSessionTimer';
4
4
  export * from './usePagamioTable';
5
5
  export * from './usePagamioCombobox';
6
+ export * from './useInfiniteCursor';
@@ -0,0 +1,104 @@
1
+ /**
2
+ * useInfiniteCursor — TanStack `useInfiniteQuery` wrapper for cursor-paginated
3
+ * Pagamio endpoints.
4
+ *
5
+ * Matches the backend cursor contract from `pagamio-nestjs-api-commons`:
6
+ * `GET /<resource>/feed` → CursorPaginatedResponseDto<T> =
7
+ * { data: T[], meta: { nextCursor, previousCursor, hasNextPage,
8
+ * hasPreviousPage, count, total? } }
9
+ *
10
+ * The hook treats `nextCursor` as an opaque base64 string — never parse or
11
+ * construct cursors on the client. If the backend changes its cursor encoding,
12
+ * the frontend doesn't need to know.
13
+ *
14
+ * For tables and any page-numbered UI, use `usePagamioTable` (offset paging)
15
+ * instead. Cursor pagination is for infinite-scroll surfaces (storefront,
16
+ * mobile feeds) where "Page X of Y" UX isn't shown.
17
+ *
18
+ * SRP: Wires `useInfiniteQuery` to the cursor contract. Callers own:
19
+ * - flattening `data.pages` into a single list
20
+ * - intersection-observer / "load more" wiring
21
+ * - reading `data.pages[0]?.meta.total` for header counts (only present
22
+ * when the caller passed `withTotal=true` on the first page).
23
+ */
24
+ import { type QueryKey, type UseInfiniteQueryResult } from '@tanstack/react-query';
25
+ /**
26
+ * Wire shape for one page of a cursor-paginated Pagamio response.
27
+ *
28
+ * Mirrors `CursorPaginatedResponseDto<T>` from `@pagamio/nestjs-api-commons`.
29
+ * `total` is optional and only set when the caller passes `withTotal=true`
30
+ * (conventionally first page only — see backend `.agent/conventions/pagination.md`).
31
+ */
32
+ export type CursorResponse<T> = {
33
+ data: T[];
34
+ meta: {
35
+ nextCursor: string | null;
36
+ previousCursor: string | null;
37
+ hasNextPage: boolean;
38
+ hasPreviousPage: boolean;
39
+ count: number;
40
+ total?: number;
41
+ };
42
+ };
43
+ /**
44
+ * Documentation-as-code marker for the cursor meta shape. Mirrors the role
45
+ * of `pagamioMapping` for offset pagination — gives consumers a single named
46
+ * export to reference. Not consumed by `useInfiniteCursor` itself (TanStack
47
+ * handles the wiring); useful for grep / IDE jump-to-definition.
48
+ */
49
+ export declare const cursorMapping: {
50
+ responseType: "cursor";
51
+ data: string;
52
+ nextCursor: string;
53
+ hasNextPage: string;
54
+ total: string;
55
+ };
56
+ export interface UseInfiniteCursorConfig<T> {
57
+ /** TanStack query key. Include scope params (orgId, BU id, filters) so
58
+ * changes invalidate correctly. */
59
+ queryKey: QueryKey;
60
+ /** Fetches one page. Receives the cursor for the next page (undefined on
61
+ * first page) and an AbortSignal forwarded from TanStack. */
62
+ queryFn: (args: {
63
+ cursor: string | undefined;
64
+ signal: AbortSignal;
65
+ }) => Promise<CursorResponse<T>>;
66
+ enabled?: boolean;
67
+ staleTime?: number;
68
+ gcTime?: number;
69
+ /**
70
+ * When true, the previous page set stays visible while the query refetches
71
+ * after a key change (filter swap, sort change). Prevents the consumer's
72
+ * grid from flashing to a skeleton between filter changes. Maps to
73
+ * TanStack's `placeholderData: keepPreviousData`.
74
+ */
75
+ keepPreviousResults?: boolean;
76
+ }
77
+ /**
78
+ * Cursor-paginated infinite query for Pagamio `/feed` endpoints.
79
+ *
80
+ * Returns the raw TanStack `useInfiniteQueryResult` — no custom shape — so
81
+ * callers keep full access to `fetchNextPage`, `hasNextPage`, `isFetchingNextPage`,
82
+ * `data.pages`, etc.
83
+ *
84
+ * Example:
85
+ * ```ts
86
+ * const query = useInfiniteCursor<Product>({
87
+ * queryKey: ['catalog', 'products', { businessUnitId, search }],
88
+ * queryFn: ({ cursor, signal }) =>
89
+ * apiClient
90
+ * .get('/ecommerce/catalog/products/feed', {
91
+ * params: { businessUnitId, cursor, limit: 30, withTotal: cursor === undefined },
92
+ * signal,
93
+ * })
94
+ * .then((r) => r.data),
95
+ * });
96
+ *
97
+ * const items = query.data?.pages.flatMap((p) => p.data) ?? [];
98
+ * const total = query.data?.pages[0]?.meta.total;
99
+ * ```
100
+ */
101
+ export declare function useInfiniteCursor<T>({ queryKey, queryFn, enabled, staleTime, gcTime, keepPreviousResults, }: UseInfiniteCursorConfig<T>): UseInfiniteQueryResult<{
102
+ pages: Array<CursorResponse<T>>;
103
+ pageParams: Array<string | undefined>;
104
+ }, Error>;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * useInfiniteCursor — TanStack `useInfiniteQuery` wrapper for cursor-paginated
3
+ * Pagamio endpoints.
4
+ *
5
+ * Matches the backend cursor contract from `pagamio-nestjs-api-commons`:
6
+ * `GET /<resource>/feed` → CursorPaginatedResponseDto<T> =
7
+ * { data: T[], meta: { nextCursor, previousCursor, hasNextPage,
8
+ * hasPreviousPage, count, total? } }
9
+ *
10
+ * The hook treats `nextCursor` as an opaque base64 string — never parse or
11
+ * construct cursors on the client. If the backend changes its cursor encoding,
12
+ * the frontend doesn't need to know.
13
+ *
14
+ * For tables and any page-numbered UI, use `usePagamioTable` (offset paging)
15
+ * instead. Cursor pagination is for infinite-scroll surfaces (storefront,
16
+ * mobile feeds) where "Page X of Y" UX isn't shown.
17
+ *
18
+ * SRP: Wires `useInfiniteQuery` to the cursor contract. Callers own:
19
+ * - flattening `data.pages` into a single list
20
+ * - intersection-observer / "load more" wiring
21
+ * - reading `data.pages[0]?.meta.total` for header counts (only present
22
+ * when the caller passed `withTotal=true` on the first page).
23
+ */
24
+ import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query';
25
+ /**
26
+ * Documentation-as-code marker for the cursor meta shape. Mirrors the role
27
+ * of `pagamioMapping` for offset pagination — gives consumers a single named
28
+ * export to reference. Not consumed by `useInfiniteCursor` itself (TanStack
29
+ * handles the wiring); useful for grep / IDE jump-to-definition.
30
+ */
31
+ export const cursorMapping = {
32
+ responseType: 'cursor',
33
+ data: 'data',
34
+ nextCursor: 'meta.nextCursor',
35
+ hasNextPage: 'meta.hasNextPage',
36
+ total: 'meta.total',
37
+ };
38
+ /**
39
+ * Cursor-paginated infinite query for Pagamio `/feed` endpoints.
40
+ *
41
+ * Returns the raw TanStack `useInfiniteQueryResult` — no custom shape — so
42
+ * callers keep full access to `fetchNextPage`, `hasNextPage`, `isFetchingNextPage`,
43
+ * `data.pages`, etc.
44
+ *
45
+ * Example:
46
+ * ```ts
47
+ * const query = useInfiniteCursor<Product>({
48
+ * queryKey: ['catalog', 'products', { businessUnitId, search }],
49
+ * queryFn: ({ cursor, signal }) =>
50
+ * apiClient
51
+ * .get('/ecommerce/catalog/products/feed', {
52
+ * params: { businessUnitId, cursor, limit: 30, withTotal: cursor === undefined },
53
+ * signal,
54
+ * })
55
+ * .then((r) => r.data),
56
+ * });
57
+ *
58
+ * const items = query.data?.pages.flatMap((p) => p.data) ?? [];
59
+ * const total = query.data?.pages[0]?.meta.total;
60
+ * ```
61
+ */
62
+ export function useInfiniteCursor({ queryKey, queryFn, enabled, staleTime, gcTime, keepPreviousResults, }) {
63
+ return useInfiniteQuery({
64
+ queryKey,
65
+ queryFn: ({ pageParam, signal }) => queryFn({ cursor: pageParam, signal }),
66
+ initialPageParam: undefined,
67
+ getNextPageParam: (lastPage) => lastPage.meta.hasNextPage && lastPage.meta.nextCursor ? lastPage.meta.nextCursor : undefined,
68
+ enabled,
69
+ staleTime,
70
+ gcTime,
71
+ ...(keepPreviousResults ? { placeholderData: keepPreviousData } : {}),
72
+ });
73
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pagamio/frontend-commons-lib",
3
3
  "description": "Pagamio library for Frontend reusable components like the form engine and table container",
4
- "version": "0.8.350",
4
+ "version": "0.8.352",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "provenance": false
@@ -100,8 +100,8 @@
100
100
  "dayjs": "^1.11.13",
101
101
  "file-saver": "^2.0.5",
102
102
  "js-cookie": "^3.0.5",
103
- "jspdf": "^2.5.2",
104
- "jspdf-autotable": "^3.8.4",
103
+ "jspdf": "^4.2.1",
104
+ "jspdf-autotable": "^5.0.8",
105
105
  "jwt-decode": "^4.0.0",
106
106
  "lodash": "^4.17.21",
107
107
  "papaparse": "^5.4.1",