@aglyn/plugins-commerce 1.0.0-beta.198 → 1.0.0-beta.200

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 (97) 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/pos-page.component.d.ts +14 -0
  11. package/src/lib/components/console/pos-page.component.js +86 -45
  12. package/src/lib/components/console/pos-page.component.js.map +1 -1
  13. package/src/lib/components/console/product-editor-dialog.component.js +30 -6
  14. package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
  15. package/src/lib/components/console/product-slugs.d.ts +43 -0
  16. package/src/lib/components/console/product-slugs.js +60 -0
  17. package/src/lib/components/console/product-slugs.js.map +1 -0
  18. package/src/lib/components/console/products-hub-card.component.d.ts +2 -2
  19. package/src/lib/components/console/products-hub-card.component.js +229 -282
  20. package/src/lib/components/console/products-hub-card.component.js.map +1 -1
  21. package/src/lib/components/console/products-hub-zone.component.d.ts +10 -5
  22. package/src/lib/components/console/products-hub-zone.component.js +32 -9
  23. package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
  24. package/src/lib/components/console/recovery-queue-card.component.d.ts +37 -5
  25. package/src/lib/components/console/recovery-queue-card.component.js +107 -86
  26. package/src/lib/components/console/recovery-queue-card.component.js.map +1 -1
  27. package/src/lib/components/console/reservations-card.component.js +15 -3
  28. package/src/lib/components/console/reservations-card.component.js.map +1 -1
  29. package/src/lib/components/console/smart-collections.d.ts +32 -0
  30. package/src/lib/components/console/smart-collections.js +57 -0
  31. package/src/lib/components/console/smart-collections.js.map +1 -0
  32. package/src/lib/components/console/stock-movements-card.component.d.ts +13 -4
  33. package/src/lib/components/console/stock-movements-card.component.js +142 -180
  34. package/src/lib/components/console/stock-movements-card.component.js.map +1 -1
  35. package/src/lib/components/product-grid.d.ts +5 -0
  36. package/src/lib/components/product-grid.js +95 -34
  37. package/src/lib/components/product-grid.js.map +1 -1
  38. package/src/lib/constants/orders-list-query.d.ts +40 -0
  39. package/src/lib/constants/orders-list-query.js +205 -0
  40. package/src/lib/constants/orders-list-query.js.map +1 -0
  41. package/src/lib/constants/product-list-query.d.ts +42 -0
  42. package/src/lib/constants/product-list-query.js +217 -0
  43. package/src/lib/constants/product-list-query.js.map +1 -0
  44. package/src/lib/constants/stock-movement-query.d.ts +27 -0
  45. package/src/lib/constants/stock-movement-query.js +110 -0
  46. package/src/lib/constants/stock-movement-query.js.map +1 -0
  47. package/src/lib/constants/storefront-catalog-query.d.ts +103 -0
  48. package/src/lib/constants/storefront-catalog-query.js +312 -0
  49. package/src/lib/constants/storefront-catalog-query.js.map +1 -0
  50. package/src/lib/model/checkout-recovery.d.ts +37 -0
  51. package/src/lib/model/checkout-recovery.js +44 -0
  52. package/src/lib/model/checkout-recovery.js.map +1 -0
  53. package/src/lib/model/commerce.d.ts +80 -0
  54. package/src/lib/model/commerce.js +67 -6
  55. package/src/lib/model/commerce.js.map +1 -1
  56. package/src/lib/model/gift-card-search.d.ts +24 -0
  57. package/src/lib/model/gift-card-search.js +48 -0
  58. package/src/lib/model/gift-card-search.js.map +1 -0
  59. package/src/lib/model/index.d.ts +1 -0
  60. package/src/lib/model/index.js +1 -0
  61. package/src/lib/model/index.js.map +1 -1
  62. package/src/lib/model/order-list-fields.d.ts +80 -0
  63. package/src/lib/model/order-list-fields.js +112 -0
  64. package/src/lib/model/order-list-fields.js.map +1 -0
  65. package/src/lib/server/billing-webhook.js +108 -89
  66. package/src/lib/server/billing-webhook.js.map +1 -1
  67. package/src/lib/server/cancel-order.js +6 -6
  68. package/src/lib/server/cancel-order.js.map +1 -1
  69. package/src/lib/server/cart-checkout.js +6 -0
  70. package/src/lib/server/cart-checkout.js.map +1 -1
  71. package/src/lib/server/catalog.d.ts +46 -30
  72. package/src/lib/server/catalog.js +492 -170
  73. package/src/lib/server/catalog.js.map +1 -1
  74. package/src/lib/server/collection-membership.d.ts +35 -0
  75. package/src/lib/server/collection-membership.js +172 -0
  76. package/src/lib/server/collection-membership.js.map +1 -0
  77. package/src/lib/server/draft-order.js +3 -2
  78. package/src/lib/server/draft-order.js.map +1 -1
  79. package/src/lib/server/gift-cards.js +3 -0
  80. package/src/lib/server/gift-cards.js.map +1 -1
  81. package/src/lib/server/member-name-search.d.ts +17 -0
  82. package/src/lib/server/member-name-search.js +23 -1
  83. package/src/lib/server/member-name-search.js.map +1 -1
  84. package/src/lib/server/membership-account.js +7 -3
  85. package/src/lib/server/membership-account.js.map +1 -1
  86. package/src/lib/server/membership-register.js +3 -1
  87. package/src/lib/server/membership-register.js.map +1 -1
  88. package/src/lib/server/pos-order.js +6 -4
  89. package/src/lib/server/pos-order.js.map +1 -1
  90. package/src/lib/server/process-abandoned.js +14 -5
  91. package/src/lib/server/process-abandoned.js.map +1 -1
  92. package/src/lib/server/reserve-stock.js +3 -4
  93. package/src/lib/server/reserve-stock.js.map +1 -1
  94. package/src/lib/server/site-page-enricher.js +3 -3
  95. package/src/lib/server/site-page-enricher.js.map +1 -1
  96. package/src/lib/server.js +4 -0
  97. package/src/lib/server.js.map +1 -1
