@aglyn/plugins-commerce 1.0.0-beta.199 → 1.0.0-beta.201

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.
Files changed (111) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/console/catalog-organization-card.component.js +48 -3
  3. package/src/lib/components/console/catalog-organization-card.component.js.map +1 -1
  4. package/src/lib/components/console/gift-cards-card.component.d.ts +9 -0
  5. package/src/lib/components/console/gift-cards-card.component.js +53 -28
  6. package/src/lib/components/console/gift-cards-card.component.js.map +1 -1
  7. package/src/lib/components/console/host-orders-card.component.d.ts +26 -0
  8. package/src/lib/components/console/host-orders-card.component.js +264 -194
  9. package/src/lib/components/console/host-orders-card.component.js.map +1 -1
  10. package/src/lib/components/console/order-detail-dialog.component.js +12 -0
  11. package/src/lib/components/console/order-detail-dialog.component.js.map +1 -1
  12. package/src/lib/components/console/pos-page.component.d.ts +14 -0
  13. package/src/lib/components/console/pos-page.component.js +86 -45
  14. package/src/lib/components/console/pos-page.component.js.map +1 -1
  15. package/src/lib/components/console/product-editor-dialog.component.js +30 -6
  16. package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
  17. package/src/lib/components/console/product-slugs.d.ts +43 -0
  18. package/src/lib/components/console/product-slugs.js +60 -0
  19. package/src/lib/components/console/product-slugs.js.map +1 -0
  20. package/src/lib/components/console/products-hub-card.component.d.ts +2 -2
  21. package/src/lib/components/console/products-hub-card.component.js +229 -282
  22. package/src/lib/components/console/products-hub-card.component.js.map +1 -1
  23. package/src/lib/components/console/products-hub-zone.component.d.ts +10 -5
  24. package/src/lib/components/console/products-hub-zone.component.js +32 -9
  25. package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
  26. package/src/lib/components/console/recovery-queue-card.component.d.ts +37 -5
  27. package/src/lib/components/console/recovery-queue-card.component.js +107 -86
  28. package/src/lib/components/console/recovery-queue-card.component.js.map +1 -1
  29. package/src/lib/components/console/reservations-card.component.js +15 -3
  30. package/src/lib/components/console/reservations-card.component.js.map +1 -1
  31. package/src/lib/components/console/smart-collections.d.ts +32 -0
  32. package/src/lib/components/console/smart-collections.js +57 -0
  33. package/src/lib/components/console/smart-collections.js.map +1 -0
  34. package/src/lib/components/console/stock-movements-card.component.d.ts +13 -4
  35. package/src/lib/components/console/stock-movements-card.component.js +142 -180
  36. package/src/lib/components/console/stock-movements-card.component.js.map +1 -1
  37. package/src/lib/components/product-grid.d.ts +5 -0
  38. package/src/lib/components/product-grid.js +95 -34
  39. package/src/lib/components/product-grid.js.map +1 -1
  40. package/src/lib/constants/orders-list-query.d.ts +40 -0
  41. package/src/lib/constants/orders-list-query.js +205 -0
  42. package/src/lib/constants/orders-list-query.js.map +1 -0
  43. package/src/lib/constants/product-list-query.d.ts +42 -0
  44. package/src/lib/constants/product-list-query.js +217 -0
  45. package/src/lib/constants/product-list-query.js.map +1 -0
  46. package/src/lib/constants/stock-movement-query.d.ts +27 -0
  47. package/src/lib/constants/stock-movement-query.js +110 -0
  48. package/src/lib/constants/stock-movement-query.js.map +1 -0
  49. package/src/lib/constants/storefront-catalog-query.d.ts +103 -0
  50. package/src/lib/constants/storefront-catalog-query.js +312 -0
  51. package/src/lib/constants/storefront-catalog-query.js.map +1 -0
  52. package/src/lib/model/checkout-recovery.d.ts +37 -0
  53. package/src/lib/model/checkout-recovery.js +44 -0
  54. package/src/lib/model/checkout-recovery.js.map +1 -0
  55. package/src/lib/model/commerce-orders.d.ts +7 -0
  56. package/src/lib/model/commerce-orders.js.map +1 -1
  57. package/src/lib/model/commerce.d.ts +80 -0
  58. package/src/lib/model/commerce.js +67 -6
  59. package/src/lib/model/commerce.js.map +1 -1
  60. package/src/lib/model/gift-card-search.d.ts +24 -0
  61. package/src/lib/model/gift-card-search.js +48 -0
  62. package/src/lib/model/gift-card-search.js.map +1 -0
  63. package/src/lib/model/index.d.ts +1 -0
  64. package/src/lib/model/index.js +1 -0
  65. package/src/lib/model/index.js.map +1 -1
  66. package/src/lib/model/order-list-fields.d.ts +80 -0
  67. package/src/lib/model/order-list-fields.js +112 -0
  68. package/src/lib/model/order-list-fields.js.map +1 -0
  69. package/src/lib/server/billing-webhook.js +180 -99
  70. package/src/lib/server/billing-webhook.js.map +1 -1
  71. package/src/lib/server/cancel-order.js +6 -6
  72. package/src/lib/server/cancel-order.js.map +1 -1
  73. package/src/lib/server/cart-checkout.js +9 -2
  74. package/src/lib/server/cart-checkout.js.map +1 -1
  75. package/src/lib/server/catalog.d.ts +46 -30
  76. package/src/lib/server/catalog.js +507 -170
  77. package/src/lib/server/catalog.js.map +1 -1
  78. package/src/lib/server/checkout.js +3 -1
  79. package/src/lib/server/checkout.js.map +1 -1
  80. package/src/lib/server/collection-membership.d.ts +35 -0
  81. package/src/lib/server/collection-membership.js +172 -0
  82. package/src/lib/server/collection-membership.js.map +1 -0
  83. package/src/lib/server/draft-order.js +5 -3
  84. package/src/lib/server/draft-order.js.map +1 -1
  85. package/src/lib/server/gift-cards.js +7 -1
  86. package/src/lib/server/gift-cards.js.map +1 -1
  87. package/src/lib/server/member-name-search.d.ts +17 -0
  88. package/src/lib/server/member-name-search.js +23 -1
  89. package/src/lib/server/member-name-search.js.map +1 -1
  90. package/src/lib/server/membership-account.js +7 -3
  91. package/src/lib/server/membership-account.js.map +1 -1
  92. package/src/lib/server/membership-admin-password.js +8 -2
  93. package/src/lib/server/membership-admin-password.js.map +1 -1
  94. package/src/lib/server/membership-recover.js +4 -1
  95. package/src/lib/server/membership-recover.js.map +1 -1
  96. package/src/lib/server/membership-register.js +3 -1
  97. package/src/lib/server/membership-register.js.map +1 -1
  98. package/src/lib/server/pos-order.js +9 -5
  99. package/src/lib/server/pos-order.js.map +1 -1
  100. package/src/lib/server/process-abandoned.js +20 -7
  101. package/src/lib/server/process-abandoned.js.map +1 -1
  102. package/src/lib/server/process-restock.js +6 -2
  103. package/src/lib/server/process-restock.js.map +1 -1
  104. package/src/lib/server/reserve-stock.js +3 -4
  105. package/src/lib/server/reserve-stock.js.map +1 -1
  106. package/src/lib/server/reserve.js +3 -1
  107. package/src/lib/server/reserve.js.map +1 -1
  108. package/src/lib/server/site-page-enricher.js +3 -3
  109. package/src/lib/server/site-page-enricher.js.map +1 -1
  110. package/src/lib/server.js +4 -0
  111. package/src/lib/server.js.map +1 -1
