@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
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/products-hub-zone.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport { useConsoleWidgetSlot } from '@aglyn/aglyn/app-utils/console-widget-slot-context'\nimport type {\n ConsoleProductCopyValues,\n ConsoleProductSummary,\n ConsoleProductsHubZoneProps,\n ConsoleProposedDiscount,\n ConsoleProposedProduct,\n} from '@aglyn/aglyn/plugin-manager/feature-plugins'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport { useFirestore, useHostResourceApi } from '@aglyn/tenant-feature-instance'\nimport {\n collection,\n doc,\n getDocs,\n limit,\n query,\n runTransaction,\n setDoc,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useMemo } from 'react'\nimport * as CommerceModel from '../../model'\n\n/**\n * The products hub's zone (AGL-2916): the hub's catalog rows, and the writes a\n * widget there may ask the hub to make. A widget proposes; these are the\n * hub's writes, each through the door the hub's own controls use.\n *\n * - **Copy onto a saved product** is a transaction over the product as the\n * store holds it now, never a row the table was seeded with: it changes\n * only the fields the copy names, and renames options through\n * `renameProductOptions`, so a sale or a colleague's edit since the table\n * loaded is kept.\n * - **A proposed product** is created through the quota-enforcing resources\n * API, as Add product and Import create one, after the hub's own allowance\n * check for the batch. It is a draft with no price (`unpricedProductDraft`).\n * - **A category** is created as the catalog card creates one.\n * - **A discount** is created switched off (`switchedOffDiscount`), for a\n * person to switch on from the discounts card.\n *\n * Each create passes over what the site already has — a product of the same\n * name, a category of the same name, a discount with the same code or, with no\n * code, the same name — so a proposal applied twice, by anyone, creates\n * nothing twice. Each check is a read made when a person applies, never while\n * the hub is open, and reaches past the hub's filtered, ceilinged rows: a\n * product is looked up by its search key.\n */\n\ntype ProductRow = CommerceModel.HostProduct & { $id: string }\n\n/** The hub's allowance check for `count` more products: `null` while the plan loads. */\nexport type ProductsRoomCheck = (count: number) => { allowed: boolean; limit: number } | null\n\nexport interface ProductsHubZoneProps {\n hostId: string\n /** The catalog rows the hub holds. */\n products: readonly ProductRow[]\n roomFor: ProductsRoomCheck\n lastImport: ConsoleProductsHubZoneProps['lastImport']\n /** Called once a create has moved the catalog's count. */\n onCreated: () => void\n}\n\nexport const PRODUCT_GONE_COPY = 'This product is no longer in the catalog.'\nexport const PLAN_LOADING_COPY = 'Your plan is still loading. Try again in a moment.'\n\n/** How many of the site's categories the duplicate check reads. */\nconst CATEGORY_NAMES_READ = 250\n\n/** How a name is compared for a duplicate: case and spacing aside. */\nconst nameKey = (name: string) => name.replace(/\\s+/g, ' ').trim().toLowerCase()\n\n/** The most values one Firestore `in` clause takes. */\nconst IN_CLAUSE_MAX = 10\n\nexport function ProductsHubZone(props: ProductsHubZoneProps) {\n const { hostId, products, roomFor, lastImport, onCreated } = props\n const Slot = useConsoleWidgetSlot()\n const firestore = useFirestore()\n const createHostResource = useHostResourceApi()\n\n const summaries = useMemo<ConsoleProductSummary[]>(\n () =>\n products.map((product) => ({\n id: product.$id,\n name: product.name,\n status: product.status,\n priceMissing: CommerceModel.productPriceMissing(product),\n hasPhoto: Boolean(product.mediaUrls?.[0] ?? product.imageUrl),\n })),\n [products],\n )\n\n const applyProductCopy = useCallback(\n async (productId: string, values: ConsoleProductCopyValues) => {\n const ref = doc(firestore, 'hosts', hostId, 'products', productId)\n await runTransaction(firestore, async (transaction) => {\n const snapshot = await transaction.get(ref)\n const stored = snapshot.exists() ? (snapshot.data() as CommerceModel.HostProduct) : null\n if (!stored || stored.deletedAt) throw new Error(PRODUCT_GONE_COPY)\n const patch = CommerceModel.productCopyPatch(CommerceModel.liftLegacyProduct(stored), values)\n if (!Object.keys(patch).length) return\n transaction.update(ref, { ...patch, updatedAtMs: Date.now(), updatedAt: Timestamp.now() })\n })\n },\n [firestore, hostId],\n )\n\n const createProductDrafts = useCallback(\n async (proposals: readonly ConsoleProposedProduct[]) => {\n const held = new Set(products.map((product) => nameKey(product.name)))\n const searchKeys = [\n ...new Set(proposals.map((proposal) => CommerceModel.productSearchFields({ name: proposal.name.trim() }).nameLower)),\n ].filter(Boolean)\n const productsRef = collection(firestore, 'hosts', hostId, 'products')\n const stored = await Promise.all(\n Array.from({ length: Math.ceil(searchKeys.length / IN_CLAUSE_MAX) }, (_, index) =>\n getDocs(query(productsRef, where('nameLower', 'in', searchKeys.slice(index * IN_CLAUSE_MAX, (index + 1) * IN_CLAUSE_MAX)))),\n ),\n )\n for (const read of stored) {\n for (const existing of read.docs) {\n if (!existing.get('deletedAt')) held.add(nameKey(String(existing.get('name') ?? '')))\n }\n }\n const fresh = proposals.filter((proposal) => {\n const key = nameKey(proposal.name)\n if (!key || held.has(key)) return false\n held.add(key)\n return true\n })\n if (!fresh.length) return []\n const room = roomFor(fresh.length)\n if (!room) throw new Error(PLAN_LOADING_COPY)\n if (!room.allowed) {\n throw new Error(\n `These products need ${fresh.length} product slots — your plan allows ` +\n `${room.limit}. See Billing to upgrade.`,\n )\n }\n const taken = new Set(products.map((product) => product.slug))\n const created: string[] = []\n try {\n for (const proposal of fresh) {\n const draft = CommerceModel.unpricedProductDraft(proposal, taken, Date.now())\n taken.add(draft.slug)\n const { id } = await createHostResource({ hostId, resource: 'product', data: draft })\n created.push(id)\n }\n } finally {\n if (created.length) onCreated()\n }\n return created\n },\n [products, roomFor, createHostResource, firestore, hostId, onCreated],\n )\n\n const createCategories = useCallback(\n async (names: readonly string[]) => {\n const existing = await getDocs(\n query(collection(firestore, 'hosts', hostId, 'productCategories'), limit(CATEGORY_NAMES_READ)),\n )\n const held = new Set(existing.docs.map((category) => nameKey(String(category.get('name') ?? ''))))\n let created = 0\n for (const raw of names) {\n const name = raw.trim().slice(0, 80)\n if (!name || held.has(nameKey(name))) continue\n held.add(nameKey(name))\n await setDoc(doc(firestore, 'hosts', hostId, 'productCategories', createResourceUid()), {\n name,\n slug: CommerceModel.commerceSlug(name),\n parentId: null,\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n })\n created += 1\n }\n return created\n },\n [firestore, hostId],\n )\n\n const createDiscountDrafts = useCallback(\n async (discounts: readonly ConsoleProposedDiscount[]) => {\n const drafts = discounts\n .map((proposal) => CommerceModel.switchedOffDiscount(proposal))\n .filter((discount): discount is CommerceModel.StoredDiscount => discount !== null)\n if (!drafts.length) return 0\n const codes = [...new Set(drafts.map((discount) => discount.code).filter((code): code is string => Boolean(code)))]\n // A stored name is compared as typed, and the query matches it exactly.\n const names = [...new Set(drafts.filter((discount) => !discount.code).map((discount) => discount.name ?? ''))]\n const discountsRef = collection(firestore, 'hosts', hostId, 'discounts')\n const [byCode, byName] = await Promise.all([\n codes.length ? getDocs(query(discountsRef, where('code', 'in', codes.slice(0, 10)))) : null,\n names.length ? getDocs(query(discountsRef, where('name', 'in', names.slice(0, 10)))) : null,\n ])\n const heldCodes = new Set((byCode?.docs ?? []).map((existing) => String(existing.get('code'))))\n const heldNames = new Set((byName?.docs ?? []).map((existing) => nameKey(String(existing.get('name') ?? ''))))\n let created = 0\n for (const discount of drafts) {\n const name = nameKey(discount.name ?? '')\n if (discount.code ? heldCodes.has(discount.code) : heldNames.has(name)) continue\n if (discount.code) heldCodes.add(discount.code)\n else heldNames.add(name)\n await setDoc(doc(firestore, 'hosts', hostId, 'discounts', createResourceUid()), discount)\n created += 1\n }\n return created\n },\n [firestore, hostId],\n )\n\n if (!Slot) return null\n return (\n <Slot\n slot=\"productsHub\"\n hostId={hostId}\n orgId={undefined}\n products={summaries}\n lastImport={lastImport}\n applyProductCopy={applyProductCopy}\n createProductDrafts={createProductDrafts}\n createCategories={createCategories}\n createDiscountDrafts={createDiscountDrafts}\n />\n )\n}\nProductsHubZone.displayName = 'ProductsHubZone'\n\nexport default ProductsHubZone\n"],"names":["createResourceUid","useConsoleWidgetSlot","Timestamp","useFirestore","useHostResourceApi","collection","doc","getDocs","limit","query","runTransaction","setDoc","where","useCallback","useMemo","CommerceModel","PRODUCT_GONE_COPY","PLAN_LOADING_COPY","CATEGORY_NAMES_READ","nameKey","name","replace","trim","toLowerCase","IN_CLAUSE_MAX","ProductsHubZone","props","hostId","products","roomFor","lastImport","onCreated","Slot","firestore","createHostResource","summaries","map","product","id","$id","status","priceMissing","productPriceMissing","hasPhoto","Boolean","mediaUrls","imageUrl","applyProductCopy","productId","values","ref","transaction","snapshot","get","stored","exists","data","deletedAt","Error","patch","productCopyPatch","liftLegacyProduct","Object","keys","length","update","updatedAtMs","Date","now","updatedAt","createProductDrafts","proposals","held","Set","searchKeys","proposal","productSearchFields","nameLower","filter","productsRef","Promise","all","Array","from","Math","ceil","_","index","slice","read","existing","docs","add","String","fresh","key","has","room","allowed","taken","slug","created","draft","unpricedProductDraft","resource","push","createCategories","names","category","raw","commerceSlug","parentId","createdAt","createDiscountDrafts","discounts","drafts","switchedOffDiscount","discount","codes","code","discountsRef","byCode","byName","heldCodes","heldNames","slot","orgId","undefined","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/products-hub-zone.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport { useConsoleWidgetSlot } from '@aglyn/aglyn/app-utils/console-widget-slot-context'\nimport type {\n ConsoleProductCopyValues,\n ConsoleProductSummary,\n ConsoleProductsHubZoneProps,\n ConsoleProposedDiscount,\n ConsoleProposedProduct,\n} from '@aglyn/aglyn/plugin-manager/feature-plugins'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport { useFirestore, useHostResourceApi } from '@aglyn/tenant-feature-instance'\nimport {\n collection,\n doc,\n getDocs,\n limit,\n query,\n runTransaction,\n setDoc,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useMemo } from 'react'\nimport * as CommerceModel from '../../model'\nimport { productSlugLedger } from './product-slugs'\nimport { readSmartCollections } from './smart-collections'\n\n/**\n * The products hub's zone (AGL-2916): the page of catalog rows the hub's table\n * shows, and the writes a widget there may ask the hub to make. A widget proposes; these are the\n * hub's writes, each through the door the hub's own controls use.\n *\n * - **Copy onto a saved product** is a transaction over the product as the\n * store holds it now, never a row the table was seeded with: it changes\n * only the fields the copy names, and renames options through\n * `renameProductOptions`, so a sale or a colleague's edit since the table\n * loaded is kept.\n * - **A proposed product** is created through the quota-enforcing resources\n * API, as Add product and Import create one, after the hub's own allowance\n * check for the batch. It is a draft with no price (`unpricedProductDraft`).\n * - **A category** is created as the catalog card creates one.\n * - **A discount** is created switched off (`switchedOffDiscount`), for a\n * person to switch on from the discounts card.\n *\n * Each create passes over what the site already has — a product of the same\n * name, a category of the same name, a discount with the same code or, with no\n * code, the same name — so a proposal applied twice, by anyone, creates\n * nothing twice. Each check is a read made when a person applies, never while\n * the hub is open, and reaches past the page the hub shows: a product is\n * looked up by its search key, and its slug by the slugs it could take\n * (`productSlugLedger`, AGL-3321).\n */\n\ntype ProductRow = CommerceModel.HostProduct & { $id: string }\n\n/** The hub's allowance check for `count` more products: `null` while the plan loads. */\nexport type ProductsRoomCheck = (count: number) => { allowed: boolean; limit: number } | null\n\nexport interface ProductsHubZoneProps {\n hostId: string\n /**\n * The page of catalog rows the hub's table shows, in its order — the\n * products its filters and search matched, one page at a time (AGL-3321).\n * Never the catalog: nothing here decides what exists from these rows.\n */\n products: readonly ProductRow[]\n roomFor: ProductsRoomCheck\n lastImport: ConsoleProductsHubZoneProps['lastImport']\n /** Called once a create has moved the catalog's count. */\n onCreated: () => void\n}\n\nexport const PRODUCT_GONE_COPY = 'This product is no longer in the catalog.'\nexport const PLAN_LOADING_COPY = 'Your plan is still loading. Try again in a moment.'\n\n/** How many of the site's categories the duplicate check reads. */\nconst CATEGORY_NAMES_READ = 250\n\n/** How a name is compared for a duplicate: case and spacing aside. */\nconst nameKey = (name: string) => name.replace(/\\s+/g, ' ').trim().toLowerCase()\n\n/** The most values one Firestore `in` clause takes. */\nconst IN_CLAUSE_MAX = 10\n\n/** No slug taken: a draft's own base slug, before the store is asked. */\nconst NO_SLUGS: ReadonlySet<string> = new Set()\n\nexport function ProductsHubZone(props: ProductsHubZoneProps) {\n const { hostId, products, roomFor, lastImport, onCreated } = props\n const Slot = useConsoleWidgetSlot()\n const firestore = useFirestore()\n const createHostResource = useHostResourceApi()\n\n const summaries = useMemo<ConsoleProductSummary[]>(\n () =>\n products.map((product) => ({\n id: product.$id,\n name: product.name,\n status: product.status,\n priceMissing: CommerceModel.productPriceMissing(product),\n hasPhoto: Boolean(product.mediaUrls?.[0] ?? product.imageUrl),\n })),\n [products],\n )\n\n const applyProductCopy = useCallback(\n async (productId: string, values: ConsoleProductCopyValues) => {\n const ref = doc(firestore, 'hosts', hostId, 'products', productId)\n const smartCollections = await readSmartCollections(firestore, hostId)\n await runTransaction(firestore, async (transaction) => {\n const snapshot = await transaction.get(ref)\n const stored = snapshot.exists() ? (snapshot.data() as CommerceModel.HostProduct) : null\n if (!stored || stored.deletedAt) throw new Error(PRODUCT_GONE_COPY)\n const lifted = CommerceModel.liftLegacyProduct(stored)\n const patch = CommerceModel.productCopyPatch(lifted, values)\n if (!Object.keys(patch).length) return\n // Tags and categories may move, and with them its smart collections\n // (AGL-3321).\n // Written only when it moved, so a copy that changes no rule input\n // writes only the copy.\n const collectionIds = CommerceModel.productCollectionIds(\n { ...lifted, ...patch },\n smartCollections,\n )\n const held = stored.collectionIds ?? []\n const moved =\n held.length !== collectionIds.length ||\n collectionIds.some((id, at) => held[at] !== id)\n transaction.update(ref, {\n ...patch,\n ...(moved ? { collectionIds } : {}),\n updatedAtMs: Date.now(),\n updatedAt: Timestamp.now(),\n })\n })\n },\n [firestore, hostId],\n )\n\n const createProductDrafts = useCallback(\n async (proposals: readonly ConsoleProposedProduct[]) => {\n // What exists is asked of the store, never read off the page on screen.\n const held = new Set<string>()\n const searchKeys = [\n ...new Set(proposals.map((proposal) => CommerceModel.productSearchFields({ name: proposal.name.trim() }).nameLower)),\n ].filter(Boolean)\n const productsRef = collection(firestore, 'hosts', hostId, 'products')\n const stored = await Promise.all(\n Array.from({ length: Math.ceil(searchKeys.length / IN_CLAUSE_MAX) }, (_, index) =>\n getDocs(query(productsRef, where('nameLower', 'in', searchKeys.slice(index * IN_CLAUSE_MAX, (index + 1) * IN_CLAUSE_MAX)))),\n ),\n )\n for (const read of stored) {\n for (const existing of read.docs) {\n if (!existing.get('deletedAt')) held.add(nameKey(String(existing.get('name') ?? '')))\n }\n }\n const fresh = proposals.filter((proposal) => {\n const key = nameKey(proposal.name)\n if (!key || held.has(key)) return false\n held.add(key)\n return true\n })\n if (!fresh.length) return []\n const room = roomFor(fresh.length)\n if (!room) throw new Error(PLAN_LOADING_COPY)\n if (!room.allowed) {\n throw new Error(\n `These products need ${fresh.length} product slots — your plan allows ` +\n `${room.limit}. See Billing to upgrade.`,\n )\n }\n // Each draft's slug is asked of the store: the base slug its name makes,\n // then `-2`, `-3`, … until one no product holds, live or deleted.\n const slugs = productSlugLedger(firestore, hostId)\n const created: string[] = []\n try {\n const drafts = fresh.map((proposal) =>\n CommerceModel.unpricedProductDraft(proposal, NO_SLUGS, Date.now()),\n )\n await slugs.ask(drafts.map((draft) => draft.slug))\n const smartCollections = await readSmartCollections(firestore, hostId)\n for (const draft of drafts) {\n const slug = await slugs.claim(draft.slug)\n const collectionIds = CommerceModel.productCollectionIds(draft, smartCollections)\n const { id } = await createHostResource({\n hostId,\n resource: 'product',\n data: { ...draft, slug, collectionIds },\n })\n created.push(id)\n }\n } finally {\n if (created.length) onCreated()\n }\n return created\n },\n [roomFor, createHostResource, firestore, hostId, onCreated],\n )\n\n const createCategories = useCallback(\n async (names: readonly string[]) => {\n const existing = await getDocs(\n query(collection(firestore, 'hosts', hostId, 'productCategories'), limit(CATEGORY_NAMES_READ)),\n )\n const held = new Set(existing.docs.map((category) => nameKey(String(category.get('name') ?? ''))))\n let created = 0\n for (const raw of names) {\n const name = raw.trim().slice(0, 80)\n if (!name || held.has(nameKey(name))) continue\n held.add(nameKey(name))\n await setDoc(doc(firestore, 'hosts', hostId, 'productCategories', createResourceUid()), {\n name,\n slug: CommerceModel.commerceSlug(name),\n parentId: null,\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n })\n created += 1\n }\n return created\n },\n [firestore, hostId],\n )\n\n const createDiscountDrafts = useCallback(\n async (discounts: readonly ConsoleProposedDiscount[]) => {\n const drafts = discounts\n .map((proposal) => CommerceModel.switchedOffDiscount(proposal))\n .filter((discount): discount is CommerceModel.StoredDiscount => discount !== null)\n if (!drafts.length) return 0\n const codes = [...new Set(drafts.map((discount) => discount.code).filter((code): code is string => Boolean(code)))]\n // A stored name is compared as typed, and the query matches it exactly.\n const names = [...new Set(drafts.filter((discount) => !discount.code).map((discount) => discount.name ?? ''))]\n const discountsRef = collection(firestore, 'hosts', hostId, 'discounts')\n const [byCode, byName] = await Promise.all([\n codes.length ? getDocs(query(discountsRef, where('code', 'in', codes.slice(0, 10)))) : null,\n names.length ? getDocs(query(discountsRef, where('name', 'in', names.slice(0, 10)))) : null,\n ])\n const heldCodes = new Set((byCode?.docs ?? []).map((existing) => String(existing.get('code'))))\n const heldNames = new Set((byName?.docs ?? []).map((existing) => nameKey(String(existing.get('name') ?? ''))))\n let created = 0\n for (const discount of drafts) {\n const name = nameKey(discount.name ?? '')\n if (discount.code ? heldCodes.has(discount.code) : heldNames.has(name)) continue\n if (discount.code) heldCodes.add(discount.code)\n else heldNames.add(name)\n await setDoc(doc(firestore, 'hosts', hostId, 'discounts', createResourceUid()), discount)\n created += 1\n }\n return created\n },\n [firestore, hostId],\n )\n\n if (!Slot) return null\n return (\n <Slot\n slot=\"productsHub\"\n hostId={hostId}\n orgId={undefined}\n products={summaries}\n lastImport={lastImport}\n applyProductCopy={applyProductCopy}\n createProductDrafts={createProductDrafts}\n createCategories={createCategories}\n createDiscountDrafts={createDiscountDrafts}\n />\n )\n}\nProductsHubZone.displayName = 'ProductsHubZone'\n\nexport default ProductsHubZone\n"],"names":["createResourceUid","useConsoleWidgetSlot","Timestamp","useFirestore","useHostResourceApi","collection","doc","getDocs","limit","query","runTransaction","setDoc","where","useCallback","useMemo","CommerceModel","productSlugLedger","readSmartCollections","PRODUCT_GONE_COPY","PLAN_LOADING_COPY","CATEGORY_NAMES_READ","nameKey","name","replace","trim","toLowerCase","IN_CLAUSE_MAX","NO_SLUGS","Set","ProductsHubZone","props","hostId","products","roomFor","lastImport","onCreated","Slot","firestore","createHostResource","summaries","map","product","id","$id","status","priceMissing","productPriceMissing","hasPhoto","Boolean","mediaUrls","imageUrl","applyProductCopy","productId","values","ref","smartCollections","transaction","stored","snapshot","get","exists","data","deletedAt","Error","lifted","liftLegacyProduct","patch","productCopyPatch","Object","keys","length","collectionIds","productCollectionIds","held","moved","some","at","update","updatedAtMs","Date","now","updatedAt","createProductDrafts","proposals","searchKeys","proposal","productSearchFields","nameLower","filter","productsRef","Promise","all","Array","from","Math","ceil","_","index","slice","read","existing","docs","add","String","fresh","key","has","room","allowed","slugs","created","drafts","unpricedProductDraft","ask","draft","slug","claim","resource","push","createCategories","names","category","raw","commerceSlug","parentId","createdAt","createDiscountDrafts","discounts","switchedOffDiscount","discount","codes","code","discountsRef","byCode","byName","heldCodes","heldNames","slot","orgId","undefined","displayName"],"mappings":"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"}
@@ -1,6 +1,35 @@
1
+ import { type Firestore, type Query } from 'firebase/firestore';
1
2
  export interface RecoveryQueueCardProps {
2
3
  hostId: string;
3
4
  }
5
+ /**
6
+ * The queries behind every figure on the card (AGL-3321), each the set
7
+ * `scanAbandonedCheckouts` or `scanRestockAlerts` acts on, asked of Firestore
8
+ * rather than matched over rows the card has read.
9
+ *
10
+ * Checkouts ask `status == 'open'` and the `recoveryState` every writer
11
+ * stamps, so "carries an email" and "not reminded yet" are equalities rather
12
+ * than absences. The two time windows are ranges on `createdAtMs`, ordered
13
+ * newest first so they share one composite with the recent list:
14
+ * `(status, recoveryState, createdAtMs DESC)`. Reminded is two equalities and
15
+ * needs none.
16
+ *
17
+ * Alerts ask `notifiedAtMs == null` for the waiting, which `notify-restock.ts`
18
+ * writes as an explicit null. Notified is every retired alert less the
19
+ * skipped ones — `process-restock.ts` writes `skipped: true` only together
20
+ * with `notifiedAtMs`, so the difference is exact — both on single-field
21
+ * indexes.
22
+ */
23
+ export declare function recoveryQueueQueries(firestore: Firestore, hostId: string, nowMs: number): {
24
+ due: Query<import("@firebase/firestore").DocumentData, import("@firebase/firestore").DocumentData>;
25
+ waiting: Query<import("@firebase/firestore").DocumentData, import("@firebase/firestore").DocumentData>;
26
+ reminded: Query<import("@firebase/firestore").DocumentData, import("@firebase/firestore").DocumentData>;
27
+ alertsWaiting: Query<import("@firebase/firestore").DocumentData, import("@firebase/firestore").DocumentData>;
28
+ alertsRetired: Query<import("@firebase/firestore").DocumentData, import("@firebase/firestore").DocumentData>;
29
+ alertsSkipped: Query<import("@firebase/firestore").DocumentData, import("@firebase/firestore").DocumentData>;
30
+ };
31
+ /** The open checkouts with an email, newest first — the card's short list. */
32
+ export declare function recentRecoverableCheckouts(firestore: Firestore, hostId: string): Query<import("@firebase/firestore").DocumentData, import("@firebase/firestore").DocumentData>;
4
33
  /**
5
34
  * Recovery & alerts (AGL-2227).
6
35
  *
@@ -19,11 +48,14 @@ export interface RecoveryQueueCardProps {
19
48
  * needs somewhere the queue depth is visible. A queue that is always empty and
20
49
  * a queue that is never drained look identical from outside.
21
50
  *
22
- * Read-only, and deliberately so. The counts come from the same documents the
23
- * scans read; nothing here writes, so this card cannot disagree with the job
24
- * about what is owed. Sending is the job's to do — a "send now" button would
25
- * be a second sender racing a beat that runs every 15 minutes, for a reminder
26
- * the merchant cannot un-send.
51
+ * Every figure is a Firestore COUNT (AGL-3321) over the whole collection —
52
+ * see `recoveryQueueQueries` — so a queue of any depth reads exactly, where
53
+ * the card used to count the first two hundred documents it read.
54
+ *
55
+ * Read-only, and deliberately so. Nothing here writes, so this card cannot
56
+ * disagree with the job about what is owed. Sending is the job's to do — a
57
+ * "send now" button would be a second sender racing a beat that runs every 15
58
+ * minutes, for a reminder the merchant cannot un-send.
27
59
  *
28
60
  * The abandoned half is entitlement-gated in place rather than gating the
29
61
  * whole card: `abandonedCart` is Pro, back-in-stock alerts are on every plan
@@ -17,13 +17,12 @@
17
17
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
18
18
  import { CardDisplay } from "@aglyn/shared-ui-jsx";
19
19
  import { Chip, Divider, Stack, Typography } from "@mui/material";
20
- import { collection } from "firebase/firestore";
21
- import { useMemo } from "react";
22
- import { ceilingedWindow, collectionCeiling, useFirestore, useFirestoreCollection } from "@aglyn/tenant-feature-instance";
20
+ import { collection, getCountFromServer, limit, orderBy, query, where } from "firebase/firestore";
21
+ import { useEffect, useMemo, useState } from "react";
22
+ import { useFirestore, useFirestoreCollection } from "@aglyn/tenant-feature-instance";
23
23
  import { pluginDocsHelp } from "@aglyn/aglyn";
24
+ import { CHECKOUT_GIVE_UP_AFTER_MS, CHECKOUT_RECOVERY_STATE_FIELD, CHECKOUT_REMIND_AFTER_MS } from "../../model/checkout-recovery.js";
24
25
  import { EntitlementUpsell, useCommerceEntitlement } from "./entitlement-gate.component.js";
25
- /** `process-abandoned.ts` waits this long before it will remind a checkout. */ const REMIND_AFTER_MS = 60 * 60 * 1000;
26
- /** …and gives up after this, marking the checkout `expired`. */ const GIVE_UP_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
27
26
  const recoveryHelp = pluginDocsHelp('commerce', {
28
27
  anchor: '#recovery-and-alerts',
29
28
  title: 'Recovery & alerts',
@@ -37,21 +36,48 @@ const relative = (atMs)=>{
37
36
  if (hours < 48) return `${hours}h ago`;
38
37
  return `${Math.round(hours / 24)}d ago`;
39
38
  };
39
+ /** How many open checkouts the card names under its chips. */ const RECENT_CHECKOUTS = 5;
40
+ const PENDING = 'pending';
41
+ const REMINDED = 'reminded';
40
42
  /**
41
- * How many documents each set of counters may be computed from.
43
+ * The queries behind every figure on the card (AGL-3321), each the set
44
+ * `scanAbandonedCheckouts` or `scanRestockAlerts` acts on, asked of Firestore
45
+ * rather than matched over rows the card has read.
42
46
  *
43
- * Every figure this card renders is a COUNT, so neither read can be paged — a
44
- * tally over page one is not a tally. What makes a count trustworthy instead
45
- * is that the walk reaching it is total, which is why both ask through
46
- * `collectionCeiling` and order on the document name: an unordered cap returns
47
- * a pseudo-random slice, and a count over a random slice is an undercount with
48
- * nothing on screen to suggest it.
47
+ * Checkouts ask `status == 'open'` and the `recoveryState` every writer
48
+ * stamps, so "carries an email" and "not reminded yet" are equalities rather
49
+ * than absences. The two time windows are ranges on `createdAtMs`, ordered
50
+ * newest first so they share one composite with the recent list:
51
+ * `(status, recoveryState, createdAtMs DESC)`. Reminded is two equalities and
52
+ * needs none.
49
53
  *
50
- * These chips claim to mirror what `scanAbandonedCheckouts` will act on, so a
51
- * ceiling that bites has to say so rather than quietly report a smaller queue
52
- * than the job will find.
53
- */ const CHECKOUT_CEILING = 200;
54
- const ALERT_CEILING = 200;
54
+ * Alerts ask `notifiedAtMs == null` for the waiting, which `notify-restock.ts`
55
+ * writes as an explicit null. Notified is every retired alert less the
56
+ * skipped ones — `process-restock.ts` writes `skipped: true` only together
57
+ * with `notifiedAtMs`, so the difference is exact — both on single-field
58
+ * indexes.
59
+ */ export function recoveryQueueQueries(firestore, hostId, nowMs) {
60
+ const checkouts = collection(firestore, 'hosts', hostId, 'checkouts');
61
+ const alerts = collection(firestore, 'hosts', hostId, 'restockAlerts');
62
+ const open = where('status', '==', 'open');
63
+ const pending = where(CHECKOUT_RECOVERY_STATE_FIELD, '==', PENDING);
64
+ const newestFirst = orderBy('createdAtMs', 'desc');
65
+ return {
66
+ due: query(checkouts, open, pending, where('createdAtMs', '>=', nowMs - CHECKOUT_GIVE_UP_AFTER_MS), where('createdAtMs', '<=', nowMs - CHECKOUT_REMIND_AFTER_MS), newestFirst),
67
+ waiting: query(checkouts, open, pending, where('createdAtMs', '>', nowMs - CHECKOUT_REMIND_AFTER_MS), newestFirst),
68
+ reminded: query(checkouts, open, where(CHECKOUT_RECOVERY_STATE_FIELD, '==', REMINDED)),
69
+ alertsWaiting: query(alerts, where('notifiedAtMs', '==', null)),
70
+ alertsRetired: query(alerts, where('notifiedAtMs', '!=', null)),
71
+ alertsSkipped: query(alerts, where('skipped', '==', true))
72
+ };
73
+ }
74
+ /** The open checkouts with an email, newest first — the card's short list. */ export function recentRecoverableCheckouts(firestore, hostId) {
75
+ return query(collection(firestore, 'hosts', hostId, 'checkouts'), where('status', '==', 'open'), where(CHECKOUT_RECOVERY_STATE_FIELD, 'in', [
76
+ PENDING,
77
+ REMINDED
78
+ ]), orderBy('createdAtMs', 'desc'), limit(RECENT_CHECKOUTS));
79
+ }
80
+ const countOf = (target)=>getCountFromServer(target).then((snapshot)=>snapshot.data().count);
55
81
  /**
56
82
  * Recovery & alerts (AGL-2227).
57
83
  *
@@ -70,11 +96,14 @@ const ALERT_CEILING = 200;
70
96
  * needs somewhere the queue depth is visible. A queue that is always empty and
71
97
  * a queue that is never drained look identical from outside.
72
98
  *
73
- * Read-only, and deliberately so. The counts come from the same documents the
74
- * scans read; nothing here writes, so this card cannot disagree with the job
75
- * about what is owed. Sending is the job's to do — a "send now" button would
76
- * be a second sender racing a beat that runs every 15 minutes, for a reminder
77
- * the merchant cannot un-send.
99
+ * Every figure is a Firestore COUNT (AGL-3321) over the whole collection —
100
+ * see `recoveryQueueQueries` — so a queue of any depth reads exactly, where
101
+ * the card used to count the first two hundred documents it read.
102
+ *
103
+ * Read-only, and deliberately so. Nothing here writes, so this card cannot
104
+ * disagree with the job about what is owed. Sending is the job's to do — a
105
+ * "send now" button would be a second sender racing a beat that runs every 15
106
+ * minutes, for a reminder the merchant cannot un-send.
78
107
  *
79
108
  * The abandoned half is entitlement-gated in place rather than gating the
80
109
  * whole card: `abandonedCart` is Pro, back-in-stock alerts are on every plan
@@ -86,59 +115,56 @@ const ALERT_CEILING = 200;
86
115
  const { hostId } = props;
87
116
  const firestore = useFirestore();
88
117
  const { ready, entitled, upgradeHref, planLabel } = useCommerceEntitlement(hostId, 'abandonedCart');
89
- const { data: checkoutDocs } = useFirestoreCollection(()=>collectionCeiling(collection(firestore, 'hosts', hostId, 'checkouts'), CHECKOUT_CEILING), [
118
+ const { data: recentDocs } = useFirestoreCollection(()=>recentRecoverableCheckouts(firestore, hostId), [
90
119
  firestore,
91
120
  hostId
92
121
  ], {
93
122
  idField: '$id'
94
123
  });
95
- const { data: alertDocs } = useFirestoreCollection(()=>collectionCeiling(collection(firestore, 'hosts', hostId, 'restockAlerts'), ALERT_CEILING), [
96
- firestore,
97
- hostId
98
- ], {
99
- idField: '$id'
100
- });
101
- const checkoutWindow = useMemo(()=>ceilingedWindow(checkoutDocs != null ? checkoutDocs : undefined, CHECKOUT_CEILING), [
102
- checkoutDocs
103
- ]);
104
- const alertWindow = useMemo(()=>ceilingedWindow(alertDocs != null ? alertDocs : undefined, ALERT_CEILING), [
105
- alertDocs
124
+ const recent = useMemo(()=>recentDocs != null ? recentDocs : [], [
125
+ recentDocs
106
126
  ]);
107
- const checkouts = useMemo(()=>{
108
- const now = Date.now();
109
- // The same three tests `scanAbandonedCheckouts` applies, in the same
110
- // order, so the number here is the number the job will act on rather
111
- // than a looser "open checkouts" count that would always read higher.
112
- const open = checkoutWindow.rows.filter((row)=>row.status === 'open' && row.email);
113
- return {
114
- reminded: open.filter((row)=>row.remindedAtMs).length,
115
- due: open.filter((row)=>{
116
- var _row_createdAtMs, _row_createdAtMs1;
117
- return !row.remindedAtMs && now - Number((_row_createdAtMs = row.createdAtMs) != null ? _row_createdAtMs : 0) >= REMIND_AFTER_MS && now - Number((_row_createdAtMs1 = row.createdAtMs) != null ? _row_createdAtMs1 : 0) <= GIVE_UP_AFTER_MS;
118
- }).length,
119
- waiting: open.filter((row)=>{
120
- var _row_createdAtMs;
121
- return !row.remindedAtMs && now - Number((_row_createdAtMs = row.createdAtMs) != null ? _row_createdAtMs : 0) < REMIND_AFTER_MS;
122
- }).length,
123
- recent: [
124
- ...open
125
- ].sort((a, b)=>{
126
- var _b_createdAtMs, _a_createdAtMs;
127
- return ((_b_createdAtMs = b.createdAtMs) != null ? _b_createdAtMs : 0) - ((_a_createdAtMs = a.createdAtMs) != null ? _a_createdAtMs : 0);
128
- }).slice(0, 5)
127
+ // Counted again whenever the newest checkouts move, which is when the
128
+ // figures under them have.
129
+ const [figures, setFigures] = useState(null);
130
+ const [countFailed, setCountFailed] = useState(false);
131
+ const recentKey = recent.map((row)=>{
132
+ var _row_remindedAtMs;
133
+ return `${row.$id}:${(_row_remindedAtMs = row.remindedAtMs) != null ? _row_remindedAtMs : ''}`;
134
+ }).join(',');
135
+ useEffect(()=>{
136
+ let active = true;
137
+ const asked = recoveryQueueQueries(firestore, hostId, Date.now());
138
+ Promise.all([
139
+ countOf(asked.due),
140
+ countOf(asked.waiting),
141
+ countOf(asked.reminded),
142
+ countOf(asked.alertsWaiting),
143
+ countOf(asked.alertsRetired),
144
+ countOf(asked.alertsSkipped)
145
+ ]).then(([due, waiting, reminded, alertsWaiting, retired, skipped])=>{
146
+ if (!active) return;
147
+ setFigures({
148
+ due,
149
+ waiting,
150
+ reminded,
151
+ alertsWaiting,
152
+ alertsNotified: Math.max(0, retired - skipped)
153
+ });
154
+ setCountFailed(false);
155
+ }).catch((error)=>{
156
+ console.error('Recovery queue counts failed', error);
157
+ if (active) setCountFailed(true);
158
+ });
159
+ return ()=>{
160
+ active = false;
129
161
  };
130
162
  }, [
131
- checkoutWindow
132
- ]);
133
- const alerts = useMemo(()=>{
134
- const rows = alertWindow.rows;
135
- return {
136
- pending: rows.filter((row)=>row.notifiedAtMs == null).length,
137
- notified: rows.filter((row)=>row.notifiedAtMs != null && !row.skipped).length
138
- };
139
- }, [
140
- alertWindow
163
+ firestore,
164
+ hostId,
165
+ recentKey
141
166
  ]);
167
+ /** A figure, or a dash until it is counted. Never a 0 it has not counted. */ const figure = (value)=>value == null ? '—' : String(value);
142
168
  return /*#__PURE__*/ _jsx(CardDisplay, {
143
169
  header: 'Recovery & alerts',
144
170
  help: recoveryHelp,
@@ -147,6 +173,11 @@ const ALERT_CEILING = 200;
147
173
  children: /*#__PURE__*/ _jsxs(Stack, {
148
174
  spacing: 2,
149
175
  children: [
176
+ countFailed ? /*#__PURE__*/ _jsx(Typography, {
177
+ variant: "caption",
178
+ color: "error",
179
+ children: 'The queue figures could not be counted just now. They are ' + 'counted again when a checkout changes, or on reload.'
180
+ }) : null,
150
181
  /*#__PURE__*/ _jsxs(Stack, {
151
182
  spacing: 1,
152
183
  children: [
@@ -170,33 +201,28 @@ const ALERT_CEILING = 200;
170
201
  children: [
171
202
  /*#__PURE__*/ _jsx(Chip, {
172
203
  size: "small",
173
- color: checkouts.due ? 'warning' : 'default',
174
- label: `${checkouts.due} due a reminder`
204
+ color: (figures == null ? void 0 : figures.due) ? 'warning' : 'default',
205
+ label: `${figure(figures == null ? void 0 : figures.due)} due a reminder`
175
206
  }),
176
207
  /*#__PURE__*/ _jsx(Chip, {
177
208
  size: "small",
178
- label: `${checkouts.waiting} still within the first hour`
209
+ label: `${figure(figures == null ? void 0 : figures.waiting)} still within the first hour`
179
210
  }),
180
211
  /*#__PURE__*/ _jsx(Chip, {
181
212
  size: "small",
182
- color: checkouts.reminded ? 'success' : 'default',
183
- label: `${checkouts.reminded} reminded`
213
+ color: (figures == null ? void 0 : figures.reminded) ? 'success' : 'default',
214
+ label: `${figure(figures == null ? void 0 : figures.reminded)} reminded`
184
215
  })
185
216
  ]
186
217
  }),
187
- checkoutWindow.truncated ? /*#__PURE__*/ _jsx(Typography, {
188
- variant: "caption",
189
- color: "text.secondary",
190
- children: `Counted across ${CHECKOUT_CEILING} checkouts. The reminder job reads the rest; these figures do not.`
191
- }) : null,
192
218
  /*#__PURE__*/ _jsx(Typography, {
193
219
  variant: "caption",
194
220
  color: "text.secondary",
195
221
  children: 'Reminders send automatically about 15 minutes after a ' + 'checkout has been idle for an hour. A checkout that is ' + 'completed stops reminding itself.'
196
222
  }),
197
- checkouts.recent.length ? /*#__PURE__*/ _jsx(Stack, {
223
+ recent.length ? /*#__PURE__*/ _jsx(Stack, {
198
224
  spacing: 0.5,
199
- children: checkouts.recent.map((row)=>/*#__PURE__*/ _jsx(Typography, {
225
+ children: recent.map((row)=>/*#__PURE__*/ _jsx(Typography, {
200
226
  variant: "body2",
201
227
  children: `${row.email} · started ${relative(row.createdAtMs)}${row.remindedAtMs ? ` · reminded ${relative(row.remindedAtMs)}` : ''}`
202
228
  }, row.$id))
@@ -231,20 +257,15 @@ const ALERT_CEILING = 200;
231
257
  children: [
232
258
  /*#__PURE__*/ _jsx(Chip, {
233
259
  size: "small",
234
- color: alerts.pending ? 'info' : 'default',
235
- label: `${alerts.pending} shoppers waiting`
260
+ color: (figures == null ? void 0 : figures.alertsWaiting) ? 'info' : 'default',
261
+ label: `${figure(figures == null ? void 0 : figures.alertsWaiting)} shoppers waiting`
236
262
  }),
237
263
  /*#__PURE__*/ _jsx(Chip, {
238
264
  size: "small",
239
- label: `${alerts.notified} notified`
265
+ label: `${figure(figures == null ? void 0 : figures.alertsNotified)} notified`
240
266
  })
241
267
  ]
242
268
  }),
243
- alertWindow.truncated ? /*#__PURE__*/ _jsx(Typography, {
244
- variant: "caption",
245
- color: "text.secondary",
246
- children: `Counted across ${ALERT_CEILING} alerts. The notify job reads the rest; these figures do not.`
247
- }) : null,
248
269
  /*#__PURE__*/ _jsx(Typography, {
249
270
  variant: "caption",
250
271
  color: "text.secondary",
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/recovery-queue-card.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Chip, Divider, Stack, Typography } from '@mui/material'\nimport { collection } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport {\n ceilingedWindow,\n collectionCeiling,\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport {\n EntitlementUpsell,\n useCommerceEntitlement,\n} from './entitlement-gate.component'\n\nexport interface RecoveryQueueCardProps {\n hostId: string\n}\n\n/** `process-abandoned.ts` waits this long before it will remind a checkout. */\nconst REMIND_AFTER_MS = 60 * 60 * 1000\n/** …and gives up after this, marking the checkout `expired`. */\nconst GIVE_UP_AFTER_MS = 7 * 24 * 60 * 60 * 1000\n\nconst recoveryHelp = pluginDocsHelp('commerce', {\n anchor: '#recovery-and-alerts',\n title: 'Recovery & alerts',\n excerpt:\n 'Shoppers who left a checkout unfinished, and shoppers waiting to be ' +\n 'told a sold-out product is back. Both are emailed automatically.',\n})\n\nconst relative = (atMs: number | undefined): string => {\n if (!atMs) return '—'\n const minutes = Math.max(0, Math.round((Date.now() - atMs) / 60_000))\n if (minutes < 60) return `${minutes}m ago`\n const hours = Math.round(minutes / 60)\n if (hours < 48) return `${hours}h ago`\n return `${Math.round(hours / 24)}d ago`\n}\n\n/**\n * How many documents each set of counters may be computed from.\n *\n * Every figure this card renders is a COUNT, so neither read can be paged — a\n * tally over page one is not a tally. What makes a count trustworthy instead\n * is that the walk reaching it is total, which is why both ask through\n * `collectionCeiling` and order on the document name: an unordered cap returns\n * a pseudo-random slice, and a count over a random slice is an undercount with\n * nothing on screen to suggest it.\n *\n * These chips claim to mirror what `scanAbandonedCheckouts` will act on, so a\n * ceiling that bites has to say so rather than quietly report a smaller queue\n * than the job will find.\n */\nconst CHECKOUT_CEILING = 200\nconst ALERT_CEILING = 200\n\n/**\n * Recovery & alerts (AGL-2227).\n *\n * Two queues the storefront fills and a background job drains, neither of\n * which the console showed at all:\n *\n * - **Abandoned checkouts** — `hosts/{hostId}/checkouts` with `status: 'open'`.\n * `scanAbandonedCheckouts` emails one reminder per checkout that carries an\n * email and has been sitting for an hour, then stamps `remindedAtMs`.\n * - **Back-in-stock alerts** — `hosts/{hostId}/restockAlerts` with a null\n * `notifiedAtMs`. `scanRestockAlerts` mails them once the product has stock.\n *\n * Both scans were dark until AGL-2227 put them on the platform job beat. That\n * is the reason this card exists rather than only the wiring: a background job\n * is the one surface with no user to notice it has stopped, so the merchant\n * needs somewhere the queue depth is visible. A queue that is always empty and\n * a queue that is never drained look identical from outside.\n *\n * Read-only, and deliberately so. The counts come from the same documents the\n * scans read; nothing here writes, so this card cannot disagree with the job\n * about what is owed. Sending is the job's to do — a \"send now\" button would\n * be a second sender racing a beat that runs every 15 minutes, for a reminder\n * the merchant cannot un-send.\n *\n * The abandoned half is entitlement-gated in place rather than gating the\n * whole card: `abandonedCart` is Pro, back-in-stock alerts are on every plan\n * that has commerce, and `EntitlementGatedCard` would have hidden the free\n * half behind the paid one. `ready` is load-bearing for the same reason it is\n * everywhere else — `checkEntitlement(undefined)` resolves the FREE tier, so\n * refusing before the org doc lands accuses a paying customer.\n */\nexport function RecoveryQueueCard(props: RecoveryQueueCardProps) {\n const { hostId } = props\n const firestore = useFirestore()\n const { ready, entitled, upgradeHref, planLabel } = useCommerceEntitlement(\n hostId,\n 'abandonedCart',\n )\n\n const { data: checkoutDocs } = useFirestoreCollection<any>(\n () =>\n collectionCeiling(\n collection(firestore, 'hosts', hostId, 'checkouts'),\n CHECKOUT_CEILING,\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n const { data: alertDocs } = useFirestoreCollection<any>(\n () =>\n collectionCeiling(\n collection(firestore, 'hosts', hostId, 'restockAlerts'),\n ALERT_CEILING,\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n const checkoutWindow = useMemo(\n () => ceilingedWindow<any>(checkoutDocs ?? undefined, CHECKOUT_CEILING),\n [checkoutDocs],\n )\n const alertWindow = useMemo(\n () => ceilingedWindow<any>(alertDocs ?? undefined, ALERT_CEILING),\n [alertDocs],\n )\n\n const checkouts = useMemo(() => {\n const now = Date.now()\n // The same three tests `scanAbandonedCheckouts` applies, in the same\n // order, so the number here is the number the job will act on rather\n // than a looser \"open checkouts\" count that would always read higher.\n const open = checkoutWindow.rows.filter(\n (row: any) => row.status === 'open' && row.email,\n )\n return {\n reminded: open.filter((row: any) => row.remindedAtMs).length,\n due: open.filter(\n (row: any) =>\n !row.remindedAtMs &&\n now - Number(row.createdAtMs ?? 0) >= REMIND_AFTER_MS &&\n now - Number(row.createdAtMs ?? 0) <= GIVE_UP_AFTER_MS,\n ).length,\n waiting: open.filter(\n (row: any) =>\n !row.remindedAtMs &&\n now - Number(row.createdAtMs ?? 0) < REMIND_AFTER_MS,\n ).length,\n recent: [...open]\n .sort((a: any, b: any) => (b.createdAtMs ?? 0) - (a.createdAtMs ?? 0))\n .slice(0, 5),\n }\n }, [checkoutWindow])\n\n const alerts = useMemo(() => {\n const rows = alertWindow.rows\n return {\n pending: rows.filter((row: any) => row.notifiedAtMs == null).length,\n notified: rows.filter(\n (row: any) => row.notifiedAtMs != null && !row.skipped,\n ).length,\n }\n }, [alertWindow])\n\n return (\n <CardDisplay\n header={'Recovery & alerts'}\n help={recoveryHelp}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack spacing={1}>\n <Typography variant=\"subtitle2\">{'Abandoned checkouts'}</Typography>\n {!ready ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Checking your plan…'}\n </Typography>\n ) : entitled ? (\n <>\n <Stack direction=\"row\" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <Chip\n size=\"small\"\n color={checkouts.due ? 'warning' : 'default'}\n label={`${checkouts.due} due a reminder`}\n />\n <Chip\n size=\"small\"\n label={`${checkouts.waiting} still within the first hour`}\n />\n <Chip\n size=\"small\"\n color={checkouts.reminded ? 'success' : 'default'}\n label={`${checkouts.reminded} reminded`}\n />\n </Stack>\n {checkoutWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Counted across ${CHECKOUT_CEILING} checkouts. The reminder job reads the rest; these figures do not.`}\n </Typography>\n ) : null}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Reminders send automatically about 15 minutes after a ' +\n 'checkout has been idle for an hour. A checkout that is ' +\n 'completed stops reminding itself.'}\n </Typography>\n {checkouts.recent.length ? (\n <Stack spacing={0.5}>\n {checkouts.recent.map((row: any) => (\n <Typography key={row.$id} variant=\"body2\">\n {`${row.email} · started ${relative(row.createdAtMs)}${\n row.remindedAtMs\n ? ` · reminded ${relative(row.remindedAtMs)}`\n : ''\n }`}\n </Typography>\n ))}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No open checkouts with an email address.'}\n </Typography>\n )}\n </>\n ) : (\n <EntitlementUpsell planLabel={planLabel} upgradeHref={upgradeHref}>\n {'Abandoned checkout recovery emails a shopper who reached ' +\n 'checkout, entered their email and left, with a link straight ' +\n 'back to the cart they built.'}\n </EntitlementUpsell>\n )}\n </Stack>\n <Divider />\n <Stack spacing={1}>\n <Typography variant=\"subtitle2\">{'Back-in-stock alerts'}</Typography>\n <Stack direction=\"row\" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <Chip\n size=\"small\"\n color={alerts.pending ? 'info' : 'default'}\n label={`${alerts.pending} shoppers waiting`}\n />\n <Chip size=\"small\" label={`${alerts.notified} notified`} />\n </Stack>\n {alertWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Counted across ${ALERT_CEILING} alerts. The notify job reads the rest; these figures do not.`}\n </Typography>\n ) : null}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Anyone who used “Notify me when it’s back” on a sold-out ' +\n 'product is emailed once its stock goes above zero. Waiting ' +\n 'shoppers are a demand signal worth restocking against.'}\n </Typography>\n </Stack>\n </Stack>\n </CardDisplay>\n )\n}\nRecoveryQueueCard.displayName = 'RecoveryQueueCard'\n\nexport default RecoveryQueueCard\n"],"names":["CardDisplay","Chip","Divider","Stack","Typography","collection","useMemo","ceilingedWindow","collectionCeiling","useFirestore","useFirestoreCollection","pluginDocsHelp","EntitlementUpsell","useCommerceEntitlement","REMIND_AFTER_MS","GIVE_UP_AFTER_MS","recoveryHelp","anchor","title","excerpt","relative","atMs","minutes","Math","max","round","Date","now","hours","CHECKOUT_CEILING","ALERT_CEILING","RecoveryQueueCard","props","hostId","firestore","ready","entitled","upgradeHref","planLabel","data","checkoutDocs","idField","alertDocs","checkoutWindow","undefined","alertWindow","checkouts","open","rows","filter","row","status","email","reminded","remindedAtMs","length","due","Number","createdAtMs","waiting","recent","sort","a","b","slice","alerts","pending","notifiedAtMs","notified","skipped","header","help","contentGutterX","contentGutterY","spacing","variant","color","direction","useFlexGap","sx","flexWrap","size","label","truncated","map","$id","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/recovery-queue-card.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Chip, Divider, Stack, Typography } from '@mui/material'\nimport {\n collection,\n type Firestore,\n getCountFromServer,\n limit,\n orderBy,\n query,\n type Query,\n where,\n} from 'firebase/firestore'\nimport { useEffect, useMemo, useState } from 'react'\nimport { useFirestore, useFirestoreCollection } from '@aglyn/tenant-feature-instance'\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport {\n CHECKOUT_GIVE_UP_AFTER_MS,\n CHECKOUT_RECOVERY_STATE_FIELD,\n CHECKOUT_REMIND_AFTER_MS,\n type CheckoutRecoveryState,\n} from '../../model/checkout-recovery'\nimport {\n EntitlementUpsell,\n useCommerceEntitlement,\n} from './entitlement-gate.component'\n\nexport interface RecoveryQueueCardProps {\n hostId: string\n}\n\nconst recoveryHelp = pluginDocsHelp('commerce', {\n anchor: '#recovery-and-alerts',\n title: 'Recovery & alerts',\n excerpt:\n 'Shoppers who left a checkout unfinished, and shoppers waiting to be ' +\n 'told a sold-out product is back. Both are emailed automatically.',\n})\n\nconst relative = (atMs: number | undefined): string => {\n if (!atMs) return '—'\n const minutes = Math.max(0, Math.round((Date.now() - atMs) / 60_000))\n if (minutes < 60) return `${minutes}m ago`\n const hours = Math.round(minutes / 60)\n if (hours < 48) return `${hours}h ago`\n return `${Math.round(hours / 24)}d ago`\n}\n\n/** How many open checkouts the card names under its chips. */\nconst RECENT_CHECKOUTS = 5\n\nconst PENDING: CheckoutRecoveryState = 'pending'\nconst REMINDED: CheckoutRecoveryState = 'reminded'\n\n/**\n * The queries behind every figure on the card (AGL-3321), each the set\n * `scanAbandonedCheckouts` or `scanRestockAlerts` acts on, asked of Firestore\n * rather than matched over rows the card has read.\n *\n * Checkouts ask `status == 'open'` and the `recoveryState` every writer\n * stamps, so \"carries an email\" and \"not reminded yet\" are equalities rather\n * than absences. The two time windows are ranges on `createdAtMs`, ordered\n * newest first so they share one composite with the recent list:\n * `(status, recoveryState, createdAtMs DESC)`. Reminded is two equalities and\n * needs none.\n *\n * Alerts ask `notifiedAtMs == null` for the waiting, which `notify-restock.ts`\n * writes as an explicit null. Notified is every retired alert less the\n * skipped ones — `process-restock.ts` writes `skipped: true` only together\n * with `notifiedAtMs`, so the difference is exact — both on single-field\n * indexes.\n */\nexport function recoveryQueueQueries(\n firestore: Firestore,\n hostId: string,\n nowMs: number,\n) {\n const checkouts = collection(firestore, 'hosts', hostId, 'checkouts')\n const alerts = collection(firestore, 'hosts', hostId, 'restockAlerts')\n const open = where('status', '==', 'open')\n const pending = where(CHECKOUT_RECOVERY_STATE_FIELD, '==', PENDING)\n const newestFirst = orderBy('createdAtMs', 'desc')\n return {\n due: query(\n checkouts,\n open,\n pending,\n where('createdAtMs', '>=', nowMs - CHECKOUT_GIVE_UP_AFTER_MS),\n where('createdAtMs', '<=', nowMs - CHECKOUT_REMIND_AFTER_MS),\n newestFirst,\n ),\n waiting: query(\n checkouts,\n open,\n pending,\n where('createdAtMs', '>', nowMs - CHECKOUT_REMIND_AFTER_MS),\n newestFirst,\n ),\n reminded: query(checkouts, open, where(CHECKOUT_RECOVERY_STATE_FIELD, '==', REMINDED)),\n alertsWaiting: query(alerts, where('notifiedAtMs', '==', null)),\n alertsRetired: query(alerts, where('notifiedAtMs', '!=', null)),\n alertsSkipped: query(alerts, where('skipped', '==', true)),\n }\n}\n\n/** The open checkouts with an email, newest first — the card's short list. */\nexport function recentRecoverableCheckouts(\n firestore: Firestore,\n hostId: string,\n) {\n return query(\n collection(firestore, 'hosts', hostId, 'checkouts'),\n where('status', '==', 'open'),\n where(CHECKOUT_RECOVERY_STATE_FIELD, 'in', [PENDING, REMINDED]),\n orderBy('createdAtMs', 'desc'),\n limit(RECENT_CHECKOUTS),\n )\n}\n\ninterface QueueFigures {\n due: number\n waiting: number\n reminded: number\n alertsWaiting: number\n alertsNotified: number\n}\n\nconst countOf = (target: Query) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n\n/**\n * Recovery & alerts (AGL-2227).\n *\n * Two queues the storefront fills and a background job drains, neither of\n * which the console showed at all:\n *\n * - **Abandoned checkouts** — `hosts/{hostId}/checkouts` with `status: 'open'`.\n * `scanAbandonedCheckouts` emails one reminder per checkout that carries an\n * email and has been sitting for an hour, then stamps `remindedAtMs`.\n * - **Back-in-stock alerts** — `hosts/{hostId}/restockAlerts` with a null\n * `notifiedAtMs`. `scanRestockAlerts` mails them once the product has stock.\n *\n * Both scans were dark until AGL-2227 put them on the platform job beat. That\n * is the reason this card exists rather than only the wiring: a background job\n * is the one surface with no user to notice it has stopped, so the merchant\n * needs somewhere the queue depth is visible. A queue that is always empty and\n * a queue that is never drained look identical from outside.\n *\n * Every figure is a Firestore COUNT (AGL-3321) over the whole collection —\n * see `recoveryQueueQueries` — so a queue of any depth reads exactly, where\n * the card used to count the first two hundred documents it read.\n *\n * Read-only, and deliberately so. Nothing here writes, so this card cannot\n * disagree with the job about what is owed. Sending is the job's to do — a\n * \"send now\" button would be a second sender racing a beat that runs every 15\n * minutes, for a reminder the merchant cannot un-send.\n *\n * The abandoned half is entitlement-gated in place rather than gating the\n * whole card: `abandonedCart` is Pro, back-in-stock alerts are on every plan\n * that has commerce, and `EntitlementGatedCard` would have hidden the free\n * half behind the paid one. `ready` is load-bearing for the same reason it is\n * everywhere else — `checkEntitlement(undefined)` resolves the FREE tier, so\n * refusing before the org doc lands accuses a paying customer.\n */\nexport function RecoveryQueueCard(props: RecoveryQueueCardProps) {\n const { hostId } = props\n const firestore = useFirestore()\n const { ready, entitled, upgradeHref, planLabel } = useCommerceEntitlement(\n hostId,\n 'abandonedCart',\n )\n\n const { data: recentDocs } = useFirestoreCollection<any>(\n () => recentRecoverableCheckouts(firestore, hostId),\n [firestore, hostId],\n { idField: '$id' },\n )\n const recent = useMemo(() => recentDocs ?? [], [recentDocs])\n\n // Counted again whenever the newest checkouts move, which is when the\n // figures under them have.\n const [figures, setFigures] = useState<QueueFigures | null>(null)\n const [countFailed, setCountFailed] = useState(false)\n const recentKey = recent\n .map((row: any) => `${row.$id}:${row.remindedAtMs ?? ''}`)\n .join(',')\n useEffect(() => {\n let active = true\n const asked = recoveryQueueQueries(firestore, hostId, Date.now())\n Promise.all([\n countOf(asked.due),\n countOf(asked.waiting),\n countOf(asked.reminded),\n countOf(asked.alertsWaiting),\n countOf(asked.alertsRetired),\n countOf(asked.alertsSkipped),\n ])\n .then(([due, waiting, reminded, alertsWaiting, retired, skipped]) => {\n if (!active) return\n setFigures({\n due,\n waiting,\n reminded,\n alertsWaiting,\n alertsNotified: Math.max(0, retired - skipped),\n })\n setCountFailed(false)\n })\n .catch((error) => {\n console.error('Recovery queue counts failed', error)\n if (active) setCountFailed(true)\n })\n return () => {\n active = false\n }\n }, [firestore, hostId, recentKey])\n\n /** A figure, or a dash until it is counted. Never a 0 it has not counted. */\n const figure = (value: number | undefined) => (value == null ? '—' : String(value))\n\n return (\n <CardDisplay\n header={'Recovery & alerts'}\n help={recoveryHelp}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {countFailed ? (\n <Typography variant=\"caption\" color=\"error\">\n {'The queue figures could not be counted just now. They are ' +\n 'counted again when a checkout changes, or on reload.'}\n </Typography>\n ) : null}\n <Stack spacing={1}>\n <Typography variant=\"subtitle2\">{'Abandoned checkouts'}</Typography>\n {!ready ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Checking your plan…'}\n </Typography>\n ) : entitled ? (\n <>\n <Stack direction=\"row\" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <Chip\n size=\"small\"\n color={figures?.due ? 'warning' : 'default'}\n label={`${figure(figures?.due)} due a reminder`}\n />\n <Chip\n size=\"small\"\n label={`${figure(figures?.waiting)} still within the first hour`}\n />\n <Chip\n size=\"small\"\n color={figures?.reminded ? 'success' : 'default'}\n label={`${figure(figures?.reminded)} reminded`}\n />\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Reminders send automatically about 15 minutes after a ' +\n 'checkout has been idle for an hour. A checkout that is ' +\n 'completed stops reminding itself.'}\n </Typography>\n {recent.length ? (\n <Stack spacing={0.5}>\n {recent.map((row: any) => (\n <Typography key={row.$id} variant=\"body2\">\n {`${row.email} · started ${relative(row.createdAtMs)}${\n row.remindedAtMs\n ? ` · reminded ${relative(row.remindedAtMs)}`\n : ''\n }`}\n </Typography>\n ))}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No open checkouts with an email address.'}\n </Typography>\n )}\n </>\n ) : (\n <EntitlementUpsell planLabel={planLabel} upgradeHref={upgradeHref}>\n {'Abandoned checkout recovery emails a shopper who reached ' +\n 'checkout, entered their email and left, with a link straight ' +\n 'back to the cart they built.'}\n </EntitlementUpsell>\n )}\n </Stack>\n <Divider />\n <Stack spacing={1}>\n <Typography variant=\"subtitle2\">{'Back-in-stock alerts'}</Typography>\n <Stack direction=\"row\" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <Chip\n size=\"small\"\n color={figures?.alertsWaiting ? 'info' : 'default'}\n label={`${figure(figures?.alertsWaiting)} shoppers waiting`}\n />\n <Chip size=\"small\" label={`${figure(figures?.alertsNotified)} notified`} />\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Anyone who used “Notify me when it’s back” on a sold-out ' +\n 'product is emailed once its stock goes above zero. Waiting ' +\n 'shoppers are a demand signal worth restocking against.'}\n </Typography>\n </Stack>\n </Stack>\n </CardDisplay>\n )\n}\nRecoveryQueueCard.displayName = 'RecoveryQueueCard'\n\nexport default RecoveryQueueCard\n"],"names":["CardDisplay","Chip","Divider","Stack","Typography","collection","getCountFromServer","limit","orderBy","query","where","useEffect","useMemo","useState","useFirestore","useFirestoreCollection","pluginDocsHelp","CHECKOUT_GIVE_UP_AFTER_MS","CHECKOUT_RECOVERY_STATE_FIELD","CHECKOUT_REMIND_AFTER_MS","EntitlementUpsell","useCommerceEntitlement","recoveryHelp","anchor","title","excerpt","relative","atMs","minutes","Math","max","round","Date","now","hours","RECENT_CHECKOUTS","PENDING","REMINDED","recoveryQueueQueries","firestore","hostId","nowMs","checkouts","alerts","open","pending","newestFirst","due","waiting","reminded","alertsWaiting","alertsRetired","alertsSkipped","recentRecoverableCheckouts","countOf","target","then","snapshot","data","count","RecoveryQueueCard","props","ready","entitled","upgradeHref","planLabel","recentDocs","idField","recent","figures","setFigures","countFailed","setCountFailed","recentKey","map","row","$id","remindedAtMs","join","active","asked","Promise","all","retired","skipped","alertsNotified","catch","error","console","figure","value","String","header","help","contentGutterX","contentGutterY","spacing","variant","color","direction","useFlexGap","sx","flexWrap","size","label","length","email","createdAtMs","displayName"],"mappings":"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"}
@@ -23,7 +23,7 @@ import { CardDisplay, useConfirmationContext } from "@aglyn/shared-ui-jsx";
23
23
  import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
24
24
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
25
25
  import { Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField, Typography } from "@mui/material";
26
- import { collection, deleteDoc, doc, limit, orderBy, query, setDoc, updateDoc } from "firebase/firestore";
26
+ import { collection, deleteDoc, doc, getDocs, limit, orderBy, query, setDoc, updateDoc, where } from "firebase/firestore";
27
27
  import { useCallback, useMemo, useState } from "react";
28
28
  import { useFirestore } from "@aglyn/tenant-feature-instance";
29
29
  import { useFirestoreCollection, usePagedCollection, writeGuardedBySeed } from "@aglyn/tenant-feature-instance";
@@ -168,11 +168,24 @@ const day = (dayMs)=>dayMs ? new Date(dayMs).toISOString().slice(0, 10) : '—';
168
168
  if (!resource) return;
169
169
  const checkInDayMs = Date.parse(`${walkIn.checkIn}T00:00:00Z`);
170
170
  const checkOutDayMs = Date.parse(`${walkIn.checkOut}T00:00:00Z`);
171
+ /*
172
+ * Every stay of THIS resource that ends after the walk-in arrives — the
173
+ * same query `reserve.ts` checks a booking against, on the composite it
174
+ * already has (AGL-3321). This used to test the rows on the card's
175
+ * current page, so a stay on the next page did not block the dates and
176
+ * the desk could double-book the room.
177
+ */ const overlapping = await getDocs(query(collection(firestore, 'hosts', hostId, 'reservations'), where('resourceId', '==', walkIn.resourceId), where('checkOutDayMs', '>', checkInDayMs), orderBy('checkOutDayMs'), limit(500))).catch(()=>null);
178
+ if (!overlapping) {
179
+ return void enqueueSnackbar('Could not check those dates — try again', {
180
+ variant: 'warning',
181
+ persist: false
182
+ });
183
+ }
171
184
  // `createdAtMs` carried through because the pending hold lapses on it
172
185
  // (`reservationHoldsDates`). Dropping it made every unpaid `pending` row
173
186
  // block forever, so the front desk was refused a walk-in into a room that
174
187
  // an abandoned online checkout had left empty.
175
- const existing = reservations.filter((item)=>item.resourceId === walkIn.resourceId).map((item)=>({
188
+ const existing = overlapping.docs.map((snapshot)=>snapshot.data()).map((item)=>({
176
189
  checkInDayMs: item.checkInDayMs,
177
190
  checkOutDayMs: item.checkOutDayMs,
178
191
  status: item.status,
@@ -206,7 +219,6 @@ const day = (dayMs)=>dayMs ? new Date(dayMs).toISOString().slice(0, 10) : '—';
206
219
  }, [
207
220
  walkIn,
208
221
  resourceDocs,
209
- reservations,
210
222
  firestore,
211
223
  hostId,
212
224
  enqueueSnackbar