@@ -19,133 +19,85 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
19
19
  import * as CommerceModel from "../../model/index.js";
20
20
  import { CardDisplay } from "@aglyn/shared-ui-jsx";
21
21
  import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
22
+ import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
23
+ import { ListQueryNotices, listQueryRefusals } from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
22
24
  import { ListTable } from "@aglyn/shared-ui-jsx/components/list-table.component";
23
25
  import { hiddenFilterVisibility } from "@aglyn/shared-ui-jsx/const/list-filter";
24
- import { inMemoryListField, upsertListFilterClause } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
25
- import { useListRowsFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-rows-filter";
26
+ import { listFilterGridColumns, upsertListFilterClause } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
27
+ import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
26
28
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
27
29
  import { checkEntitlement, ORDERS_CUSTOMER_PARAM, ORDERS_ORDER_PARAM, pluginDocsHelp } from "@aglyn/aglyn";
28
30
  import { Alert, AlertTitle, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField, Tooltip, Typography } from "@mui/material";
29
- import { collection, doc, getDoc, limit, orderBy, query } from "firebase/firestore";
31
+ import { collection, doc, getDoc, getDocs, limit, orderBy, query, startAfter, where } from "firebase/firestore";
30
32
  import { useSearchParams } from "next/navigation";
31
33
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
32
34
  import { ceilingedWindow, collectionCeiling, useFirestore, useOrgPlan, useUser } from "@aglyn/tenant-feature-instance";
33
35
  import { useFirestoreCollection } from "@aglyn/tenant-feature-instance";
36
+ import { listQueryConstraints, useListQuery } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
34
37
  import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
38
+ import { OPEN_DISPUTE_CLAUSE, ORDER_CHANNEL_OPTIONS, ORDER_DISPUTE_OPTIONS, ORDER_LIST_FIELDS, ORDER_LIST_HEADERS, ORDER_LIST_QUERY, ORDER_LIST_SELECT_FIELDS, ORDER_STATUS_OPTIONS, ordersCustomerClause } from "../../constants/orders-list-query.js";
35
39
  import CommerceStatTile from "./commerce-stat-tile.component.js";
36
40
  import OrderDetailDialog, { DISPUTE_COLOR } from "./order-detail-dialog.component.js";
37
41
  /**
38
- * How far back the table, its filters and its export reach.
39
- *
40
- * This window cannot be paged the way a plain list can. The Filters panel,
41
- * the search and the CSV export all run over what was read, so a page of ten
42
- * would quietly narrow every one of them — a status filter would search a
43
- * tenth of the orders and report confidently on what it found. Bounding the read and saying where the
44
- * bound falls keeps the filters honest about their own scope.
45
- */ const ORDERS_WINDOW = 200;
46
- /** A name map for the filter menu; it renders no product rows of its own. */ const PRODUCT_NAME_WINDOW = 100;
47
- /*
48
- * What the orders grid's Filters panel offers. The card holds its whole
49
- * window, so the panel and the search answer over every order it read.
50
- *
51
- * Status and Channel are the order's own enumerations; Disputes is apart
52
- * from Status on purpose, because the two are orthogonal: an OPEN dispute
53
- * sits on an order that is still `paid`, and a lost one on `refunded`
54
- * beside every ordinary refund. Product is the ids of every line item, so
55
- * a cart, POS or draft order is found by any product it holds. The date is
56
- * the field the query orders by, which every order writer stamps.
57
- */ const ORDER_FILTER_FIELDS = [
58
- inMemoryListField('orderLabel', 'text'),
59
- inMemoryListField('customerEmail', 'text'),
60
- inMemoryListField('channelKey', 'select'),
61
- inMemoryListField('statusKey', 'select'),
62
- inMemoryListField('createdAtMs', 'date'),
63
- _extends({}, inMemoryListField('productIds', 'select'), {
64
- tokensPath: 'productIds',
65
- verbatimTokens: true
66
- }),
67
- inMemoryListField('disputeKey', 'select')
68
- ];
69
- const ORDER_FILTER_HEADERS = {
70
- orderLabel: 'Order',
71
- customerEmail: 'Customer',
72
- channelKey: 'Channel',
73
- statusKey: 'Status',
74
- createdAtMs: 'Date',
75
- productIds: 'Product',
76
- disputeKey: 'Disputes'
77
- };
78
- const ORDER_STATUS_OPTIONS = Object.keys(CommerceModel.ORDER_STATUS_LABELS).map((status)=>({
79
- value: status,
80
- label: CommerceModel.ORDER_STATUS_LABELS[status]
81
- }));
82
- const ORDER_CHANNEL_OPTIONS = Object.keys(CommerceModel.ORDER_CHANNEL_LABELS).map((channel)=>({
83
- value: channel,
84
- label: CommerceModel.ORDER_CHANNEL_LABELS[channel]
85
- }));
86
- /*
87
- * `lost` is the badge's tone, not the raw dispute status: a dispute that was
88
- * WON also closes with money untouched, and listing it as charged back would
89
- * tell a merchant they lost a case they won.
90
- */ const ORDER_DISPUTE_OPTIONS = [
91
- {
92
- value: 'open',
93
- label: 'Open dispute'
94
- },
95
- {
96
- value: 'lost',
97
- label: 'Charged back'
98
- }
99
- ];
100
- /** What the quick search reads on an order row. */ const ORDER_SEARCH_FIELDS = [
101
- 'orderLabel',
102
- 'customerEmail',
103
- 'itemName'
104
- ];
105
- /** The filter-only columns never show. */ const ORDER_HIDDEN_COLUMNS = hiddenFilterVisibility(ORDER_FILTER_FIELDS, [
42
+ * The products the card reads to NAME a legacy row and the CSV, to offer the
43
+ * Product filter's choices and to fill the draft dialog's picker. A picker's
44
+ * reach, not the list's: the orders themselves are paged by their query and
45
+ * the Product filter asks it for any product id, named here or not.
46
+ */ const PRODUCT_NAME_WINDOW = 100;
47
+ /**
48
+ * How many open disputes the banner reads. Its own query, `disputeKey ==
49
+ * 'open'`, so a deadline is raised whatever the list is filtered to and
50
+ * however deep in the store the order sits; a store past this many open
51
+ * cases is told "more than".
52
+ */ const OPEN_DISPUTE_CEILING = 50;
53
+ /**
54
+ * The money tiles read the last sixty days (thirty, and the thirty before it)
55
+ * on their own bounded query. They are analytics over a period, not a filter
56
+ * over the list, and share the analytics card's bound.
57
+ */ const STATS_WINDOW_MS = 60 * 24 * 60 * 60 * 1000;
58
+ const STATS_ORDER_CEILING = 500;
59
+ /**
60
+ * Export CSV walks the list's own query, page by page, up to this many
61
+ * orders, and says so when the matches run past it.
62
+ */ const ORDERS_EXPORT_CEILING = 5000;
63
+ const ORDERS_EXPORT_PAGE = 500;
64
+ /** The columns the grid draws; every other declared field is a hidden filter column. */ const ORDER_VISIBLE_COLUMNS = [
106
65
  'orderLabel',
107
66
  'customerEmail',
108
67
  'channelKey',
109
68
  'statusKey',
110
69
  'createdAtMs'
111
- ]);
112
- /** The clause the dispute banner's "Show them" sets. */ const OPEN_DISPUTE_CLAUSE = {
113
- field: 'disputeKey',
114
- op: 'equals',
115
- value: 'open'
116
- };
70
+ ];
71
+ const ORDER_HIDDEN_COLUMNS = hiddenFilterVisibility(ORDER_LIST_FIELDS, ORDER_VISIBLE_COLUMNS);
117
72
  /**
118
73
  * Orders console (AGL-287): filterable list over webhook-written order
119
74
  * docs with a detail dialog (timeline, fulfill, refund, cancel, notes,
120
75
  * packing slip) and draft orders that send the buyer a payment link.
76
+ *
77
+ * ## Every filter and the search are on the query (AGL-3321)
78
+ *
79
+ * The list used to read the newest two hundred orders and match its Filters
80
+ * panel and search over them, so a Status filter on a busy store answered
81
+ * "none" about every order past the two hundredth. Now each clause and the
82
+ * search word are predicates on ONE Firestore query (`ORDER_LIST_QUERY`),
83
+ * over the fields every writer stamps (`orderListFields`), and the footer
84
+ * pages that query's answer. A combination one query cannot hold is refused
85
+ * by name above the grid and never applied to some rows only.
121
86
  */ export function HostOrdersCard(props) {
122
- var _ref, _find, _ref1, _ref2, _ref3, _ref4;
87
+ var _ref, _loadedOrders_find, _ref1, _ref2, _ref3, _ref4;
123
88
  var _draft_shipCountries, _draft_shipCountries1;
124
89
  const { hostId } = props;
125
90
  const firestore = useFirestore();
126
- /**
127
- * The most recent orders, in the order the table shows them.
128
- *
129
- * `createdAtMs` is the field every order writer in the plugin stamps and the
130
- * one `reconcile-stock` already walks the collection by. Ordering on it is
131
- * what makes this window the RECENT orders: an unordered cap is answered in
132
- * document-id order, and orders are keyed by generated ids, so it returns a
133
- * pseudo-random sample that a client sort then dresses as newest-first.
134
- */ const { data: orderDocs } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'orders'), orderBy('createdAtMs', 'desc'), limit(ORDERS_WINDOW + 1)), [
91
+ const ordersRef = useMemo(()=>collection(firestore, 'hosts', hostId, 'orders'), [
135
92
  firestore,
136
93
  hostId
137
- ], {
138
- idField: '$id'
139
- });
94
+ ]);
140
95
  const { data: productDocs } = useFirestoreCollection(()=>collectionCeiling(collection(firestore, 'hosts', hostId, 'products'), PRODUCT_NAME_WINDOW), [
141
96
  firestore,
142
97
  hostId
143
98
  ], {
144
99
  idField: '$id'
145
100
  });