@@ -19,36 +19,41 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
19
19
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
20
20
  import * as Aglyn from "@aglyn/aglyn";
21
21
  import * as CommerceModel from "../../model/index.js";
22
- import { PRODUCT_LIST_FILTER_FIELDS } from "../../constants/product-filters.js";
22
+ import { PRODUCT_LIST_BASE, PRODUCT_LIST_FIELDS, PRODUCT_LIST_HEADERS, PRODUCT_LIST_OPTIONS, PRODUCT_LIST_QUERY, PRODUCT_LIST_SELECT_FIELDS, productListRequestClauses } from "../../constants/product-list-query.js";
23
23
  import { CardDisplay, useConfirmationContext } from "@aglyn/shared-ui-jsx";
24
24
  import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
25
25
  import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
26
+ import { ListQueryNotices, listQueryRefusals } from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
26
27
  import { ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
27
28
  import QuotaReadoutComponent from "@aglyn/shared-ui-jsx/components/quota-readout.component";
28
- import { filterListRows, inMemoryListField, listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
29
- import { TABLE_PAGE_SIZE_DEFAULT } from "@aglyn/shared-ui-jsx/const/table-pagination";
29
+ import { hiddenFilterVisibility } from "@aglyn/shared-ui-jsx/const/list-filter";
30
+ import { listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
30
31
  import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
31
32
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
32
33
  import { Timestamp } from "@aglyn/shared-util-timestamp";
33
- import { Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField, Typography } from "@mui/material";
34
- import { addDoc, collection, doc, documentId, getCountFromServer, limit, orderBy, query, updateDoc, where } from "firebase/firestore";
34
+ import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField, Typography } from "@mui/material";
35
+ import { addDoc, collection, doc, documentId, getCountFromServer, getDocs, limit, orderBy, query, startAfter, updateDoc, where } from "firebase/firestore";
35
36
  import { useCallback, useEffect, useMemo, useState } from "react";
36
37
  import { useFirestore } from "@aglyn/tenant-feature-instance";
37
- import { listFilterConstraints, useFirestoreCollection, writeGuardedBySeed } from "@aglyn/tenant-feature-instance";
38
+ import { useFirestoreCollection, writeGuardedBySeed } from "@aglyn/tenant-feature-instance";
39
+ import { listQueryConstraints, useListQuery } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
38
40
  import { useHostResourceApi } from "@aglyn/tenant-feature-instance";
39
41
  import { useOrgPlan } from "@aglyn/tenant-feature-instance";
40
42
  import ProductEditorDialog from "./product-editor-dialog.component.js";
43
+ import { productSlugLedger } from "./product-slugs.js";
44
+ import { productCollectionFields, readSmartCollections } from "./smart-collections.js";
41
45
  import ProductsHubZone from "./products-hub-zone.component.js";
42
46
  import { pluginDocsHelp } from "@aglyn/aglyn";
43
47
  import { useConsoleWidgetSlot } from "@aglyn/aglyn/app-utils/console-widget-slot-context";
48
+ /** Products read per request of the CSV export's walk. */ const EXPORT_PAGE = 500;
44
49
  /**
45
- * How many catalog documents the table's listener holds.
50
+ * The most products one CSV export writes (AGL-3321).
46
51
  *
47
- * A CEILING, not a page size: a 25,000-product catalog does not belong in a
48
- * table, and three other readers on this card — the CSV export, the importer's
49
- * duplicate-slug check and the reserved-stock clock — need the window whole.
50
- * The footer pages what this holds; the query is what bounds it.
51
- */ const CATALOG_CEILING = 500;
52
+ * The export walks the table's own query page by page, so it writes what the
53
+ * filters and search match across the whole catalog rather than the rows on
54
+ * screen. It stops here because the file is built in the browser, and says
55
+ * so when it does.
56
+ */ const EXPORT_CEILING = 10000;
52
57
  /**
53
58
  * How many of ONE product's license keys the dialog reads.
54
59
  *
@@ -60,42 +65,23 @@ const STATUS_COLOR = {
60
65
  draft: 'warning',
61
66
  archived: 'default'
62
67
  };
63
- /*
64
- * What the products grid's Filters panel offers: Status, a select. The
65
- * query serves it whenever no search is typed; under a search the query is
66
- * the search, and Status narrows its matches over the rows the card holds.
67
- */ const PRODUCT_GRID_FILTER_FIELDS = [
68
- inMemoryListField('status', 'select')
68
+ /** The grid's own columns; every other declared field is a hidden filter column. */ const PRODUCT_VISIBLE_COLUMNS = [
69
+ 'name',
70
+ 'status',
71
+ 'type',
72
+ 'priceUsd',
73
+ 'stock',
74
+ 'variants'
69
75
  ];
70
- const PRODUCT_GRID_FILTER_HEADERS = {
71
- status: 'Status'
72
- };
73
- const PRODUCT_GRID_FILTER_OPTIONS = {
74
- status: [
75
- {
76
- value: 'active',
77
- label: 'Active'
78
- },
79
- {
80
- value: 'draft',
81
- label: 'Draft'
82
- },
83
- {
84
- value: 'archived',
85
- label: 'Archived'
86
- }
87
- ]
88
- };
89
- const PRODUCT_GRID_SELECT_FIELDS = Object.keys(PRODUCT_GRID_FILTER_OPTIONS);
90
76
  /**
91
77
  * Products hub v1 (AGL-279): the catalog manager replacing the Commerce
92
- * Starter card — search + status filter over `hosts/{hostId}/products`,
93
- * full editor dialog, duplicate, archive/activate, soft delete (past
78
+ * Starter card — a table over `hosts/{hostId}/products` whose search and
79
+ * filters are its Firestore query (AGL-3321), full editor dialog, duplicate, archive/activate, soft delete (past
94
80
  * order rows keep resolving). Product cap (`productsPerHost`, AGL-278)
95
81
  * gated here on create/duplicate/import (AGL-471); server-side
96
82
  * enforcement of the client-write path rides AGL-473.
97
83
  */ export function ProductsHubCard(props) {
98
- var _filterClauses_find, _ref, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6, _ref7, _ref8, _ref9, _ref10, _ref11, _ref12;
84
+ var _ref, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6, _ref7, _ref8, _ref9, _ref10, _ref11, _ref12;
99
85
  var _importing_parsed, _importing_parsed1, _importing_parsed2;
100
86
  const { hostId } = props;
101
87
  const firestore = useFirestore();
@@ -106,38 +92,13 @@ const PRODUCT_GRID_SELECT_FIELDS = Object.keys(PRODUCT_GRID_FILTER_OPTIONS);
106
92
  const { org, ready: planReady } = useOrgPlan(hostId);
107
93
  const { confirm } = useConfirmationContext();
108
94
  /*
109
- * The grid's quick search IS the served search: its words go to the query,
110
- * never matched again over the rows (see the query below).
111
- */ const [searchWords, setSearchWords] = useState([]);
112
- const search = searchWords.join(' ').trim();
113
- const gridFilter = useListGridFilter({
114
- selectFields: PRODUCT_GRID_SELECT_FIELDS,
115
- search: {
116
- words: searchWords,
117
- onChange: setSearchWords
118
- }
95
+ * The grid's Filters panel and quick search, bound to the clauses the
96
+ * query below serves (AGL-3321). Every clause and the search word go on
97
+ * that query; see `constants/product-list-query.ts` for what is offered.
98
+ */ const gridFilter = useListGridFilter({
99
+ selectFields: PRODUCT_LIST_SELECT_FIELDS
119
100
  });
120
- const filterClauses = gridFilter.clauses;
121
- /*
122
- * The Status clause the query can serve — only while no search is typed,
123
- * because the query carries one predicate.
124
- */ const servedStatus = search ? null : (_filterClauses_find = filterClauses.find((clause)=>clause.field === 'status' && listFilterConstraints(PRODUCT_LIST_FILTER_FIELDS, clause) !== null)) != null ? _filterClauses_find : null;
125
- /*
126
- * WHICH field the search box searches (AGL-2501). A SCOPE of the search,
127
- * not a filter: it picks which served query the search runs, so it stays
128
- * beside the grid rather than in its panel.
129
- *
130
- * The box used to compare four fields at once — name, slug, tag, SKU — over
131
- * the rows the listener had already fetched. Server-side that is not one
132
- * query: Firestore allows a single `array-contains` per query and cannot OR
133
- * across fields, so four fields at once means four queries and a merge, and
134
- * a merged result cannot be paged or capped coherently.
135
- *
136
- * Naming the field is the honest trade. It buys a search that reaches the
137
- * WHOLE catalog instead of an arbitrary five hundred rows of it, which is
138
- * the case the old box got wrong — and a merchant looking for a SKU knows
139
- * they are looking for a SKU.
140
- */ const [searchField, setSearchField] = useState('name');
101
+ const filtering = gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word)=>word.trim());
141
102
  const [editing, setEditing] = useState(null);
142
103
  const [creating, setCreating] = useState(false);
143
104
  const [adjusting, setAdjusting] = useState(null);
@@ -151,68 +112,48 @@ const PRODUCT_GRID_SELECT_FIELDS = Object.keys(PRODUCT_GRID_FILTER_OPTIONS);
151
112
  */ const [importOptions, setImportOptions] = useState({});
152
113
  const [lastImport, setLastImport] = useState(null);
153
114
  const WidgetSlot = useConsoleWidgetSlot();
154
- const { data: productDocs, status: productsStatus, /**
115
+ const { rows: productDocs, data: productWindow, hasMore, page, setPage, pageSize, setPageSize, plan, status: productsStatus, /**
155
116
  * The product rows the stock dialog is seeded from are unconfirmed by
156
117
  * the server (AGL-1358). A stock adjustment does not write the delta —
157
118
  * it recomputes the WHOLE `variants` array from the seeded product and
158
119
  * replaces it, so a cached seed silently reverts every sale, return and
159
120
  * adjustment the server has recorded since that snapshot, on every
160
121
  * variant and every location.
161
- */ fromCache: productsFromCache } = useFirestoreCollection(()=>{
122
+ */ fromCache: productsFromCache } = useListQuery({
162
123
  /*
163
- * FILTERED BY THE QUERY, and ordered rather than merely capped
164
- * (AGL-2501, AGL-2292).
165
- *
166
- * `limit(500)` with no `orderBy` returns documents in ID order, and
167
- * products are created at `createResourceUid()` — so that was a
168
- * pseudo-random SAMPLE of five hundred, which the client `.sort()` by
169
- * name below dressed up as a reliable alphabetical page. The search then
170
- * ran over that sample, so a product on the wrong side of the cap
171
- * answered "no products match": the one answer a search must never get
172
- * wrong, on the list a merchant uses to find one item in their catalog.
173
- *
174
- * The cap STAYS — a 25,000-product catalog does not belong in a table,
175
- * and the head-count has been a server aggregate since AGL-1716. What
176
- * changes is that a filter now reaches the whole collection BEFORE the
177
- * cap applies.
178
- *
179
- * ONE request, because Firestore allows one `array-contains` and the
180
- * shared translator builds one predicate. Whichever control is set alone
181
- * is answered entirely by the server; with BOTH set the name reaches the
182
- * whole catalog and the status narrows those matches client-side below,
183
- * which is complete unless a single name matches more than five hundred
184
- * products.
185
- *
186
- * ⚠️ The default ordering is by DOCUMENT ID, not by `nameLower`.
187
- * Ordering by a denormalized key drops every document that lacks it, and
188
- * an unfiltered list must not be able to hide a product — a catalog
189
- * imported before the search keys existed, or written by a path that
190
- * forgot them, would simply stop appearing. Under a name filter the
191
- * ordering does move to `nameLower`, and there it is safe: a document
192
- * without the key has no `nameTokens` either, so the `array-contains`
193
- * has already excluded it.
194
- */ const constraints = listFilterConstraints(PRODUCT_LIST_FILTER_FIELDS, search ? {
195
- field: searchField,
196
- op: 'contains',
197
- value: search
198
- } : servedStatus);
199
- return query(collection(firestore, 'hosts', hostId, 'products'), ...constraints != null ? constraints : [
200
- orderBy(documentId())
201
- ], /*
202
- * `CATALOG_CEILING + 1` is a PROBE (AGL-2501). One document past the
203
- * ceiling turns "this catalog is larger than the table" into a fact.
204
- * The probe row is dropped below and never rendered, exported or
205
- * counted.
206
- */ limit(CATALOG_CEILING + 1));
207
- }, [
208
- firestore,
209
- hostId,
210
- search,
211
- searchField,
212
- servedStatus == null ? void 0 : servedStatus.op,
213
- servedStatus == null ? void 0 : servedStatus.value
214
- ], {
215
- idField: '$id'
124
+ * PAGED BY THE QUERY, and every filter ON it (AGL-3321).
125
+ *
126
+ * The table read a five-hundred-product window and put ONE predicate on
127
+ * it — the search or the Status clause — then matched Status beside a
128
+ * search, dropped soft-deleted products and sorted by name over the rows
129
+ * it held, and paged that. Past five hundred products, a match past the
130
+ * window answered "no products match", and a page came back short by
131
+ * however many deleted products it had held.
132
+ *
133
+ * Now the plan puts the search word and every clause on one query under
134
+ * the `deletedAt == null` scope, ordered by `nameLower`, and each page is
135
+ * a page of that query — one page plus a probe row, widened as the reader
136
+ * pages forward. Nothing is matched over the rows afterwards; a clause
137
+ * the query cannot hold is refused by name (`ListQueryNotices` below).
138
+ */ collection: collection(firestore, 'hosts', hostId, 'products'),
139
+ declaration: PRODUCT_LIST_QUERY,
140
+ request: {
141
+ clauses: productListRequestClauses(gridFilter.clauses),
142
+ search: gridFilter.searchWords,
143
+ base: PRODUCT_LIST_BASE
144
+ },
145
+ deps: [
146
+ firestore,
147
+ hostId
148
+ ],
149
+ idField: '$id',
150
+ /*
151
+ * The scope and the filters read MUTABLE fields — a delete sets
152
+ * `deletedAt`, Archive moves `status`, a rename moves the tokens — so a
153
+ * product can leave this query mid-session, and the SDK can cache that
154
+ * departure as a tombstone at the product's own path (AGL-1196). A
155
+ * disappearance is confirmed against the server before it is believed.
156
+ */ confirmDisappearances: true
216
157
  });
217
158
  /*
218
159
  * License key pool (AGL-308) for the open dialog's product — asked FOR that
@@ -250,69 +191,15 @@ const PRODUCT_GRID_SELECT_FIELDS = Object.keys(PRODUCT_GRID_FILTER_OPTIONS);
250
191
  ], {
251
192
  idField: '$id'
252
193
  });
253
- /** The read went past the ceiling, so the catalog is larger than the window. */ const catalogTruncated = ((_ref1 = productDocs == null ? void 0 : productDocs.length) != null ? _ref1 : 0) > CATALOG_CEILING;
254
- const products = useMemo(()=>{
255
- /*
256
- * The SEARCH is gone from here — it is the query's job now, and running it
257
- * twice would be worse than redundant. A server `contains` matches a word
258
- * PREFIX ("cof" finds "Coffee"), while the old `includes` matched a raw
259
- * substring, so a two-word search would have been sent to the server as
260
- * its first word and then dropped again here by a whole-string compare
261
- * that the matching row never satisfies: rows found, then hidden.
262
- *
263
- * The panel's clauses are matched here, and Status only earns its keep
264
- * when a search is also active — that is the one combination the single
265
- * predicate above cannot express, and here it runs over rows the server
266
- * already matched across the whole catalog rather than over an arbitrary
267
- * window. No search words are passed: the query answered them.
268
- *
269
- * Soft-deleted products are still dropped here rather than in the query:
270
- * `deletedAt` is absent on a live product, and Firestore cannot ask for
271
- * documents that LACK a field.
272
- */ const live = (productDocs != null ? productDocs : []).slice(0, CATALOG_CEILING).filter((product)=>!product.deletedAt).map((product)=>_extends({}, CommerceModel.liftLegacyProduct(product), {
194
+ /*
195
+ * The PAGE on screen, lifted from legacy shapes. It is what the table
196
+ * draws, the reserved-stock clock watches and the zone hands its widgets,
197
+ * and nothing narrows it: the query already answered every filter, and
198
+ * soft-deleted products are outside its scope.
199
+ */ const products = useMemo(()=>(productDocs != null ? productDocs : []).map((product)=>_extends({}, CommerceModel.liftLegacyProduct(product), {
273
200
  $id: product.$id
274
- }));
275
- return filterListRows(live, PRODUCT_GRID_FILTER_FIELDS, filterClauses, {
276
- paths: [],
277
- words: []
278
- }).sort((a, b)=>a.name.localeCompare(b.name));
279
- }, [
280
- productDocs,
281
- filterClauses
282
- ]);
283
- /*==========================================
284
- * THE TABLE PAGES, and the READ deliberately does not (AGL-2501).
285
- *
286
- * The card rendered every row of a five-hundred-document window in one wall
287
- * with no footer under it — the shape this sweep is about. What it must not
288
- * become is a server-paged query, because three other things in this file
289
- * read the same window and every one of them needs it whole:
290
- *
291
- * * `handleExport` writes the CSV from these rows. A ten-row page would
292
- * silently export ten products under a filename that claims the catalog.
293
- * * the CSV importer builds `existingSlugs` from them to refuse a
294
- * duplicate slug. Narrowed to a page, it would stop seeing the clash and
295
- * create the duplicate it exists to prevent.
296
- * * the reserved-stock clock arms off them.
297
- *
298
- * The window is also already correct: `orderBy(documentId())` unfiltered,
299
- * `nameLower` under a name filter, and the filters themselves reach the
300
- * whole collection through the query. So the fix here is the CONTROL, not
301
- * the read — and the head-count beside it has been a server aggregate since
302
- * AGL-1716.
303
- *=========================================*/ const [page, setPage] = useState(0);
304
- const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT);
305
- // A filter narrows the list under the reader's feet, and page four of the
306
- // unfiltered catalog is not a position in the filtered one.
307
- useEffect(()=>setPage(0), [
308
- search,
309
- searchField,
310
- filterClauses
311
- ]);
312
- const visibleProducts = useMemo(()=>products.slice(page * pageSize, page * pageSize + pageSize), [
313
- products,
314
- page,
315
- pageSize
201
+ })), [
202
+ productDocs
316
203
  ]);
317
204
  /**
318
205
  * A CLOCK, because a hold lapses without anybody writing anything
@@ -326,7 +213,8 @@ const PRODUCT_GRID_SELECT_FIELDS = Object.keys(PRODUCT_GRID_FILTER_OPTIONS);
326
213
  *
327
214
  * Only runs while something is actually held, so an ordinary catalog costs
328
215
  * no timer. A NEW hold arrives as a document change, which re-renders, which
329
- * re-arms this.
216
+ * re-arms this. It watches the page on screen, which is every row whose
217
+ * caption it keeps true.
330
218
  */ const [nowMs, setNowMs] = useState(()=>Date.now());
331
219
  const anyHeld = useMemo(()=>products.some((product)=>CommerceModel.heldProductUnits(product, nowMs) > 0), [
332
220
  products,
@@ -360,10 +248,10 @@ const PRODUCT_GRID_SELECT_FIELDS = Object.keys(PRODUCT_GRID_FILTER_OPTIONS);
360
248
  * excluded them. The card now asks the enforcing route's question, in the
361
249
  * enforcing route's terms.
362
250
  *
363
- * THE LIST KEEPS ITS CAP, and the filtered `products` view above still
364
- * drives the table, the export and the slug set. A one-shot goes stale
365
- * where a listener refreshed for free, so the count is re-read after any
366
- * mutation that moves it.
251
+ * The table pages its own query (AGL-3321) and answers a different
252
+ * question — which products match — so it never stands in for this. A
253
+ * one-shot goes stale where a listener refreshed for free, so the count is
254
+ * re-read after any mutation that moves it.
367
255
  *
368
256
  * No counting RULE moves: `checkQuota` is untouched and `report-usage`
369
257
  * meters contacts, storage and API requests — never the catalog.
@@ -387,12 +275,11 @@ const PRODUCT_GRID_SELECT_FIELDS = Object.keys(PRODUCT_GRID_FILTER_OPTIONS);
387
275
  hostId,
388
276
  productCountEpoch
389
277
  ]);
390
- // Cap against ALL live products, not the filtered view (AGL-471). This is
391
- // the fallback now: pending or denied, it can only UNDERSTATE, never
392
- // overstate, so nothing it gates fires on a count larger than the truth.
393
- const loadedProductCount = useMemo(()=>(productDocs != null ? productDocs : []).filter((product)=>!product.deletedAt).length, [
394
- productDocs
395
- ]);
278
+ // The fallback, pending or denied: the live products the table's query has
279
+ // read, probe row included. Each is a product the site has, so it can only
280
+ // UNDERSTATE, never overstate, and nothing it gates fires on a count larger
281
+ // than the truth (AGL-471).
282
+ const loadedProductCount = (_ref1 = productWindow == null ? void 0 : productWindow.length) != null ? _ref1 : 0;
396
283
  const productCount = serverProductCount != null ? serverProductCount : loadedProductCount;
397
284
  // Gate only once the org doc has loaded: an unresolved org reads as the
398
285
  // free tier's 0-product cap, which swallowed every Add/Duplicate click.
@@ -419,17 +306,31 @@ const PRODUCT_GRID_SELECT_FIELDS = Object.keys(PRODUCT_GRID_FILTER_OPTIONS);
419
306
  persist: false
420
307
  });
421
308
  }
422
- const { $id: _sourceId } = product, copy = _object_without_properties_loose(product, [
423
- "$id"
309
+ // The source's SKU and barcode keys are not copied: the copy's own are
310
+ // derived below, and a copy of a product with none must have none.
311
+ const { $id: _sourceId, skus: _skus, barcodes: _barcodes } = product, copy = _object_without_properties_loose(product, [
312
+ "$id",
313
+ "skus",
314
+ "barcodes"
424
315
  ]);
425
316
  try {
317
+ /*
318
+ * The copy's slug is asked of the store, so a second Duplicate of
319
+ * the same product does not mint a second `-copy` (AGL-3321), and
320
+ * its search keys are the COPY's: spreading the source's would sort
321
+ * and find it by the source's name.
322
+ */ const slug = await productSlugLedger(firestore, hostId).claim(CommerceModel.commerceSlug(`${product.slug}-copy`));
426
323
  // Duplicate is a create — rides the quota-enforcing API (AGL-473).
427
324
  await createHostResource({
428
325
  hostId,
429
326
  resource: 'product',
430
- data: _extends({}, copy, {
327
+ data: _extends({}, copy, CommerceModel.productSearchFields({
431
328
  name: `${product.name} (copy)`,
432
- slug: CommerceModel.commerceSlug(`${product.slug}-copy`),
329
+ variants: product.variants
330
+ }), CommerceModel.productStockFields(product), await productCollectionFields(firestore, hostId, _extends({}, product, {
331
+ name: `${product.name} (copy)`
332
+ })), {
333
+ slug,
433
334
  status: 'draft',
434
335
  createdAtMs: Date.now(),
435
336
  updatedAtMs: Date.now()
@@ -451,6 +352,7 @@ const PRODUCT_GRID_SELECT_FIELDS = Object.keys(PRODUCT_GRID_FILTER_OPTIONS);
451
352
  });
452
353
  }
453
354
  }, [
355
+ firestore,
454
356
  hostId,
455
357
  createHostResource,
456
358
  enqueueSnackbar,
@@ -498,22 +400,72 @@ const PRODUCT_GRID_SELECT_FIELDS = Object.keys(PRODUCT_GRID_FILTER_OPTIONS);
498
400
  hostId,
499
401
  enqueueSnackbar
500
402
  ]);
501
- // CSV import/export (AGL-282): Shopify-dialect columns, dry-run first.
502
- const handleExport = useCallback(()=>{
503
- const csv = CommerceModel.productsToCsv(products);
504
- const blob = new Blob([
505
- csv
506
- ], {
507
- type: 'text/csv'
508
- });
509
- const anchor = document.createElement('a');
510
- anchor.href = URL.createObjectURL(blob);
511
- anchor.download = `products-${hostId}.csv`;
512
- anchor.click();
513
- URL.revokeObjectURL(anchor.href);
403
+ /*
404
+ * CSV import/export (AGL-282): Shopify-dialect columns, dry-run first.
405
+ *
406
+ * The export writes what the table's filters and search MATCH, across the
407
+ * whole catalog (AGL-3321): it walks the table's own query — the same plan,
408
+ * so the same scope, predicates and order — a page of `EXPORT_PAGE` at a
409
+ * time from a cursor, rather than writing the rows on screen. A walk that
410
+ * reaches `EXPORT_CEILING` stops, asks once whether anything is left, and
411
+ * says so when something is.
412
+ */ const [exporting, setExporting] = useState(false);
413
+ const handleExport = useCallback(async ()=>{
414
+ setExporting(true);
415
+ try {
416
+ const productsRef = collection(firestore, 'hosts', hostId, 'products');
417
+ const constraints = listQueryConstraints(plan);
418
+ const exported = [];
419
+ let cursor = null;
420
+ let more = false;
421
+ for(;;){
422
+ const room = EXPORT_CEILING - exported.length;
423
+ const snapshot = await getDocs(query(productsRef, ...constraints, ...cursor ? [
424
+ startAfter(cursor)
425
+ ] : [], limit(room > 0 ? Math.min(EXPORT_PAGE, room) : 1)));
426
+ if (room <= 0) {
427
+ more = !snapshot.empty;
428
+ break;
429
+ }
430
+ for (const product of snapshot.docs){
431
+ exported.push(_extends({}, CommerceModel.liftLegacyProduct(product.data()), {
432
+ $id: product.id
433
+ }));
434
+ }
435
+ if (snapshot.docs.length < Math.min(EXPORT_PAGE, room)) break;
436
+ cursor = snapshot.docs[snapshot.docs.length - 1];
437
+ }
438
+ const csv = CommerceModel.productsToCsv(exported);
439
+ const blob = new Blob([
440
+ csv
441
+ ], {
442
+ type: 'text/csv'
443
+ });
444
+ const anchor = document.createElement('a');
445
+ anchor.href = URL.createObjectURL(blob);
446
+ anchor.download = `products-${hostId}.csv`;
447
+ anchor.click();
448
+ URL.revokeObjectURL(anchor.href);
449
+ if (more) {
450
+ enqueueSnackbar(`Exported the first ${EXPORT_CEILING.toLocaleString()} matching ` + 'products — the most one file holds. Filter the list to export the rest.', {
451
+ variant: 'info',
452
+ persist: false
453
+ });
454
+ }
455
+ } catch (error) {
456
+ var _ref;
457
+ enqueueSnackbar((_ref = error == null ? void 0 : error.message) != null ? _ref : 'Could not export the products', {
458
+ variant: 'warning',
459
+ persist: false
460
+ });
461
+ } finally{
462
+ setExporting(false);
463
+ }
514
464
  }, [
515
- products,
516
- hostId
465
+ firestore,
466
+ hostId,
467
+ plan,
468
+ enqueueSnackbar
517
469
  ]);
518
470
  const handleImportApply = useCallback(async ()=>{
519
471
  const parsed = importing == null ? void 0 : importing.parsed;
@@ -530,24 +482,31 @@ const PRODUCT_GRID_SELECT_FIELDS = Object.keys(PRODUCT_GRID_FILTER_OPTIONS);
530
482
  persist: false
531
483
  });
532
484
  }
533
- const existingSlugs = new Set(products.map((product)=>product.slug));
485
+ /*
486
+ * The duplicate-slug check asks the STORE (AGL-3321): every slug the file
487
+ * names, by `slug in […]`, and any suffixed one only when it is needed.
488
+ * It asked the table's rows, which held five hundred products and now
489
+ * hold one page, so a slug past them was handed out a second time.
490
+ */ const slugs = productSlugLedger(firestore, hostId);
534
491
  const created = [];
535
492
  try {
493
+ await slugs.ask(parsed.products.map((product)=>product.slug));
494
+ // One read of the host's smart collections for the whole file.
495
+ const smartCollections = await readSmartCollections(firestore, hostId);
536
496
  // Each create rides the quota-enforcing API (AGL-473); the batch cap
537
497
  // above short-circuits before we start, so this loop stays bounded.
538
498
  for (const product of parsed.products){
539
499
  var _ref, _ref1;
540
500
  var _product_variants_, _product_mediaUrls;
541
- let slug = product.slug;
542
- while(existingSlugs.has(slug))slug = `${product.slug}-${Date.now() % 1000}`;
543
- existingSlugs.add(slug);
501
+ const slug = await slugs.claim(product.slug);
544
502
  const { id } = await createHostResource({
545
503
  hostId,
546
504
  resource: 'product',
547
- data: _extends({}, product, CommerceModel.productSearchFields(product), {
505
+ data: _extends({}, product, CommerceModel.productSearchFields(product), CommerceModel.productStockFields(product), {
506
+ // Its smart collections (AGL-3321).
507
+ collectionIds: CommerceModel.productCollectionIds(product, smartCollections),
548
508
  slug,
549
509
  priceUsd: (_ref = (_product_variants_ = product.variants[0]) == null ? void 0 : _product_variants_.priceUsd) != null ? _ref : 0,
550
- inventory: CommerceModel.productInventory(product),
551
510
  imageUrl: (_ref1 = (_product_mediaUrls = product.mediaUrls) == null ? void 0 : _product_mediaUrls[0]) != null ? _ref1 : null,
552
511
  createdAtMs: Date.now(),
553
512
  updatedAtMs: Date.now()
@@ -576,7 +535,7 @@ const PRODUCT_GRID_SELECT_FIELDS = Object.keys(PRODUCT_GRID_FILTER_OPTIONS);
576
535
  });
577
536
  }, [
578
537
  importing,
579
- products,
538
+ firestore,
580
539
  hostId,
581
540
  createHostResource,
582
541
  enqueueSnackbar,
@@ -605,13 +564,13 @@ const PRODUCT_GRID_SELECT_FIELDS = Object.keys(PRODUCT_GRID_FILTER_OPTIONS);
605
564
  unreadable: productsStatus === 'error',
606
565
  fromCache: productsFromCache
607
566
  }, async ()=>{
608
- await updateDoc(doc(firestore, 'hosts', hostId, 'products', adjusting.product.$id), {
609
- variants,
610
- inventory: CommerceModel.productInventory({
611
- variants
612
- }),
567
+ await updateDoc(doc(firestore, 'hosts', hostId, 'products', adjusting.product.$id), _extends({
568
+ variants
569
+ }, CommerceModel.productStockFields(_extends({}, adjusting.product, {
570
+ variants
571
+ })), {
613
572
  updatedAtMs: Date.now()
614
- });
573
+ }));
615
574
  // Adjustment history (AGL-281): the same log the sale webhook writes.
616
575
  await addDoc(collection(firestore, 'hosts', hostId, 'inventoryAdjustments'), _extends({
617
576
  productId: adjusting.product.$id,
@@ -813,7 +772,7 @@ const PRODUCT_GRID_SELECT_FIELDS = Object.keys(PRODUCT_GRID_FILTER_OPTIONS);
813
772
  }), {
814
773
  width: 470
815
774
  })
816
- ], PRODUCT_GRID_FILTER_FIELDS, PRODUCT_GRID_FILTER_OPTIONS, PRODUCT_GRID_FILTER_HEADERS);
775
+ ], PRODUCT_LIST_FIELDS, PRODUCT_LIST_OPTIONS, PRODUCT_LIST_HEADERS);
817
776
  return /*#__PURE__*/ _jsxs(CardDisplay, {
818
777
  /*
819
778
  * The header count is the SITE'S catalog, not the rows in hand.
@@ -825,7 +784,7 @@ const PRODUCT_GRID_SELECT_FIELDS = Object.keys(PRODUCT_GRID_FILTER_OPTIONS);
825
784
  * the aggregate counts the whole catalog and the view counts matches, so
826
785
  * neither describes what the reader is looking at, and the footer's own
827
786
  * count line answers it instead.
828
- */ header: search || filterClauses.length ? 'Products' : `Products${productCount ? ` (${productCount})` : ''}`,
787
+ */ header: filtering ? 'Products' : `Products${productCount ? ` (${productCount})` : ''}`,
829
788
  help: pluginDocsHelp('commerce', {
830
789
  anchor: '#products-hub'
831
790
  }),
@@ -842,30 +801,6 @@ const PRODUCT_GRID_SELECT_FIELDS = Object.keys(PRODUCT_GRID_FILTER_OPTIONS);
842
801
  },
843
802
  spacing: 1,
844
803
  children: [
845
- /*#__PURE__*/ _jsxs(TextField, {
846
- label: "Search in",
847
- value: searchField,
848
- onChange: (event)=>setSearchField(event.target.value),
849
- size: "small",
850
- select: true,
851
- sx: {
852
- minWidth: 170
853
- },
854
- children: [
855
- /*#__PURE__*/ _jsx(MenuItem, {
856
- value: "name",
857
- children: 'Name'
858
- }),
859
- /*#__PURE__*/ _jsx(MenuItem, {
860
- value: "skus",
861
- children: 'SKU (whole)'
862
- }),
863
- /*#__PURE__*/ _jsx(MenuItem, {
864
- value: "barcodes",
865
- children: 'Barcode (whole)'
866
- })
867
- ]
868
- }),
869
804
  /*#__PURE__*/ _jsx(Box, {
870
805
  sx: {
871
806
  flex: 1
@@ -902,9 +837,10 @@ const PRODUCT_GRID_SELECT_FIELDS = Object.keys(PRODUCT_GRID_FILTER_OPTIONS);
902
837
  }),
903
838
  /*#__PURE__*/ _jsx(Button, {
904
839
  size: "small",
905
- disabled: products.length === 0,
840
+ // Nothing matches: there is nothing for the walk to write.
841
+ disabled: exporting || products.length === 0 && page === 0,
906
842
  onClick: handleExport,
907
- children: 'Export'
843
+ children: exporting ? 'Exporting…' : 'Export'
908
844
  })
909
845
  ]
910
846
  }),
@@ -922,42 +858,53 @@ const PRODUCT_GRID_SELECT_FIELDS = Object.keys(PRODUCT_GRID_FILTER_OPTIONS);
922
858
  onCreated: onCatalogCreated
923
859
  }),
924
860
  /*#__PURE__*/ _jsx(ListFilterChips, {
925
- fields: PRODUCT_GRID_FILTER_FIELDS,
926
- headers: PRODUCT_GRID_FILTER_HEADERS,
927
- clauses: filterClauses,
861
+ fields: PRODUCT_LIST_FIELDS,
862
+ headers: PRODUCT_LIST_HEADERS,
863
+ clauses: gridFilter.clauses,
928
864
  onChange: gridFilter.setClauses,
929
- options: PRODUCT_GRID_FILTER_OPTIONS,
930
- servedField: servedStatus ? 'status' : null
865
+ options: PRODUCT_LIST_OPTIONS
866
+ }),
867
+ /*#__PURE__*/ _jsx(ListQueryNotices, {
868
+ refused: listQueryRefusals(plan.refused, {
869
+ fields: PRODUCT_LIST_FIELDS,
870
+ headers: PRODUCT_LIST_HEADERS,
871
+ options: PRODUCT_LIST_OPTIONS
872
+ }),
873
+ notices: plan.notices
931
874
  }),
932
875
  /*#__PURE__*/ _jsx(ListTable, {
933
876
  "aria-label": "Products",
934
- rows: visibleProducts,
877
+ rows: products,
935
878
  columns: productColumns,
879
+ loading: productsStatus === 'loading',
880
+ // One page of the query, turned by the footer below: the grid
881
+ // neither slices, filters nor sorts it.
882
+ hideFooter: true,
936
883
  filterMode: "server",
937
884
  filterModel: gridFilter.filterModel,
938
885
  onFilterModelChange: gridFilter.onFilterModelChange,
939
- // The search box drives the served query above.
940
886
  quickFilter: true,
941
- // `ListPagination` below pages what the card holds.
942
- hideFooter: true,
943
- noRowsLabel: search || filterClauses.length ? 'No products match these filters' : 'No products yet',
944
- noRowsDescription: search || filterClauses.length ? undefined : 'Build your catalog: add a product, then drop commerce ' + 'blocks on any screen in Besigner.'
887
+ // The one order the list offers, by name: the query's.
888
+ sortingMode: "server",
889
+ disableColumnSorting: true,
890
+ initialState: {
891
+ columns: {
892
+ columnVisibilityModel: hiddenFilterVisibility(PRODUCT_LIST_FIELDS, PRODUCT_VISIBLE_COLUMNS)
893
+ }
894
+ },
895
+ noRowsLabel: filtering ? 'No products match these filters' : 'No products yet',
896
+ noRowsDescription: filtering ? undefined : 'Build your catalog: add a product, then drop commerce ' + 'blocks on any screen in Besigner.'
945
897
  }),
946
- products.length === 0 ? null : /*#__PURE__*/ _jsx(ListPagination, {
898
+ products.length === 0 && page === 0 && productsStatus !== 'loading' ? null : /* The count line is the PAGE's. `hasMore` is a fact from the probe
899
+ row; the site's total is `productCount` above, which counts the
900
+ whole catalog rather than the matches, so it is not this. */ /*#__PURE__*/ _jsx(ListPagination, {
947
901
  page: page,
948
902
  pageSize: pageSize,
949
- rowCount: visibleProducts.length,
950
- // The rows matching the current filters, which the card holds in
951
- // full below the ceiling. NOT the catalog total — that is
952
- // `productCount`, and it answers a different question.
953
- count: products.length,
903
+ rowCount: products.length,
904
+ hasMore: hasMore,
954
905
  onPageChange: setPage,
955
906
  onPageSizeChange: setPageSize
956
- }),
957
- catalogTruncated ? /*#__PURE__*/ _jsx(Alert, {
958
- severity: "info",
959
- children: `This table holds ${CATALOG_CEILING} products at a time and this ` + 'catalog is larger. Search, and Status on its own, reach every ' + 'product; the CSV export covers what the table holds.'
960
- }) : null
907
+ })
961
908
  ]
962
909
  }),
963
910
  /*#__PURE__*/ _jsxs(Dialog, {