146
- const orderWindow = useMemo(()=>ceilingedWindow(orderDocs != null ? orderDocs : undefined, ORDERS_WINDOW), [
147
- orderDocs
148
- ]);
149
101
  const productWindow = useMemo(()=>ceilingedWindow(productDocs != null ? productDocs : undefined, PRODUCT_NAME_WINDOW), [
150
102
  productDocs
151
103
  ]);
@@ -168,49 +120,90 @@ const ORDER_CHANNEL_OPTIONS = Object.keys(CommerceModel.ORDER_CHANNEL_LABELS).ma
168
120
  * a paid feature.
169
121
  */ const { org, ready: orgReady } = useOrgPlan(hostId);
170
122
  const showStats = orgReady && checkEntitlement(org, 'commerceAnalytics');
171
- // The query returns the window already newest-first; re-sorting it here
172
- // would only restate the order it arrived in.
173
- const orders = orderWindow.rows;
174
123
  /*
175
124
  * The grid's clauses (AGL-96, AGL-3317), held here so two things outside
176
125
  * the grid can set one: the CRM's contact page links with the buyer's
177
- * address to answer "what has this customer ordered" (AGL-2622), seeded as
178
- * a Customer clause matched as a substring so a domain finds every buyer at
179
- * a company; and the dispute banner's "Show them".
126
+ * address to answer "what has this customer ordered" (AGL-2622) — a whole
127
+ * address is that buyer, a domain every buyer at a company
128
+ * (`ordersCustomerClause`) — and the dispute banner's "Show them".
180
129
  */ const searchParams = useSearchParams();
181
130
  const [clauses, setClauses] = useState(()=>{
182
- var _searchParams_get;
183
- const customer = searchParams == null ? void 0 : (_searchParams_get = searchParams.get(ORDERS_CUSTOMER_PARAM)) == null ? void 0 : _searchParams_get.trim();
131
+ var _ref;
132
+ const customer = ordersCustomerClause((_ref = searchParams == null ? void 0 : searchParams.get(ORDERS_CUSTOMER_PARAM)) != null ? _ref : '');
184
133
  return customer ? [
185
- {
186
- field: 'customerEmail',
187
- op: 'contains',
188
- value: customer
189
- }
134
+ customer
190
135
  ] : [];
191
136
  });
137
+ const gridFilter = useListGridFilter({
138
+ clauses,
139
+ onChange: setClauses,
140
+ selectFields: ORDER_LIST_SELECT_FIELDS
141
+ });
142
+ const filtering = clauses.length > 0 || gridFilter.searchWords.some((word)=>word.trim());
143
+ /*
144
+ * The open disputes, on their OWN query (AGL-1796). Raised whatever the
145
+ * list is filtered to — a merchant filtered to "delivered" still has a
146
+ * deadline running on a `paid` order, and the evidence window is days —
147
+ * and however far back the order is. Asked before the list so the list's
148
+ * plan is the one a spec reads last.
149
+ */ const { rows: disputedRows, hasMore: moreDisputes } = useListQuery({
150
+ collection: ordersRef,
151
+ declaration: ORDER_LIST_QUERY,
152
+ request: {
153
+ clauses: [
154
+ OPEN_DISPUTE_CLAUSE
155
+ ]
156
+ },
157
+ deps: [
158
+ firestore,
159
+ hostId
160
+ ],
161
+ idField: '$id',
162
+ pageSize: OPEN_DISPUTE_CEILING,
163
+ // A dispute that closes leaves this query; confirm it before believing it.
164
+ confirmDisappearances: true
165
+ });
166
+ /*
167
+ * THE LIST: every clause and the search word on one query, newest first,
168
+ * paged by the footer. A status, a dispute or a paid draft's address can
169
+ * move an order out of the filtered query mid-session, so a disappearance
170
+ * is confirmed against the server before it is believed (AGL-1196).
171
+ */ const { rows: orders, data: loadedOrders, hasMore, page, setPage, pageSize, setPageSize, plan, status: listStatus } = useListQuery({
172
+ collection: ordersRef,
173
+ declaration: ORDER_LIST_QUERY,
174
+ request: {
175
+ clauses,
176
+ search: gridFilter.searchWords
177
+ },
178
+ deps: [
179
+ firestore,
180
+ hostId
181
+ ],
182
+ idField: '$id',
183
+ confirmDisappearances: true
184
+ });
192
185
  const [selectedId, setSelectedId] = useState(null);
193
186
  /*
194
187
  * An order named in the URL opens in its dialog on arrival (AGL-2622). A
195
188
  * contact's timeline names the order that made the person a customer,
196
- * and the address it links to is this list with `?order={id}`. The
197
- * window is the newest two hundred, and an order from last year is not
198
- * in it, so the document is read once by id and held beside the window
199
- * for the dialog; an order that IS in the window is found there first
200
- * and the read is skipped. Once per id — the ref keeps a re-render from
201
- * reopening a dialog the merchant has since closed — and the landing read
202
- * is judged against the ref rather than an effect cleanup, because the
203
- * `setSelectedId` above re-renders before the read lands and a cleanup
204
- * keyed on any dep would cancel the answer to the question just asked.
189
+ * and the address it links to is this list with `?order={id}`. The list
190
+ * shows one page, and an order from last year is not on it, so the
191
+ * document is read once by id and held beside the page for the dialog; an
192
+ * order that IS loaded is found there first and the read is skipped. Once
193
+ * per id — the ref keeps a re-render from reopening a dialog the merchant
194
+ * has since closed — and the landing read is judged against the ref
195
+ * rather than an effect cleanup, because the `setSelectedId` above
196
+ * re-renders before the read lands and a cleanup keyed on any dep would
197
+ * cancel the answer to the question just asked.
205
198
  */ const seededOrderId = (_ref = searchParams == null ? void 0 : searchParams.get(ORDERS_ORDER_PARAM)) != null ? _ref : null;
206
199
  const [seededOrder, setSeededOrder] = useState(null);
207
200
  const seededOpened = useRef(null);
208
201
  useEffect(()=>{
209
202
  if (!seededOrderId || seededOpened.current === seededOrderId) return;
210
- if (orderDocs === undefined) return;
203
+ if (listStatus === 'loading') return;
211
204
  seededOpened.current = seededOrderId;
212
205
  setSelectedId(seededOrderId);
213
- if (orderDocs.some((order)=>order.$id === seededOrderId)) return;
206
+ if (loadedOrders.some((order)=>order.$id === seededOrderId)) return;
214
207
  void getDoc(doc(firestore, 'hosts', hostId, 'orders', seededOrderId)).then((snapshot)=>{
215
208
  if (seededOpened.current !== snapshot.id || !snapshot.exists()) return;
216
209
  setSeededOrder(_extends({}, snapshot.data(), {
@@ -219,7 +212,8 @@ const ORDER_CHANNEL_OPTIONS = Object.keys(CommerceModel.ORDER_CHANNEL_LABELS).ma
219
212
  }).catch(()=>undefined);
220
213
  }, [
221
214
  seededOrderId,
222
- orderDocs,
215
+ loadedOrders,
216
+ listStatus,
223
217
  firestore,
224
218
  hostId
225
219
  ]);
@@ -227,30 +221,22 @@ const ORDER_CHANNEL_OPTIONS = Object.keys(CommerceModel.ORDER_CHANNEL_LABELS).ma
227
221
  const { data: user } = useUser();
228
222
  const { enqueueSnackbar } = useSnackbar();
229
223
  /**
230
- * Each order with the values the grid shows and filters by. `liftLegacyOrder`
231
- * names a status and channel on a row that predates them, and Product
224
+ * Each order on the page with the values the grid DRAWS. What it filters
225
+ * by is on the query; this is presentation only. `liftLegacyOrder` names
226
+ * a status and channel on a row that predates them, and the item name
232
227
  * reads the line items first (AGL-1747): the flat `productId` is written
233
228
  * only by the two buy-now Stripe paths.
234
229
  */ const orderRows = useMemo(()=>orders.map((order)=>{
235
- var _lifted_lineItems, _ref, _ref1, _ref2, _lifted_channel;
236
- var _lifted_lineItems_, _lifted_lineItems1;
230
+ var _ref, _ref1, _ref2, _lifted_channel;
231
+ var _lifted_lineItems_, _lifted_lineItems;
237
232
  const lifted = CommerceModel.liftLegacyOrder(order);
238
- const dispute = CommerceModel.describeOrderDispute(lifted);
239
- const productIds = [
240
- ...new Set([
241
- ...((_lifted_lineItems = lifted.lineItems) != null ? _lifted_lineItems : []).map((line)=>line.productId),
242
- order.productId
243
- ].filter((id)=>typeof id === 'string' && id !== ''))
244
- ];
245
233
  return _extends({}, order, {
246
234
  orderLabel: CommerceModel.formatOrderNumber(lifted, order.$id),
247
- itemName: (_ref = (_ref1 = (_ref2 = (_lifted_lineItems1 = lifted.lineItems) == null ? void 0 : (_lifted_lineItems_ = _lifted_lineItems1[0]) == null ? void 0 : _lifted_lineItems_.name) != null ? _ref2 : productNames[order.productId]) != null ? _ref1 : order.productId) != null ? _ref : '',
235
+ itemName: (_ref = (_ref1 = (_ref2 = (_lifted_lineItems = lifted.lineItems) == null ? void 0 : (_lifted_lineItems_ = _lifted_lineItems[0]) == null ? void 0 : _lifted_lineItems_.name) != null ? _ref2 : productNames[order.productId]) != null ? _ref1 : order.productId) != null ? _ref : '',
248
236
  statusKey: lifted.status,
249
237
  channelKey: (_lifted_channel = lifted.channel) != null ? _lifted_channel : 'online',
250
238
  netCents: CommerceModel.orderNetCents(lifted),
251
- disputeBadge: dispute,
252
- productIds,
253
- disputeKey: CommerceModel.orderHasOpenDispute(lifted) ? 'open' : (dispute == null ? void 0 : dispute.tone) === 'lost' ? 'lost' : ''
239
+ disputeBadge: CommerceModel.describeOrderDispute(lifted)
254
240
  });
255
241
  }), [
256
242
  orders,
@@ -273,52 +259,101 @@ const ORDER_CHANNEL_OPTIONS = Object.keys(CommerceModel.ORDER_CHANNEL_LABELS).ma
273
259
  }), [
274
260
  productOptions
275
261
  ]);
276
- const listFilter = useListRowsFilter({
277
- rows: orderRows,
278
- fields: ORDER_FILTER_FIELDS,
279
- options: filterOptions,
280
- headers: ORDER_FILTER_HEADERS,
281
- search: ORDER_SEARCH_FIELDS,
282
- clauses,
283
- onChange: setClauses
284
- });
285
- const visibleOrders = listFilter.rows;
262
+ const refused = useMemo(()=>listQueryRefusals(plan.refused, {
263
+ fields: ORDER_LIST_FIELDS,
264
+ headers: ORDER_LIST_HEADERS,
265
+ options: filterOptions
266
+ }), [
267
+ plan,
268
+ filterOptions
269
+ ]);
286
270
  const showingOpenDisputes = clauses.some((clause)=>clause.field === OPEN_DISPUTE_CLAUSE.field && clause.op === OPEN_DISPUTE_CLAUSE.op && clause.value === OPEN_DISPUTE_CLAUSE.value);
287
- /**
288
- * Raised over EVERY loaded order, not the visible ones (AGL-1796). A
289
- * merchant filtered to "delivered" still has a deadline running on a `paid`
290
- * order, and the evidence window is days long.
291
- */ const openDisputes = useMemo(()=>CommerceModel.summariseOpenDisputes(orders.map((order)=>CommerceModel.liftLegacyOrder(order))), [
292
- orders
271
+ const openDisputes = useMemo(()=>CommerceModel.summariseOpenDisputes(disputedRows.map((order)=>CommerceModel.liftLegacyOrder(order))), [
272
+ disputedRows
293
273
  ]);
294
- /**
295
- * 30-day money summary with the prior 30 days behind it (AGL-2136). Over
296
- * the LOADED window, like every other figure on this card — the query is
297
- * `limit(200)`, so a store past 200 orders in 60 days is summarising a
298
- * slice. That is the same bound the analytics card has always had, and
299
- * making this one silently different would be worse than sharing it.
300
- */ const summary = useMemo(()=>CommerceModel.summarizeOrderWindow(orders, {
274
+ /*
275
+ * 30-day money summary with the prior 30 days behind it (AGL-2136).
276
+ * ANALYTICS, NOT A FILTER: the tiles describe a period, not the list, so
277
+ * they keep their own bounded read — sixty days by `createdAtMs`, the
278
+ * newest `STATS_ORDER_CEILING` of them, read only when the tiles show.
279
+ * Range and order are the same field, so it needs no composite.
280
+ */ const statsSince = useMemo(()=>Date.now() - STATS_WINDOW_MS, []);
281
+ const { data: statsDocs } = useFirestoreCollection(()=>showStats ? query(ordersRef, where('createdAtMs', '>=', statsSince), orderBy('createdAtMs', 'desc'), limit(STATS_ORDER_CEILING + 1)) : null, [
282
+ firestore,
283
+ hostId,
284
+ showStats,
285
+ statsSince
286
+ ], {
287
+ idField: '$id'
288
+ });
289
+ const statsWindow = useMemo(()=>ceilingedWindow(statsDocs != null ? statsDocs : undefined, STATS_ORDER_CEILING), [
290
+ statsDocs
291
+ ]);
292
+ const summary = useMemo(()=>CommerceModel.summarizeOrderWindow(statsWindow.rows, {
301
293
  nowMs: Date.now()
302
294
  }), [
303
- orders
295
+ statsWindow
304
296
  ]);
305
- const handleExportCsv = useCallback(()=>{
306
- // The rows themselves are built by the pure model helper (AGL-1747) so the
307
- // column-by-column arithmetic is unit-testable without a Firestore mock.
308
- const csv = CommerceModel.buildOrdersCsv(visibleOrders, productNames);
309
- const url = URL.createObjectURL(new Blob([
310
- csv
311
- ], {
312
- type: 'text/csv'
313
- }));
314
- const anchor = document.createElement('a');
315
- anchor.href = url;
316
- anchor.download = 'orders.csv';
317
- anchor.click();
318
- URL.revokeObjectURL(url);
297
+ /**
298
+ * Export CSV writes what the QUERY matches — every clause and the search
299
+ * the grid is showing — not the page on screen. It walks the list's own
300
+ * plan in pages of `ORDERS_EXPORT_PAGE` up to `ORDERS_EXPORT_CEILING`, and
301
+ * says so when the matches run past it.
302
+ */ const [exporting, setExporting] = useState(false);
303
+ const handleExportCsv = useCallback(async ()=>{
304
+ setExporting(true);
305
+ try {
306
+ const matched = [];
307
+ let cursor = null;
308
+ let more = true;
309
+ while(more && matched.length < ORDERS_EXPORT_CEILING){
310
+ var _docs_;
311
+ const take = Math.min(ORDERS_EXPORT_PAGE, ORDERS_EXPORT_CEILING - matched.length);
312
+ // One past the page: whether more match is a fact, not a guess.
313
+ const snapshot = await getDocs(query(ordersRef, ...listQueryConstraints(plan), ...cursor ? [
314
+ startAfter(cursor)
315
+ ] : [], limit(take + 1)));
316
+ const docs = snapshot.docs.slice(0, take);
317
+ for (const entry of docs)matched.push(_extends({}, entry.data(), {
318
+ $id: entry.id
319
+ }));
320
+ more = snapshot.docs.length > take;
321
+ cursor = (_docs_ = docs[docs.length - 1]) != null ? _docs_ : null;
322
+ if (!cursor) more = false;
323
+ }
324
+ // The rows themselves are built by the pure model helper (AGL-1747) so
325
+ // the column-by-column arithmetic is unit-testable without Firestore.
326
+ const csv = CommerceModel.buildOrdersCsv(matched, productNames);
327
+ const url = URL.createObjectURL(new Blob([
328
+ csv
329
+ ], {
330
+ type: 'text/csv'
331
+ }));
332
+ const anchor = document.createElement('a');
333
+ anchor.href = url;
334
+ anchor.download = 'orders.csv';
335
+ anchor.click();
336
+ URL.revokeObjectURL(url);
337
+ if (more) {
338
+ enqueueSnackbar(`Exported the ${ORDERS_EXPORT_CEILING.toLocaleString('en-US')} newest matching orders. ` + 'Narrow the filters to export the rest.', {
339
+ variant: 'warning',
340
+ allowDuplicate: true
341
+ });
342
+ }
343
+ } catch (error) {
344
+ console.error('Orders export failed', error);
345
+ enqueueSnackbar('Export failed — try again', {
346
+ variant: 'error',
347
+ allowDuplicate: true
348
+ });
349
+ } finally{
350
+ setExporting(false);
351
+ }
319
352
  }, [
320
- visibleOrders,
321
- productNames
353
+ ordersRef,
354
+ plan,
355
+ productNames,
356
+ enqueueSnackbar
322
357
  ]);
323
358
  /**
324
359
  * Idempotency key for ONE draft attempt (AGL-1697).
@@ -409,7 +444,7 @@ const ORDER_CHANNEL_OPTIONS = Object.keys(CommerceModel.ORDER_CHANNEL_LABELS).ma
409
444
  hostId,
410
445
  enqueueSnackbar
411
446
  ]);
412
- const selectedOrder = (_find = (orderDocs != null ? orderDocs : []).find((order)=>order.$id === selectedId)) != null ? _find : seededOrder && seededOrder.$id === selectedId ? seededOrder : null;
447
+ const selectedOrder = (_loadedOrders_find = loadedOrders.find((order)=>order.$id === selectedId)) != null ? _loadedOrders_find : seededOrder && seededOrder.$id === selectedId ? seededOrder : null;
413
448
  /**
414
449
  * Shared by the two triggers (AGL-1805) so the empty state and the toolbar
415
450
  * cannot drift into opening the dialog with different starting fields.
@@ -584,6 +619,15 @@ const ORDER_CHANNEL_OPTIONS = Object.keys(CommerceModel.ORDER_CHANNEL_LABELS).ma
584
619
  }
585
620
  }
586
621
  ], []);
622
+ const columns = useMemo(()=>listFilterGridColumns(orderColumns, ORDER_LIST_FIELDS, filterOptions, ORDER_LIST_HEADERS), [
623
+ orderColumns,
624
+ filterOptions
625
+ ]);
626
+ /*
627
+ * The store is empty only when an UNFILTERED first page came back empty; a
628
+ * filter or search that matches nothing keeps the grid, its chips and its
629
+ * panel, so the reader can take the filter back off.
630
+ */ const empty = listStatus === 'success' && !filtering && page === 0 && orders.length === 0;
587
631
  return /*#__PURE__*/ _jsxs(CardDisplay, {
588
632
  header: 'Orders',
589
633
  help: pluginDocsHelp('commerce', {
@@ -593,7 +637,7 @@ const ORDER_CHANNEL_OPTIONS = Object.keys(CommerceModel.ORDER_CHANNEL_LABELS).ma
593
637
  contentGutterX: true,
594
638
  contentGutterY: true,
595
639
  children: [
596
- orders.length === 0 ? /*
640
+ empty ? /*
597
641
  * An invitation, not a report (AGL-1805). The "Draft order" button
598
642
  * used to live in the other arm of this ternary, so the one state a
599
643
  * draft order exists for — no sales yet, invoice the customer you
@@ -658,6 +702,11 @@ const ORDER_CHANNEL_OPTIONS = Object.keys(CommerceModel.ORDER_CHANNEL_LABELS).ma
658
702
  })
659
703
  ]
660
704
  }) : null,
705
+ showStats && statsWindow.truncated ? /*#__PURE__*/ _jsx(Typography, {
706
+ variant: "caption",
707
+ color: "text.secondary",
708
+ children: `Figures count the ${STATS_ORDER_CEILING} most recent orders of the last 60 days.`
709
+ }) : null,
661
710
  openDisputes.count > 0 ? /*#__PURE__*/ _jsxs(Alert, {
662
711
  severity: openDisputes.overdue ? 'error' : 'warning',
663
712
  action: showingOpenDisputes ? undefined : /*#__PURE__*/ _jsx(Button, {
@@ -668,7 +717,7 @@ const ORDER_CHANNEL_OPTIONS = Object.keys(CommerceModel.ORDER_CHANNEL_LABELS).ma
668
717
  }),
669
718
  children: [
670
719
  /*#__PURE__*/ _jsx(AlertTitle, {
671
- children: openDisputes.count === 1 ? 'A shopper has disputed a charge with their bank' : `${openDisputes.count} charges are disputed with the shopper’s bank`
720
+ children: moreDisputes ? `More than ${OPEN_DISPUTE_CEILING} charges are disputed with the shopper’s bank` : openDisputes.count === 1 ? 'A shopper has disputed a charge with their bank' : `${openDisputes.count} charges are disputed with the shopper’s bank`
672
721
  }),
673
722
  openDisputes.soonestDaysLeft === undefined ? 'Answer in the Stripe dashboard while the case is open — an unanswered dispute is decided for the shopper.' : openDisputes.soonestDaysLeft < 0 ? 'The evidence deadline has passed on at least one of these. Stripe decides an unanswered dispute for the shopper.' : `Evidence is due to Stripe in ${openDisputes.soonestDaysLeft} day${openDisputes.soonestDaysLeft === 1 ? '' : 's'} on the tightest of these. An unanswered dispute is decided for the shopper.`
674
723
  ]
@@ -683,7 +732,8 @@ const ORDER_CHANNEL_OPTIONS = Object.keys(CommerceModel.ORDER_CHANNEL_LABELS).ma
683
732
  children: [
684
733
  /*#__PURE__*/ _jsx(Button, {
685
734
  size: "small",
686
- onClick: handleExportCsv,
735
+ onClick: ()=>void handleExportCsv(),
736
+ disabled: exporting,
687
737
  children: 'Export CSV'
688
738
  }),
689
739
  /*#__PURE__*/ _jsx(Button, {
@@ -695,30 +745,50 @@ const ORDER_CHANNEL_OPTIONS = Object.keys(CommerceModel.ORDER_CHANNEL_LABELS).ma
695
745
  })
696
746
  ]
697
747
  }),
698
- /*#__PURE__*/ _jsx(ListFilterChips, _extends({}, listFilter.chipsProps)),
699
- /*#__PURE__*/ _jsx(ListTable, _extends({
748
+ /*#__PURE__*/ _jsx(ListFilterChips, {
749
+ fields: ORDER_LIST_FIELDS,
750
+ headers: ORDER_LIST_HEADERS,
751
+ clauses: clauses,
752
+ onChange: setClauses,
753
+ options: filterOptions
754
+ }),
755
+ /*#__PURE__*/ _jsx(ListQueryNotices, {
756
+ refused: refused,
757
+ notices: plan.notices
758
+ }),
759
+ /*#__PURE__*/ _jsx(ListTable, {
700
760
  "aria-label": "Orders",
701
- rows: visibleOrders,
702
- columns: listFilter.filterColumns(orderColumns),
703
- onOpen: (id)=>setSelectedId(id)
704
- }, listFilter.gridProps, {
761
+ rows: orderRows,
762
+ columns: columns,
763
+ onOpen: (id)=>setSelectedId(id),
764
+ loading: listStatus === 'loading',
765
+ /*
766
+ * The grid must NOT also filter, search or sort: the query
767
+ * answers all three, and the list has one order — newest first —
768
+ * which the query already holds.
769
+ */ filterMode: "server",
770
+ filterModel: gridFilter.filterModel,
771
+ onFilterModelChange: gridFilter.onFilterModelChange,
772
+ quickFilter: true,
773
+ sortingMode: "server",
774
+ disableColumnSorting: true,
775
+ // `ListPagination` below pages the query.
776
+ hideFooter: true,
705
777
  initialState: {
706
778
  columns: {
707
779
  columnVisibilityModel: ORDER_HIDDEN_COLUMNS
708
780
  }
709
781
  },
710
782
  noRowsLabel: "No orders match these filters"
711
- })),
712
- orderWindow.truncated ? /*
713
- * Where the filters, the search and the export stop. All run over
714
- * what was read, so a store past this window would otherwise see
715
- * a status filter return nothing and read it as having no such
716
- * orders.
717
- */ /*#__PURE__*/ _jsx(Typography, {
718
- variant: "caption",
719
- color: "text.secondary",
720
- children: `Showing the ${ORDERS_WINDOW} most recent orders. Filters and Export CSV cover these.`
721
- }) : null
783
+ }),
784
+ /*#__PURE__*/ _jsx(ListPagination, {
785
+ page: page,
786
+ pageSize: pageSize,
787
+ rowCount: orderRows.length,
788
+ hasMore: hasMore,
789
+ onPageChange: setPage,
790
+ onPageSizeChange: setPageSize
791
+ })
722
792
  ]
723
793
  }),
724
794
  selectedOrder ? /*#__PURE__*/ _jsx(OrderDetailDialog, {