@aglyn/plugins-commerce 1.0.0-beta.277 → 1.0.0-beta.282

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 (212) hide show
  1. package/package.json +17 -14
  2. package/src/lib/components/commerce-console-page.js +33 -1
  3. package/src/lib/components/commerce-console-page.js.map +1 -1
  4. package/src/lib/components/console/payments-settings-card.component.js +1 -1
  5. package/src/lib/components/console/payments-settings-card.component.js.map +1 -1
  6. package/src/lib/components/console/pos/pos-hardware-api.d.ts +122 -0
  7. package/src/lib/components/console/pos/pos-hardware-api.js +90 -0
  8. package/src/lib/components/console/pos/pos-hardware-api.js.map +1 -0
  9. package/src/lib/components/console/pos/pos-reader-splash-api.d.ts +58 -0
  10. package/src/lib/components/console/pos/pos-reader-splash-api.js +101 -0
  11. package/src/lib/components/console/pos/pos-reader-splash-api.js.map +1 -0
  12. package/src/lib/components/console/pos-devices-card.component.js +5 -0
  13. package/src/lib/components/console/pos-devices-card.component.js.map +1 -1
  14. package/src/lib/components/console/pos-hardware-card.component.d.ts +26 -0
  15. package/src/lib/components/console/pos-hardware-card.component.js +456 -0
  16. package/src/lib/components/console/pos-hardware-card.component.js.map +1 -0
  17. package/src/lib/components/console/pos-hardware-orders-staff-page.component.d.ts +14 -0
  18. package/src/lib/components/console/pos-hardware-orders-staff-page.component.js +519 -0
  19. package/src/lib/components/console/pos-hardware-orders-staff-page.component.js.map +1 -0
  20. package/src/lib/components/console/pos-ops/pos-check-in-dialog.component.d.ts +27 -0
  21. package/src/lib/components/console/pos-ops/pos-check-in-dialog.component.js +444 -0
  22. package/src/lib/components/console/pos-ops/pos-check-in-dialog.component.js.map +1 -0
  23. package/src/lib/components/console/pos-ops/pos-check-in-zone.component.d.ts +9 -0
  24. package/src/lib/components/console/pos-ops/pos-check-in-zone.component.js +37 -0
  25. package/src/lib/components/console/pos-ops/pos-check-in-zone.component.js.map +1 -0
  26. package/src/lib/components/console/pos-ops/pos-check-ins-card.component.d.ts +24 -0
  27. package/src/lib/components/console/pos-ops/pos-check-ins-card.component.js +346 -0
  28. package/src/lib/components/console/pos-ops/pos-check-ins-card.component.js.map +1 -0
  29. package/src/lib/components/console/pos-ops/pos-customer-lookup.component.d.ts +5 -1
  30. package/src/lib/components/console/pos-ops/pos-customer-lookup.component.js +83 -4
  31. package/src/lib/components/console/pos-ops/pos-customer-lookup.component.js.map +1 -1
  32. package/src/lib/components/console/pos-ops/pos-day-totals-card.component.d.ts +35 -0
  33. package/src/lib/components/console/pos-ops/pos-day-totals-card.component.js +546 -0
  34. package/src/lib/components/console/pos-ops/pos-day-totals-card.component.js.map +1 -0
  35. package/src/lib/components/console/pos-ops/pos-operations-bar.component.d.ts +2 -1
  36. package/src/lib/components/console/pos-ops/pos-operations-bar.component.js +20 -1
  37. package/src/lib/components/console/pos-ops/pos-operations-bar.component.js.map +1 -1
  38. package/src/lib/components/console/pos-ops/pos-ops-api.d.ts +1 -1
  39. package/src/lib/components/console/pos-ops/pos-ops-api.js.map +1 -1
  40. package/src/lib/components/console/pos-page.component.js +6 -3
  41. package/src/lib/components/console/pos-page.component.js.map +1 -1
  42. package/src/lib/components/console/pos-reader-splash-setting.component.d.ts +22 -0
  43. package/src/lib/components/console/pos-reader-splash-setting.component.js +275 -0
  44. package/src/lib/components/console/pos-reader-splash-setting.component.js.map +1 -0
  45. package/src/lib/components/console/pos-reader-splash-staff-page.component.d.ts +14 -0
  46. package/src/lib/components/console/pos-reader-splash-staff-page.component.js +301 -0
  47. package/src/lib/components/console/pos-reader-splash-staff-page.component.js.map +1 -0
  48. package/src/lib/components/console/pos-zones.d.ts +37 -3
  49. package/src/lib/components/console/pos-zones.js +11 -1
  50. package/src/lib/components/console/pos-zones.js.map +1 -1
  51. package/src/lib/components/console/product-check-in-fields.component.d.ts +20 -0
  52. package/src/lib/components/console/product-check-in-fields.component.js +172 -0
  53. package/src/lib/components/console/product-check-in-fields.component.js.map +1 -0
  54. package/src/lib/components/console/product-editor-dialog.component.js +48 -9
  55. package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
  56. package/src/lib/components/console/product-register-fields.component.d.ts +12 -0
  57. package/src/lib/components/console/product-register-fields.component.js +39 -1
  58. package/src/lib/components/console/product-register-fields.component.js.map +1 -1
  59. package/src/lib/components/console/products-hub-card.component.js +21 -6
  60. package/src/lib/components/console/products-hub-card.component.js.map +1 -1
  61. package/src/lib/components/console/reservations-card.component.js +239 -70
  62. package/src/lib/components/console/reservations-card.component.js.map +1 -1
  63. package/src/lib/components/reservation-widget.js +16 -0
  64. package/src/lib/components/reservation-widget.js.map +1 -1
  65. package/src/lib/constants/operator-alerts.d.ts +6 -0
  66. package/src/lib/constants/operator-alerts.js +20 -1
  67. package/src/lib/constants/operator-alerts.js.map +1 -1
  68. package/src/lib/declarations.console-server.js +7 -0
  69. package/src/lib/declarations.console-server.js.map +1 -1
  70. package/src/lib/declarations.d.ts +26 -0
  71. package/src/lib/declarations.js +34 -0
  72. package/src/lib/declarations.js.map +1 -0
  73. package/src/lib/model/commerce-check-in-product.d.ts +37 -0
  74. package/src/lib/model/commerce-check-in-product.js +49 -0
  75. package/src/lib/model/commerce-check-in-product.js.map +1 -0
  76. package/src/lib/model/commerce-check-in.d.ts +187 -0
  77. package/src/lib/model/commerce-check-in.js +468 -0
  78. package/src/lib/model/commerce-check-in.js.map +1 -0
  79. package/src/lib/model/commerce-pos-day-report.d.ts +209 -0
  80. package/src/lib/model/commerce-pos-day-report.js +572 -0
  81. package/src/lib/model/commerce-pos-day-report.js.map +1 -0
  82. package/src/lib/model/commerce-pos-offline.d.ts +65 -3
  83. package/src/lib/model/commerce-pos-offline.js +104 -7
  84. package/src/lib/model/commerce-pos-offline.js.map +1 -1
  85. package/src/lib/model/commerce-pos-ops.d.ts +3 -0
  86. package/src/lib/model/commerce-pos-ops.js +1 -0
  87. package/src/lib/model/commerce-pos-ops.js.map +1 -1
  88. package/src/lib/model/commerce-pos.d.ts +43 -3
  89. package/src/lib/model/commerce-pos.js +73 -5
  90. package/src/lib/model/commerce-pos.js.map +1 -1
  91. package/src/lib/model/commerce-reservations.d.ts +73 -7
  92. package/src/lib/model/commerce-reservations.js +90 -10
  93. package/src/lib/model/commerce-reservations.js.map +1 -1
  94. package/src/lib/model/commerce.d.ts +40 -0
  95. package/src/lib/model/commerce.js +22 -4
  96. package/src/lib/model/commerce.js.map +1 -1
  97. package/src/lib/model/index.d.ts +1 -0
  98. package/src/lib/model/index.js +1 -0
  99. package/src/lib/model/index.js.map +1 -1
  100. package/src/lib/model/plugin-permissions.d.ts +1 -1
  101. package/src/lib/model/plugin-permissions.js +2 -1
  102. package/src/lib/model/plugin-permissions.js.map +1 -1
  103. package/src/lib/model/pos-hardware.d.ts +288 -0
  104. package/src/lib/model/pos-hardware.js +499 -0
  105. package/src/lib/model/pos-hardware.js.map +1 -0
  106. package/src/lib/model/pos-offline-card-limits.d.ts +20 -0
  107. package/src/lib/model/pos-offline-card-limits.js +28 -0
  108. package/src/lib/model/pos-offline-card-limits.js.map +1 -0
  109. package/src/lib/model/pos-reader-splash.d.ts +114 -0
  110. package/src/lib/model/pos-reader-splash.js +141 -0
  111. package/src/lib/model/pos-reader-splash.js.map +1 -0
  112. package/src/lib/model/product-write.d.ts +6 -0
  113. package/src/lib/plugin-config.d.ts +10 -0
  114. package/src/lib/plugin-config.js +40 -0
  115. package/src/lib/plugin-config.js.map +1 -1
  116. package/src/lib/plugin.js +28 -0
  117. package/src/lib/plugin.js.map +1 -1
  118. package/src/lib/server/cart-checkout.js +3 -1
  119. package/src/lib/server/cart-checkout.js.map +1 -1
  120. package/src/lib/server/cart.js +12 -1
  121. package/src/lib/server/cart.js.map +1 -1
  122. package/src/lib/server/catalog.js +49 -12
  123. package/src/lib/server/catalog.js.map +1 -1
  124. package/src/lib/server/checkout-extras.js +1 -1
  125. package/src/lib/server/checkout-extras.js.map +1 -1
  126. package/src/lib/server/checkout.js +7 -0
  127. package/src/lib/server/checkout.js.map +1 -1
  128. package/src/lib/server/person-eraser.d.ts +8 -1
  129. package/src/lib/server/person-eraser.js +23 -1
  130. package/src/lib/server/person-eraser.js.map +1 -1
  131. package/src/lib/server/pos-check-in.d.ts +49 -0
  132. package/src/lib/server/pos-check-in.js +652 -0
  133. package/src/lib/server/pos-check-in.js.map +1 -0
  134. package/src/lib/server/pos-day-report.d.ts +46 -0
  135. package/src/lib/server/pos-day-report.js +299 -0
  136. package/src/lib/server/pos-day-report.js.map +1 -0
  137. package/src/lib/server/pos-hardware-admin.d.ts +50 -0
  138. package/src/lib/server/pos-hardware-admin.js +366 -0
  139. package/src/lib/server/pos-hardware-admin.js.map +1 -0
  140. package/src/lib/server/pos-hardware-notify.d.ts +27 -0
  141. package/src/lib/server/pos-hardware-notify.js +142 -0
  142. package/src/lib/server/pos-hardware-notify.js.map +1 -0
  143. package/src/lib/server/pos-hardware-user-eraser.d.ts +26 -0
  144. package/src/lib/server/pos-hardware-user-eraser.js +67 -0
  145. package/src/lib/server/pos-hardware-user-eraser.js.map +1 -0
  146. package/src/lib/server/pos-hardware-webhook.d.ts +31 -0
  147. package/src/lib/server/pos-hardware-webhook.js +176 -0
  148. package/src/lib/server/pos-hardware-webhook.js.map +1 -0
  149. package/src/lib/server/pos-hardware.d.ts +58 -0
  150. package/src/lib/server/pos-hardware.js +234 -0
  151. package/src/lib/server/pos-hardware.js.map +1 -0
  152. package/src/lib/server/pos-offline-card-reconcile.d.ts +38 -0
  153. package/src/lib/server/pos-offline-card-reconcile.js +58 -0
  154. package/src/lib/server/pos-offline-card-reconcile.js.map +1 -0
  155. package/src/lib/server/pos-offline-sync.d.ts +32 -0
  156. package/src/lib/server/pos-offline-sync.js +191 -15
  157. package/src/lib/server/pos-offline-sync.js.map +1 -1
  158. package/src/lib/server/pos-ops-routes.d.ts +4 -2
  159. package/src/lib/server/pos-ops-routes.js +14 -2
  160. package/src/lib/server/pos-ops-routes.js.map +1 -1
  161. package/src/lib/server/pos-order.js +39 -51
  162. package/src/lib/server/pos-order.js.map +1 -1
  163. package/src/lib/server/pos-payment.js +8 -4
  164. package/src/lib/server/pos-payment.js.map +1 -1
  165. package/src/lib/server/pos-reader-splash-admin.d.ts +36 -0
  166. package/src/lib/server/pos-reader-splash-admin.js +295 -0
  167. package/src/lib/server/pos-reader-splash-admin.js.map +1 -0
  168. package/src/lib/server/pos-reader-splash-render.d.ts +40 -0
  169. package/src/lib/server/pos-reader-splash-render.js +138 -0
  170. package/src/lib/server/pos-reader-splash-render.js.map +1 -0
  171. package/src/lib/server/pos-reader-splash-server.d.ts +32 -0
  172. package/src/lib/server/pos-reader-splash-server.js +32 -0
  173. package/src/lib/server/pos-reader-splash-server.js.map +1 -0
  174. package/src/lib/server/pos-reader-splash.d.ts +105 -0
  175. package/src/lib/server/pos-reader-splash.js +548 -0
  176. package/src/lib/server/pos-reader-splash.js.map +1 -0
  177. package/src/lib/server/pos-sale.d.ts +2 -1
  178. package/src/lib/server/pos-sale.js +3 -1
  179. package/src/lib/server/pos-sale.js.map +1 -1
  180. package/src/lib/server/pos-terminal.d.ts +67 -4
  181. package/src/lib/server/pos-terminal.js +239 -32
  182. package/src/lib/server/pos-terminal.js.map +1 -1
  183. package/src/lib/server/process-restock.js +4 -0
  184. package/src/lib/server/process-restock.js.map +1 -1
  185. package/src/lib/server/product-card.js +2 -2
  186. package/src/lib/server/product-card.js.map +1 -1
  187. package/src/lib/server/product-catalog.d.ts +2 -0
  188. package/src/lib/server/product-catalog.js +11 -4
  189. package/src/lib/server/product-catalog.js.map +1 -1
  190. package/src/lib/server/product.js +3 -1
  191. package/src/lib/server/product.js.map +1 -1
  192. package/src/lib/server/related.js +8 -1
  193. package/src/lib/server/related.js.map +1 -1
  194. package/src/lib/server/reservation-availability-query.d.ts +35 -0
  195. package/src/lib/server/reservation-availability-query.js +34 -0
  196. package/src/lib/server/reservation-availability-query.js.map +1 -0
  197. package/src/lib/server/reservation-availability.d.ts +21 -0
  198. package/src/lib/server/reservation-availability.js +64 -10
  199. package/src/lib/server/reservation-availability.js.map +1 -1
  200. package/src/lib/server/reservation-walk-in.d.ts +28 -0
  201. package/src/lib/server/reservation-walk-in.js +166 -0
  202. package/src/lib/server/reservation-walk-in.js.map +1 -0
  203. package/src/lib/server/reserve.js +13 -10
  204. package/src/lib/server/reserve.js.map +1 -1
  205. package/src/lib/server/reviews.js +3 -1
  206. package/src/lib/server/reviews.js.map +1 -1
  207. package/src/lib/server/site-page-resolver.js +5 -2
  208. package/src/lib/server/site-page-resolver.js.map +1 -1
  209. package/src/lib/server.js +26 -0
  210. package/src/lib/server.js.map +1 -1
  211. package/src/lib/testing/fake-firestore.js +72 -0
  212. package/src/lib/testing/fake-firestore.js.map +1 -1
@@ -123,6 +123,9 @@ function optionValue(options, pattern) {
123
123
  if (!raw || raw['deletedAt']) return [];
124
124
  const product = CommerceModel.liftLegacyProduct(raw);
125
125
  if (product.status !== 'active' || !str(product.name).trim() || !str(product.slug)) return [];
126
+ // Sold in person only (AGL-4060): in no shopping feed, ad catalog, marketplace
127
+ // or delivery app, unless the reader asked for the store's whole shelf.
128
+ if (!CommerceModel.productSellsOnline(product) && !context.inPersonOnly) return [];
126
129
  // A variant with no price yet is not an offer (AGL-3676): a product listed
127
130
  // as "Price coming soon" is in no shopping feed or marketplace at $0.
128
131
  const variants = ((_product_variants = product.variants) != null ? _product_variants : []).filter((variant)=>variant && str(variant.id) && CommerceModel.variantHasPrice(variant));
@@ -211,7 +214,9 @@ function optionValue(options, pattern) {
211
214
  } : {})) : []
212
215
  }, updatedAtMs ? {
213
216
  updatedAtMs
214
- } : {});
217
+ } : {}, CommerceModel.productSellsOnline(product) ? {} : {
218
+ inPersonOnly: true
219
+ });
215
220
  return offer;
216
221
  });
217
222
  }
@@ -256,7 +261,7 @@ export const productCatalog = {
256
261
  carrierPricedCountries: carrierPricedCountries(shipping, shippingCountries(shipping))
257
262
  };
258
263
  },
259
- async page ({ hostId, cursor, limit }) {
264
+ async page ({ hostId, cursor, limit, inPersonOnly }) {
260
265
  if (!isDocumentId(hostId)) return {
261
266
  offers: [],
262
267
  nextCursor: null
@@ -271,12 +276,14 @@ export const productCatalog = {
271
276
  nextCursor: null
272
277
  };
273
278
  const shipping = store.get('shipping');
274
- const context = {
279
+ const context = _extends({}, inPersonOnly ? {
280
+ inPersonOnly: true
281
+ } : {}, {
275
282
  origin: storeOrigin(host.data()),
276
283
  categories,
277
284
  shipping,
278
285
  countries: shippingCountries(shipping)
279
- };
286
+ });
280
287
  const products = firestore().collection('hosts').doc(hostId).collection('products');
281
288
  const idPath = firebaseAdmin.firestore.FieldPath.documentId();
282
289
  const offers = [];
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/product-catalog.ts"],"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\nimport type {\n CatalogOffer,\n CatalogPage,\n CatalogShippingPrice,\n CatalogStore,\n PluginProductCatalog,\n} from '@aglyn/aglyn/plugin-manager/plugin-product-catalog'\nimport { liveCustomDomain } from '@aglyn/aglyn/app-utils/host-naming'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/tenant-apex'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport * as CommerceModel from '../model'\n\n/**\n * THE STORE'S CATALOG, AS OTHER PLUGINS READ IT (AGL-3637): core's\n * `core.product-catalog` contract, answered from this plugin's products.\n *\n * A shopping-channel feed, an ad catalog and a marketplace sync read every\n * offer the store sells through here and never through `hosts/{id}/products`.\n * What counts as listed, how a variant becomes an offer, what the store's\n * currency is and what shipping costs are this plugin's decisions, made once:\n *\n * - Listed is `status: 'active'` and not soft-deleted. Drafts, archived and\n * deleted products are never offered.\n * - A product with one configuration is one offer under the product's own\n * id — the id the site's analytics events already send — and a product\n * with several is one offer per variant, `{productId}_{variantId}`.\n * - A variant on sale (`compareAtPriceUsd` above `priceUsd`) offers the\n * compare-at price as its price and its own as the sale price.\n * - Shipping is priced per country the store's zones name, for one unit of\n * the offer, through the same resolver checkout charges with; local pickup\n * is not shipping and a `*` zone names no country.\n *\n * Walked in document-id order, so a page boundary is stable while products\n * are added and edited, and a product is never split across two pages.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\ntype DocumentData = FirebaseFirestore.DocumentData\n\nconst MAX_PRODUCTS_PER_READ = 200\n/** Google's ceiling; the longest any channel accepts for every channel's id. */\nconst MAX_OFFER_ID = 50\n\nconst firestore = (): Firestore => firebaseAdmin.app().firestore()\n\nconst str = (value: unknown): string => (typeof value === 'string' ? value : '')\n\n/** Plain text from a description that may hold markup. */\nexport function plainDescription(value: unknown): string {\n return str(value)\n .replace(/<\\s*(br|\\/p|\\/div|\\/li|\\/h[1-6])\\s*\\/?>/gi, '\\n')\n .replace(/<[^>]*>/g, ' ')\n .replace(/&nbsp;/gi, ' ')\n .replace(/&amp;/gi, '&')\n .replace(/&lt;/gi, '<')\n .replace(/&gt;/gi, '>')\n .replace(/&quot;/gi, '\"')\n .replace(/&#39;/gi, \"'\")\n .replace(/[ \\t\\f\\v]+/g, ' ')\n .replace(/\\s*\\n\\s*/g, '\\n')\n .trim()\n}\n\nconst toMinor = (amount: unknown): number => {\n const number = Number(amount)\n return Number.isFinite(number) && number > 0 ? Math.round(number * 100) : 0\n}\n\n/** The origin the store's pages answer on: its live custom domain, else its subdomain. */\nexport function storeOrigin(host: DocumentData | undefined): string | null {\n const domain = liveCustomDomain(host as Parameters<typeof liveCustomDomain>[0])\n if (domain) return `https://${domain}`\n const subdomain = str(host?.['subdomain']).trim()\n return subdomain ? `https://${subdomain}.${TENANT_APEX}` : null\n}\n\n/** An absolute URL for a stored media reference, or undefined. */\nfunction absoluteImage(url: unknown, origin: string | null): string | undefined {\n const value = str(url).trim()\n if (!value) return undefined\n if (/^https?:\\/\\//i.test(value)) return value\n if (value.startsWith('//')) return `https:${value}`\n if (value.startsWith('/') && origin) return `${origin}${value}`\n return undefined\n}\n\n/** A stable offer id within the longest any channel accepts. */\nexport function offerId(productId: string, variantId: string, hasVariants: boolean): string {\n if (!hasVariants) return productId.slice(0, MAX_OFFER_ID)\n const full = `${productId}_${variantId}`\n if (full.length <= MAX_OFFER_ID) return full\n // Deterministic, so the id is the same on every read.\n let hash = 0\n for (const char of full) hash = (Math.imul(hash, 31) + char.charCodeAt(0)) | 0\n const suffix = (hash >>> 0).toString(36)\n return `${productId.slice(0, MAX_OFFER_ID - suffix.length - 1)}_${suffix}`\n}\n\nconst COLOR_OPTION = /^(colou?r|shade|finish)$/i\nconst SIZE_OPTION = /^(size|fit)$/i\n\nfunction optionValue(options: Record<string, string>, pattern: RegExp): string | undefined {\n for (const [name, value] of Object.entries(options)) {\n if (pattern.test(name.trim()) && value) return value\n }\n return undefined\n}\n\n/** The category path `Parent > Child` for a product's first category. */\nfunction categoryPath(\n categoryIds: readonly string[] | undefined,\n categories: ReadonlyMap<string, CommerceModel.ProductCategory>,\n): string | undefined {\n const first = (categoryIds ?? []).find((id) => categories.has(id))\n if (!first) return undefined\n const names: string[] = []\n const seen = new Set<string>()\n let at: string | null | undefined = first\n while (at && !seen.has(at) && categories.has(at) && names.length < 5) {\n seen.add(at)\n const category: CommerceModel.ProductCategory = categories.get(at)!\n if (category.name) names.unshift(category.name)\n at = category.parentId\n }\n return names.length ? names.join(' > ') : undefined\n}\n\n/** The specific countries the store's shipping zones name, in order, upper case. */\nfunction shippingCountries(settings: CommerceModel.ShippingSettings | undefined): string[] {\n const countries = new Set<string>()\n for (const zone of settings?.zones ?? []) {\n for (const code of zone.countries ?? []) {\n const country = String(code ?? '').trim().toUpperCase()\n if (/^[A-Z]{2}$/.test(country)) countries.add(country)\n }\n }\n return [...countries].slice(0, 100)\n}\n\n/** The countries the store's zones price by a live carrier quote, upper case. */\nexport function carrierPricedCountries(\n settings: CommerceModel.ShippingSettings | undefined,\n countries: readonly string[],\n): string[] {\n if (!CommerceModel.hasCarrierRates(settings)) return []\n return countries.filter((country) => CommerceModel.carrierRatesFor(settings, country).length > 0)\n}\n\n/** The packed size of one unit, when every side is known. */\nfunction dimensionsOf(product: CommerceModel.HostProduct): CatalogOffer['dimensionsCm'] {\n const facts = CommerceModel.normalizeProductShippingFacts(product.shipping)\n if (!facts?.lengthCm || !facts.widthCm || !facts.heightCm) return undefined\n return { length: facts.lengthCm, width: facts.widthCm, height: facts.heightCm }\n}\n\n/** What one unit of an offer costs to ship to each country, the cheapest rate each. */\nexport function offerShipping(\n settings: CommerceModel.ShippingSettings | undefined,\n countries: readonly string[],\n unit: { priceMinor: number; weightGrams?: number },\n): CatalogShippingPrice[] {\n const prices: CatalogShippingPrice[] = []\n for (const country of countries) {\n const [cheapest] = CommerceModel.resolveShippingRates(settings, country, {\n subtotalCents: unit.priceMinor,\n totalGrams: unit.weightGrams ?? 0,\n }).filter((rate) => rate.rateId !== CommerceModel.LOCAL_PICKUP_RATE_ID)\n if (cheapest) prices.push({ country, service: cheapest.name, priceMinor: cheapest.amountCents })\n }\n return prices\n}\n\ninterface PageContext {\n origin: string | null\n categories: ReadonlyMap<string, CommerceModel.ProductCategory>\n shipping: CommerceModel.ShippingSettings | undefined\n countries: readonly string[]\n}\n\n/** The offers of one stored product; empty for a product that is not listed. */\nexport function productOffers(productId: string, raw: DocumentData, context: PageContext): CatalogOffer[] {\n if (!raw || raw['deletedAt']) return []\n const product = CommerceModel.liftLegacyProduct(raw as CommerceModel.HostProduct)\n if (product.status !== 'active' || !str(product.name).trim() || !str(product.slug)) return []\n // A variant with no price yet is not an offer (AGL-3676): a product listed\n // as \"Price coming soon\" is in no shopping feed or marketplace at $0.\n const variants = (product.variants ?? []).filter(\n (variant) => variant && str(variant.id) && CommerceModel.variantHasPrice(variant),\n )\n if (!variants.length) return []\n const hasVariants = variants.length > 1\n const facts = CommerceModel.normalizeProductChannelFacts(product.channel)\n const media = [...(product.mediaUrls ?? []), ...(product.imageUrl ? [product.imageUrl] : [])]\n .map((url) => absoluteImage(url, context.origin))\n .filter((url): url is string => Boolean(url))\n const description = plainDescription(product.description)\n const productType = categoryPath(product.categoryIds, context.categories)\n const subscriptionOnly = Boolean(product.subscription) && product.subscriptionOptional !== true\n const updatedAtMs = Number(product.updatedAtMs ?? product.createdAtMs) || undefined\n const dimensionsCm = (product.type ?? 'physical') === 'physical' ? dimensionsOf(product) : undefined\n return variants.map((variant) => {\n const options = Object.fromEntries(\n Object.entries(variant.options ?? {}).filter(([, value]) => typeof value === 'string' && value),\n ) as Record<string, string>\n const choices = (product.options ?? [])\n .map((option) => options[option.name])\n .filter((value): value is string => Boolean(value))\n const ownPrice = toMinor(variant.priceUsd)\n const compareAt = toMinor(variant.compareAtPriceUsd)\n const onSale = compareAt > ownPrice && ownPrice > 0\n const priceMinor = onSale ? compareAt : ownPrice\n const tracked = CommerceModel.stockTrackingApplies(product) && variant.inventory != null\n const quantity = tracked ? Math.max(0, Math.trunc(Number(variant.inventory) || 0)) : null\n const availability: CatalogOffer['availability'] =\n quantity === null || quantity > 0\n ? 'in_stock'\n : product.oversellPolicy === 'backorder'\n ? 'backorder'\n : 'out_of_stock'\n const variantImage = absoluteImage(variant.imageUrl, context.origin)\n const imageUrl = variantImage ?? media[0]\n const weightGrams = Number(variant.weightGrams) > 0 ? Math.round(Number(variant.weightGrams)) : undefined\n const gtin = CommerceModel.normalizeGtin(variant.barcode) || (hasVariants ? '' : facts?.gtin ?? '')\n const sku = str(variant.sku).trim().slice(0, 120)\n const offer: CatalogOffer = {\n id: offerId(productId, variant.id, hasVariants),\n groupId: productId,\n hasVariants,\n productId,\n variantId: variant.id,\n ...(sku ? { sku } : {}),\n productName: product.name.trim(),\n title: choices.length ? `${product.name.trim()} - ${choices.join(' / ')}` : product.name.trim(),\n description,\n path: `/products/${product.slug}`,\n ...(imageUrl ? { imageUrl } : {}),\n additionalImageUrls: media.filter((url) => url !== imageUrl).slice(0, 10),\n priceMinor,\n ...(onSale ? { salePriceMinor: ownPrice } : {}),\n availability,\n quantity,\n kind: product.type ?? 'physical',\n subscriptionOnly,\n ...(facts?.condition ? { condition: facts.condition } : {}),\n ...(facts?.brand ? { brand: facts.brand } : {}),\n ...(gtin.length >= 8 ? { gtin } : {}),\n ...(facts?.mpn ? { mpn: facts.mpn } : {}),\n ...(facts?.googleProductCategory ? { googleProductCategory: facts.googleProductCategory } : {}),\n ...(productType ? { productType } : {}),\n options,\n ...(optionValue(options, COLOR_OPTION) ? { color: optionValue(options, COLOR_OPTION) } : {}),\n ...(optionValue(options, SIZE_OPTION) ? { size: optionValue(options, SIZE_OPTION) } : {}),\n ...(weightGrams ? { weightGrams } : {}),\n ...(dimensionsCm ? { dimensionsCm } : {}),\n shipping:\n (product.type ?? 'physical') === 'physical'\n ? offerShipping(context.shipping, context.countries, {\n priceMinor: onSale ? ownPrice : priceMinor,\n ...(weightGrams ? { weightGrams } : {}),\n })\n : [],\n ...(updatedAtMs ? { updatedAtMs } : {}),\n }\n return offer\n })\n}\n\nasync function readStore(hostId: string) {\n const hostRef = firestore().collection('hosts').doc(hostId)\n const [host, store] = await Promise.all([hostRef.get(), hostRef.collection('settings').doc('store').get()])\n return { host, store }\n}\n\nasync function readCategories(hostId: string): Promise<Map<string, CommerceModel.ProductCategory>> {\n const snapshot = await firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('productCategories')\n .limit(500)\n .get()\n return new Map(\n snapshot.docs.map((doc) => [doc.id, doc.data() as CommerceModel.ProductCategory] as const),\n )\n}\n\nconst isDocumentId = (value: string) =>\n Boolean(value) && value.length <= 200 && !value.includes('/') && !/^__.*__$/.test(value)\n\nexport const productCatalog: PluginProductCatalog = {\n async store(hostId: string): Promise<CatalogStore | null> {\n if (!isDocumentId(hostId)) return null\n const { host, store } = await readStore(hostId)\n if (!host.exists) return null\n const data = host.data() ?? {}\n const settings = store.data() ?? {}\n const origin = storeOrigin(data)\n const shipping = settings['shipping'] as CommerceModel.ShippingSettings | undefined\n const name =\n str(data['displayName']).trim() ||\n (origin ? origin.replace(/^https?:\\/\\//, '') : '') ||\n 'Store'\n return {\n hostId,\n name,\n origin,\n currency: str(settings['currency']).trim().toUpperCase() || 'USD',\n // Every active product's page answers (AGL-3676): through the\n // designated template, else the store's built-in product page.\n productPagesServed: true,\n carrierPricedCountries: carrierPricedCountries(shipping, shippingCountries(shipping)),\n }\n },\n\n async page({ hostId, cursor, limit }): Promise<CatalogPage> {\n if (!isDocumentId(hostId)) return { offers: [], nextCursor: null }\n const want = Math.max(1, Math.min(Math.trunc(limit) || 1, 1000))\n const [{ host, store }, categories] = await Promise.all([readStore(hostId), readCategories(hostId)])\n if (!host.exists) return { offers: [], nextCursor: null }\n const shipping = store.get('shipping') as CommerceModel.ShippingSettings | undefined\n const context: PageContext = {\n origin: storeOrigin(host.data()),\n categories,\n shipping,\n countries: shippingCountries(shipping),\n }\n const products = firestore().collection('hosts').doc(hostId).collection('products')\n const idPath = firebaseAdmin.firestore.FieldPath.documentId()\n const offers: CatalogOffer[] = []\n let after = cursor && isDocumentId(cursor) ? cursor : null\n // Reads products until the page holds `want` offers or the catalog ends.\n // Drafts and archived products are read and skipped, so a store of many\n // drafts may take more than one read for a page.\n for (;;) {\n let query = products.orderBy(idPath).limit(MAX_PRODUCTS_PER_READ)\n if (after) query = query.startAfter(after)\n const snapshot = await query.get()\n for (const doc of snapshot.docs) {\n offers.push(...productOffers(doc.id, doc.data(), context))\n after = doc.id\n if (offers.length >= want) {\n const last = snapshot.docs[snapshot.docs.length - 1]\n // The page is full: continue after this product, unless it was the\n // last of a short read, which is the end of the catalog.\n const ended = doc.id === last.id && snapshot.size < MAX_PRODUCTS_PER_READ\n return { offers, nextCursor: ended ? null : doc.id }\n }\n }\n if (snapshot.size < MAX_PRODUCTS_PER_READ) return { offers, nextCursor: null }\n }\n },\n}\n"],"names":["liveCustomDomain","TENANT_APEX","firebaseAdmin","CommerceModel","MAX_PRODUCTS_PER_READ","MAX_OFFER_ID","firestore","app","str","value","plainDescription","replace","trim","toMinor","amount","number","Number","isFinite","Math","round","storeOrigin","host","domain","subdomain","absoluteImage","url","origin","undefined","test","startsWith","offerId","productId","variantId","hasVariants","slice","full","length","hash","char","imul","charCodeAt","suffix","toString","COLOR_OPTION","SIZE_OPTION","optionValue","options","pattern","name","Object","entries","categoryPath","categoryIds","categories","first","find","id","has","names","seen","Set","at","add","category","get","unshift","parentId","join","shippingCountries","settings","countries","zone","zones","code","country","String","toUpperCase","carrierPricedCountries","hasCarrierRates","filter","carrierRatesFor","dimensionsOf","product","facts","normalizeProductShippingFacts","shipping","lengthCm","widthCm","heightCm","width","height","offerShipping","unit","prices","cheapest","resolveShippingRates","subtotalCents","priceMinor","totalGrams","weightGrams","rate","rateId","LOCAL_PICKUP_RATE_ID","push","service","amountCents","productOffers","raw","context","liftLegacyProduct","status","slug","variants","variant","variantHasPrice","normalizeProductChannelFacts","channel","media","mediaUrls","imageUrl","map","Boolean","description","productType","subscriptionOnly","subscription","subscriptionOptional","updatedAtMs","createdAtMs","dimensionsCm","type","fromEntries","choices","option","ownPrice","priceUsd","compareAt","compareAtPriceUsd","onSale","tracked","stockTrackingApplies","inventory","quantity","max","trunc","availability","oversellPolicy","variantImage","gtin","normalizeGtin","barcode","sku","offer","groupId","productName","title","path","additionalImageUrls","salePriceMinor","kind","condition","brand","mpn","googleProductCategory","color","size","readStore","hostId","hostRef","collection","doc","store","Promise","all","readCategories","snapshot","limit","Map","docs","data","isDocumentId","includes","productCatalog","exists","currency","productPagesServed","page","cursor","offers","nextCursor","want","min","products","idPath","FieldPath","documentId","after","query","orderBy","startAfter","last","ended"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/product-catalog.ts"],"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\nimport type {\n CatalogOffer,\n CatalogPage,\n CatalogShippingPrice,\n CatalogStore,\n PluginProductCatalog,\n} from '@aglyn/aglyn/plugin-manager/plugin-product-catalog'\nimport { liveCustomDomain } from '@aglyn/aglyn/app-utils/host-naming'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/tenant-apex'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport * as CommerceModel from '../model'\n\n/**\n * THE STORE'S CATALOG, AS OTHER PLUGINS READ IT (AGL-3637): core's\n * `core.product-catalog` contract, answered from this plugin's products.\n *\n * A shopping-channel feed, an ad catalog and a marketplace sync read every\n * offer the store sells through here and never through `hosts/{id}/products`.\n * What counts as listed, how a variant becomes an offer, what the store's\n * currency is and what shipping costs are this plugin's decisions, made once:\n *\n * - Listed is `status: 'active'` and not soft-deleted. Drafts, archived and\n * deleted products are never offered. A product sold in person only\n * (`sellOnline: false`, AGL-4060) is offered only to a reader that asks\n * for `inPersonOnly`, a stock sync, and is marked so.\n * - A product with one configuration is one offer under the product's own\n * id — the id the site's analytics events already send — and a product\n * with several is one offer per variant, `{productId}_{variantId}`.\n * - A variant on sale (`compareAtPriceUsd` above `priceUsd`) offers the\n * compare-at price as its price and its own as the sale price.\n * - Shipping is priced per country the store's zones name, for one unit of\n * the offer, through the same resolver checkout charges with; local pickup\n * is not shipping and a `*` zone names no country.\n *\n * Walked in document-id order, so a page boundary is stable while products\n * are added and edited, and a product is never split across two pages.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\ntype DocumentData = FirebaseFirestore.DocumentData\n\nconst MAX_PRODUCTS_PER_READ = 200\n/** Google's ceiling; the longest any channel accepts for every channel's id. */\nconst MAX_OFFER_ID = 50\n\nconst firestore = (): Firestore => firebaseAdmin.app().firestore()\n\nconst str = (value: unknown): string => (typeof value === 'string' ? value : '')\n\n/** Plain text from a description that may hold markup. */\nexport function plainDescription(value: unknown): string {\n return str(value)\n .replace(/<\\s*(br|\\/p|\\/div|\\/li|\\/h[1-6])\\s*\\/?>/gi, '\\n')\n .replace(/<[^>]*>/g, ' ')\n .replace(/&nbsp;/gi, ' ')\n .replace(/&amp;/gi, '&')\n .replace(/&lt;/gi, '<')\n .replace(/&gt;/gi, '>')\n .replace(/&quot;/gi, '\"')\n .replace(/&#39;/gi, \"'\")\n .replace(/[ \\t\\f\\v]+/g, ' ')\n .replace(/\\s*\\n\\s*/g, '\\n')\n .trim()\n}\n\nconst toMinor = (amount: unknown): number => {\n const number = Number(amount)\n return Number.isFinite(number) && number > 0 ? Math.round(number * 100) : 0\n}\n\n/** The origin the store's pages answer on: its live custom domain, else its subdomain. */\nexport function storeOrigin(host: DocumentData | undefined): string | null {\n const domain = liveCustomDomain(host as Parameters<typeof liveCustomDomain>[0])\n if (domain) return `https://${domain}`\n const subdomain = str(host?.['subdomain']).trim()\n return subdomain ? `https://${subdomain}.${TENANT_APEX}` : null\n}\n\n/** An absolute URL for a stored media reference, or undefined. */\nfunction absoluteImage(url: unknown, origin: string | null): string | undefined {\n const value = str(url).trim()\n if (!value) return undefined\n if (/^https?:\\/\\//i.test(value)) return value\n if (value.startsWith('//')) return `https:${value}`\n if (value.startsWith('/') && origin) return `${origin}${value}`\n return undefined\n}\n\n/** A stable offer id within the longest any channel accepts. */\nexport function offerId(productId: string, variantId: string, hasVariants: boolean): string {\n if (!hasVariants) return productId.slice(0, MAX_OFFER_ID)\n const full = `${productId}_${variantId}`\n if (full.length <= MAX_OFFER_ID) return full\n // Deterministic, so the id is the same on every read.\n let hash = 0\n for (const char of full) hash = (Math.imul(hash, 31) + char.charCodeAt(0)) | 0\n const suffix = (hash >>> 0).toString(36)\n return `${productId.slice(0, MAX_OFFER_ID - suffix.length - 1)}_${suffix}`\n}\n\nconst COLOR_OPTION = /^(colou?r|shade|finish)$/i\nconst SIZE_OPTION = /^(size|fit)$/i\n\nfunction optionValue(options: Record<string, string>, pattern: RegExp): string | undefined {\n for (const [name, value] of Object.entries(options)) {\n if (pattern.test(name.trim()) && value) return value\n }\n return undefined\n}\n\n/** The category path `Parent > Child` for a product's first category. */\nfunction categoryPath(\n categoryIds: readonly string[] | undefined,\n categories: ReadonlyMap<string, CommerceModel.ProductCategory>,\n): string | undefined {\n const first = (categoryIds ?? []).find((id) => categories.has(id))\n if (!first) return undefined\n const names: string[] = []\n const seen = new Set<string>()\n let at: string | null | undefined = first\n while (at && !seen.has(at) && categories.has(at) && names.length < 5) {\n seen.add(at)\n const category: CommerceModel.ProductCategory = categories.get(at)!\n if (category.name) names.unshift(category.name)\n at = category.parentId\n }\n return names.length ? names.join(' > ') : undefined\n}\n\n/** The specific countries the store's shipping zones name, in order, upper case. */\nfunction shippingCountries(settings: CommerceModel.ShippingSettings | undefined): string[] {\n const countries = new Set<string>()\n for (const zone of settings?.zones ?? []) {\n for (const code of zone.countries ?? []) {\n const country = String(code ?? '').trim().toUpperCase()\n if (/^[A-Z]{2}$/.test(country)) countries.add(country)\n }\n }\n return [...countries].slice(0, 100)\n}\n\n/** The countries the store's zones price by a live carrier quote, upper case. */\nexport function carrierPricedCountries(\n settings: CommerceModel.ShippingSettings | undefined,\n countries: readonly string[],\n): string[] {\n if (!CommerceModel.hasCarrierRates(settings)) return []\n return countries.filter((country) => CommerceModel.carrierRatesFor(settings, country).length > 0)\n}\n\n/** The packed size of one unit, when every side is known. */\nfunction dimensionsOf(product: CommerceModel.HostProduct): CatalogOffer['dimensionsCm'] {\n const facts = CommerceModel.normalizeProductShippingFacts(product.shipping)\n if (!facts?.lengthCm || !facts.widthCm || !facts.heightCm) return undefined\n return { length: facts.lengthCm, width: facts.widthCm, height: facts.heightCm }\n}\n\n/** What one unit of an offer costs to ship to each country, the cheapest rate each. */\nexport function offerShipping(\n settings: CommerceModel.ShippingSettings | undefined,\n countries: readonly string[],\n unit: { priceMinor: number; weightGrams?: number },\n): CatalogShippingPrice[] {\n const prices: CatalogShippingPrice[] = []\n for (const country of countries) {\n const [cheapest] = CommerceModel.resolveShippingRates(settings, country, {\n subtotalCents: unit.priceMinor,\n totalGrams: unit.weightGrams ?? 0,\n }).filter((rate) => rate.rateId !== CommerceModel.LOCAL_PICKUP_RATE_ID)\n if (cheapest) prices.push({ country, service: cheapest.name, priceMinor: cheapest.amountCents })\n }\n return prices\n}\n\ninterface PageContext {\n /** Offers products sold in person only too (AGL-4060), marked as such. */\n inPersonOnly?: boolean\n origin: string | null\n categories: ReadonlyMap<string, CommerceModel.ProductCategory>\n shipping: CommerceModel.ShippingSettings | undefined\n countries: readonly string[]\n}\n\n/** The offers of one stored product; empty for a product that is not listed. */\nexport function productOffers(productId: string, raw: DocumentData, context: PageContext): CatalogOffer[] {\n if (!raw || raw['deletedAt']) return []\n const product = CommerceModel.liftLegacyProduct(raw as CommerceModel.HostProduct)\n if (product.status !== 'active' || !str(product.name).trim() || !str(product.slug)) return []\n // Sold in person only (AGL-4060): in no shopping feed, ad catalog, marketplace\n // or delivery app, unless the reader asked for the store's whole shelf.\n if (!CommerceModel.productSellsOnline(product) && !context.inPersonOnly) return []\n // A variant with no price yet is not an offer (AGL-3676): a product listed\n // as \"Price coming soon\" is in no shopping feed or marketplace at $0.\n const variants = (product.variants ?? []).filter(\n (variant) => variant && str(variant.id) && CommerceModel.variantHasPrice(variant),\n )\n if (!variants.length) return []\n const hasVariants = variants.length > 1\n const facts = CommerceModel.normalizeProductChannelFacts(product.channel)\n const media = [...(product.mediaUrls ?? []), ...(product.imageUrl ? [product.imageUrl] : [])]\n .map((url) => absoluteImage(url, context.origin))\n .filter((url): url is string => Boolean(url))\n const description = plainDescription(product.description)\n const productType = categoryPath(product.categoryIds, context.categories)\n const subscriptionOnly = Boolean(product.subscription) && product.subscriptionOptional !== true\n const updatedAtMs = Number(product.updatedAtMs ?? product.createdAtMs) || undefined\n const dimensionsCm = (product.type ?? 'physical') === 'physical' ? dimensionsOf(product) : undefined\n return variants.map((variant) => {\n const options = Object.fromEntries(\n Object.entries(variant.options ?? {}).filter(([, value]) => typeof value === 'string' && value),\n ) as Record<string, string>\n const choices = (product.options ?? [])\n .map((option) => options[option.name])\n .filter((value): value is string => Boolean(value))\n const ownPrice = toMinor(variant.priceUsd)\n const compareAt = toMinor(variant.compareAtPriceUsd)\n const onSale = compareAt > ownPrice && ownPrice > 0\n const priceMinor = onSale ? compareAt : ownPrice\n const tracked = CommerceModel.stockTrackingApplies(product) && variant.inventory != null\n const quantity = tracked ? Math.max(0, Math.trunc(Number(variant.inventory) || 0)) : null\n const availability: CatalogOffer['availability'] =\n quantity === null || quantity > 0\n ? 'in_stock'\n : product.oversellPolicy === 'backorder'\n ? 'backorder'\n : 'out_of_stock'\n const variantImage = absoluteImage(variant.imageUrl, context.origin)\n const imageUrl = variantImage ?? media[0]\n const weightGrams = Number(variant.weightGrams) > 0 ? Math.round(Number(variant.weightGrams)) : undefined\n const gtin = CommerceModel.normalizeGtin(variant.barcode) || (hasVariants ? '' : facts?.gtin ?? '')\n const sku = str(variant.sku).trim().slice(0, 120)\n const offer: CatalogOffer = {\n id: offerId(productId, variant.id, hasVariants),\n groupId: productId,\n hasVariants,\n productId,\n variantId: variant.id,\n ...(sku ? { sku } : {}),\n productName: product.name.trim(),\n title: choices.length ? `${product.name.trim()} - ${choices.join(' / ')}` : product.name.trim(),\n description,\n path: `/products/${product.slug}`,\n ...(imageUrl ? { imageUrl } : {}),\n additionalImageUrls: media.filter((url) => url !== imageUrl).slice(0, 10),\n priceMinor,\n ...(onSale ? { salePriceMinor: ownPrice } : {}),\n availability,\n quantity,\n kind: product.type ?? 'physical',\n subscriptionOnly,\n ...(facts?.condition ? { condition: facts.condition } : {}),\n ...(facts?.brand ? { brand: facts.brand } : {}),\n ...(gtin.length >= 8 ? { gtin } : {}),\n ...(facts?.mpn ? { mpn: facts.mpn } : {}),\n ...(facts?.googleProductCategory ? { googleProductCategory: facts.googleProductCategory } : {}),\n ...(productType ? { productType } : {}),\n options,\n ...(optionValue(options, COLOR_OPTION) ? { color: optionValue(options, COLOR_OPTION) } : {}),\n ...(optionValue(options, SIZE_OPTION) ? { size: optionValue(options, SIZE_OPTION) } : {}),\n ...(weightGrams ? { weightGrams } : {}),\n ...(dimensionsCm ? { dimensionsCm } : {}),\n shipping:\n (product.type ?? 'physical') === 'physical'\n ? offerShipping(context.shipping, context.countries, {\n priceMinor: onSale ? ownPrice : priceMinor,\n ...(weightGrams ? { weightGrams } : {}),\n })\n : [],\n ...(updatedAtMs ? { updatedAtMs } : {}),\n ...(CommerceModel.productSellsOnline(product) ? {} : { inPersonOnly: true as const }),\n }\n return offer\n })\n}\n\nasync function readStore(hostId: string) {\n const hostRef = firestore().collection('hosts').doc(hostId)\n const [host, store] = await Promise.all([hostRef.get(), hostRef.collection('settings').doc('store').get()])\n return { host, store }\n}\n\nasync function readCategories(hostId: string): Promise<Map<string, CommerceModel.ProductCategory>> {\n const snapshot = await firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('productCategories')\n .limit(500)\n .get()\n return new Map(\n snapshot.docs.map((doc) => [doc.id, doc.data() as CommerceModel.ProductCategory] as const),\n )\n}\n\nconst isDocumentId = (value: string) =>\n Boolean(value) && value.length <= 200 && !value.includes('/') && !/^__.*__$/.test(value)\n\nexport const productCatalog: PluginProductCatalog = {\n async store(hostId: string): Promise<CatalogStore | null> {\n if (!isDocumentId(hostId)) return null\n const { host, store } = await readStore(hostId)\n if (!host.exists) return null\n const data = host.data() ?? {}\n const settings = store.data() ?? {}\n const origin = storeOrigin(data)\n const shipping = settings['shipping'] as CommerceModel.ShippingSettings | undefined\n const name =\n str(data['displayName']).trim() ||\n (origin ? origin.replace(/^https?:\\/\\//, '') : '') ||\n 'Store'\n return {\n hostId,\n name,\n origin,\n currency: str(settings['currency']).trim().toUpperCase() || 'USD',\n // Every active product's page answers (AGL-3676): through the\n // designated template, else the store's built-in product page.\n productPagesServed: true,\n carrierPricedCountries: carrierPricedCountries(shipping, shippingCountries(shipping)),\n }\n },\n\n async page({ hostId, cursor, limit, inPersonOnly }): Promise<CatalogPage> {\n if (!isDocumentId(hostId)) return { offers: [], nextCursor: null }\n const want = Math.max(1, Math.min(Math.trunc(limit) || 1, 1000))\n const [{ host, store }, categories] = await Promise.all([readStore(hostId), readCategories(hostId)])\n if (!host.exists) return { offers: [], nextCursor: null }\n const shipping = store.get('shipping') as CommerceModel.ShippingSettings | undefined\n const context: PageContext = {\n ...(inPersonOnly ? { inPersonOnly: true } : {}),\n origin: storeOrigin(host.data()),\n categories,\n shipping,\n countries: shippingCountries(shipping),\n }\n const products = firestore().collection('hosts').doc(hostId).collection('products')\n const idPath = firebaseAdmin.firestore.FieldPath.documentId()\n const offers: CatalogOffer[] = []\n let after = cursor && isDocumentId(cursor) ? cursor : null\n // Reads products until the page holds `want` offers or the catalog ends.\n // Drafts and archived products are read and skipped, so a store of many\n // drafts may take more than one read for a page.\n for (;;) {\n let query = products.orderBy(idPath).limit(MAX_PRODUCTS_PER_READ)\n if (after) query = query.startAfter(after)\n const snapshot = await query.get()\n for (const doc of snapshot.docs) {\n offers.push(...productOffers(doc.id, doc.data(), context))\n after = doc.id\n if (offers.length >= want) {\n const last = snapshot.docs[snapshot.docs.length - 1]\n // The page is full: continue after this product, unless it was the\n // last of a short read, which is the end of the catalog.\n const ended = doc.id === last.id && snapshot.size < MAX_PRODUCTS_PER_READ\n return { offers, nextCursor: ended ? null : doc.id }\n }\n }\n if (snapshot.size < MAX_PRODUCTS_PER_READ) return { offers, nextCursor: null }\n }\n },\n}\n"],"names":["liveCustomDomain","TENANT_APEX","firebaseAdmin","CommerceModel","MAX_PRODUCTS_PER_READ","MAX_OFFER_ID","firestore","app","str","value","plainDescription","replace","trim","toMinor","amount","number","Number","isFinite","Math","round","storeOrigin","host","domain","subdomain","absoluteImage","url","origin","undefined","test","startsWith","offerId","productId","variantId","hasVariants","slice","full","length","hash","char","imul","charCodeAt","suffix","toString","COLOR_OPTION","SIZE_OPTION","optionValue","options","pattern","name","Object","entries","categoryPath","categoryIds","categories","first","find","id","has","names","seen","Set","at","add","category","get","unshift","parentId","join","shippingCountries","settings","countries","zone","zones","code","country","String","toUpperCase","carrierPricedCountries","hasCarrierRates","filter","carrierRatesFor","dimensionsOf","product","facts","normalizeProductShippingFacts","shipping","lengthCm","widthCm","heightCm","width","height","offerShipping","unit","prices","cheapest","resolveShippingRates","subtotalCents","priceMinor","totalGrams","weightGrams","rate","rateId","LOCAL_PICKUP_RATE_ID","push","service","amountCents","productOffers","raw","context","liftLegacyProduct","status","slug","productSellsOnline","inPersonOnly","variants","variant","variantHasPrice","normalizeProductChannelFacts","channel","media","mediaUrls","imageUrl","map","Boolean","description","productType","subscriptionOnly","subscription","subscriptionOptional","updatedAtMs","createdAtMs","dimensionsCm","type","fromEntries","choices","option","ownPrice","priceUsd","compareAt","compareAtPriceUsd","onSale","tracked","stockTrackingApplies","inventory","quantity","max","trunc","availability","oversellPolicy","variantImage","gtin","normalizeGtin","barcode","sku","offer","groupId","productName","title","path","additionalImageUrls","salePriceMinor","kind","condition","brand","mpn","googleProductCategory","color","size","readStore","hostId","hostRef","collection","doc","store","Promise","all","readCategories","snapshot","limit","Map","docs","data","isDocumentId","includes","productCatalog","exists","currency","productPagesServed","page","cursor","offers","nextCursor","want","min","products","idPath","FieldPath","documentId","after","query","orderBy","startAfter","last","ended"],"mappings":"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"}
@@ -83,7 +83,9 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
83
83
  const snapshot = await firestore.collection('hosts').doc(hostId).collection('products').where('slug', '==', slug).limit(1).get();
84
84
  const docSnapshot = snapshot.docs[0];
85
85
  const raw = docSnapshot == null ? void 0 : docSnapshot.data();
86
- if (!raw || raw.deletedAt || raw.status !== 'active') {
86
+ // A product sold in person only (AGL-4060) has no page online: the same
87
+ // 404 as a draft, so its address says nothing about it.
88
+ if (!CommerceModel.productListedOnline(raw)) {
87
89
  return res.status(404).json({
88
90
  error: 'Unknown product'
89
91
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/product.ts"],"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\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\n\nexport interface PublicProductDetail {\n id: string\n name: string\n slug: string\n description?: string\n type: CommerceModel.ProductType\n mediaUrls: string[]\n options: CommerceModel.ProductOption[]\n variants: Array<{\n id: string\n options?: Record<string, string>\n /**\n * `null` for a variant listed before it has a price (AGL-3676): the page\n * says \"Price coming soon\" and offers nothing to buy, as no sale door\n * would sell it.\n */\n priceUsd: number | null\n compareAtPriceUsd?: number\n soldOut: boolean\n imageUrl?: string\n /**\n * Merchant's stock-keeping unit (AGL-686). Carried into the public DTO\n * so the server can put `sku` on the Product structured data — Google\n * Merchant treats it as required for a product offer, and without it a\n * listing is simply ineligible for rich results rather than warned about.\n * It is already public information: it prints on receipts and invoices.\n */\n sku?: string\n }>\n tags?: string[]\n /** Recurring billing (AGL-303) so the PDP frames the price. */\n subscription?: { interval: 'month' | 'year'; trialDays?: number }\n /** Buyer may choose one-time OR subscribe (AGL-545). */\n subscriptionOptional?: boolean\n}\n\n/**\n * Map a lifted product onto the public PDP DTO.\n *\n * Extracted (AGL-659) so the site-page resolver can put the SAME payload\n * into page props as the API returns — the PDP block used to fetch this in\n * an effect, which meant the server rendered a skeleton and crawlers saw a\n * product page with no product in it. One definition keeps the server-\n * rendered and client-fetched shapes from drifting.\n *\n * Display-only by design: stock is a `soldOut` boolean rather than a raw\n * count, and the charge still prices server-side.\n */\nexport function toPublicProductDetail(\n id: string,\n product: ReturnType<typeof CommerceModel.liftLegacyProduct>,\n): PublicProductDetail {\n return {\n id,\n name: product.name,\n slug: product.slug,\n ...(product.description ? { description: product.description } : {}),\n type: product.type,\n mediaUrls:\n product.mediaUrls ?? (product.imageUrl ? [product.imageUrl] : []),\n options: product.options ?? [],\n variants: product.variants.map((variant) => ({\n id: variant.id,\n ...(variant.options ? { options: variant.options } : {}),\n priceUsd: CommerceModel.variantHasPrice(variant) ? variant.priceUsd : null,\n ...(variant.compareAtPriceUsd\n ? { compareAtPriceUsd: variant.compareAtPriceUsd }\n : {}),\n soldOut:\n variant.inventory != null &&\n Number(variant.inventory) <= 0 &&\n product.oversellPolicy !== 'backorder',\n ...(variant.imageUrl ? { imageUrl: variant.imageUrl } : {}),\n ...(variant.sku ? { sku: variant.sku } : {}),\n })),\n ...(product.tags?.length ? { tags: product.tags } : {}),\n ...(product.subscription ? { subscription: product.subscription } : {}),\n ...(product.subscription && product.subscriptionOptional\n ? { subscriptionOptional: true }\n : {}),\n }\n}\n\n/**\n * Public product detail by slug (AGL-292): everything the PDP block\n * needs — variants with per-option prices and stock booleans, never raw\n * counts. Display-only; the charge still prices server-side.\n */\nexport const productHandler: PluginApiHandler = async (req, res) => {\n const hostId = String(req.query.hostId ?? '')\n const slug = String(req.query.slug ?? '')\n if (!hostId || !slug) {\n return res.status(400).json({ error: 'Missing hostId or slug' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const snapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('products')\n .where('slug', '==', slug)\n .limit(1)\n .get()\n const docSnapshot = snapshot.docs[0]\n const raw = docSnapshot?.data() as any\n if (!raw || raw.deletedAt || raw.status !== 'active') {\n return res.status(404).json({ error: 'Unknown product' })\n }\n const product = CommerceModel.liftLegacyProduct(raw)\n const detail = toPublicProductDetail(docSnapshot.id, product)\n res.setHeader(\n 'Cache-Control',\n 'public, s-maxage=60, stale-while-revalidate=300',\n )\n return res.status(200).json({ product: detail })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Product unavailable' })\n }\n}\n"],"names":["CommerceModel","firebaseAdmin","toPublicProductDetail","id","product","name","slug","description","type","mediaUrls","imageUrl","options","variants","map","variant","priceUsd","variantHasPrice","compareAtPriceUsd","soldOut","inventory","Number","oversellPolicy","sku","tags","length","subscription","subscriptionOptional","productHandler","req","res","hostId","String","query","status","json","error","firestore","app","snapshot","collection","doc","where","limit","get","docSnapshot","docs","raw","data","deletedAt","liftLegacyProduct","detail","setHeader","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/product.ts"],"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\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\n\nexport interface PublicProductDetail {\n id: string\n name: string\n slug: string\n description?: string\n type: CommerceModel.ProductType\n mediaUrls: string[]\n options: CommerceModel.ProductOption[]\n variants: Array<{\n id: string\n options?: Record<string, string>\n /**\n * `null` for a variant listed before it has a price (AGL-3676): the page\n * says \"Price coming soon\" and offers nothing to buy, as no sale door\n * would sell it.\n */\n priceUsd: number | null\n compareAtPriceUsd?: number\n soldOut: boolean\n imageUrl?: string\n /**\n * Merchant's stock-keeping unit (AGL-686). Carried into the public DTO\n * so the server can put `sku` on the Product structured data — Google\n * Merchant treats it as required for a product offer, and without it a\n * listing is simply ineligible for rich results rather than warned about.\n * It is already public information: it prints on receipts and invoices.\n */\n sku?: string\n }>\n tags?: string[]\n /** Recurring billing (AGL-303) so the PDP frames the price. */\n subscription?: { interval: 'month' | 'year'; trialDays?: number }\n /** Buyer may choose one-time OR subscribe (AGL-545). */\n subscriptionOptional?: boolean\n}\n\n/**\n * Map a lifted product onto the public PDP DTO.\n *\n * Extracted (AGL-659) so the site-page resolver can put the SAME payload\n * into page props as the API returns — the PDP block used to fetch this in\n * an effect, which meant the server rendered a skeleton and crawlers saw a\n * product page with no product in it. One definition keeps the server-\n * rendered and client-fetched shapes from drifting.\n *\n * Display-only by design: stock is a `soldOut` boolean rather than a raw\n * count, and the charge still prices server-side.\n */\nexport function toPublicProductDetail(\n id: string,\n product: ReturnType<typeof CommerceModel.liftLegacyProduct>,\n): PublicProductDetail {\n return {\n id,\n name: product.name,\n slug: product.slug,\n ...(product.description ? { description: product.description } : {}),\n type: product.type,\n mediaUrls:\n product.mediaUrls ?? (product.imageUrl ? [product.imageUrl] : []),\n options: product.options ?? [],\n variants: product.variants.map((variant) => ({\n id: variant.id,\n ...(variant.options ? { options: variant.options } : {}),\n priceUsd: CommerceModel.variantHasPrice(variant) ? variant.priceUsd : null,\n ...(variant.compareAtPriceUsd\n ? { compareAtPriceUsd: variant.compareAtPriceUsd }\n : {}),\n soldOut:\n variant.inventory != null &&\n Number(variant.inventory) <= 0 &&\n product.oversellPolicy !== 'backorder',\n ...(variant.imageUrl ? { imageUrl: variant.imageUrl } : {}),\n ...(variant.sku ? { sku: variant.sku } : {}),\n })),\n ...(product.tags?.length ? { tags: product.tags } : {}),\n ...(product.subscription ? { subscription: product.subscription } : {}),\n ...(product.subscription && product.subscriptionOptional\n ? { subscriptionOptional: true }\n : {}),\n }\n}\n\n/**\n * Public product detail by slug (AGL-292): everything the PDP block\n * needs — variants with per-option prices and stock booleans, never raw\n * counts. Display-only; the charge still prices server-side.\n */\nexport const productHandler: PluginApiHandler = async (req, res) => {\n const hostId = String(req.query.hostId ?? '')\n const slug = String(req.query.slug ?? '')\n if (!hostId || !slug) {\n return res.status(400).json({ error: 'Missing hostId or slug' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const snapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('products')\n .where('slug', '==', slug)\n .limit(1)\n .get()\n const docSnapshot = snapshot.docs[0]\n const raw = docSnapshot?.data() as any\n // A product sold in person only (AGL-4060) has no page online: the same\n // 404 as a draft, so its address says nothing about it.\n if (!CommerceModel.productListedOnline(raw)) {\n return res.status(404).json({ error: 'Unknown product' })\n }\n const product = CommerceModel.liftLegacyProduct(raw)\n const detail = toPublicProductDetail(docSnapshot.id, product)\n res.setHeader(\n 'Cache-Control',\n 'public, s-maxage=60, stale-while-revalidate=300',\n )\n return res.status(200).json({ product: detail })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Product unavailable' })\n }\n}\n"],"names":["CommerceModel","firebaseAdmin","toPublicProductDetail","id","product","name","slug","description","type","mediaUrls","imageUrl","options","variants","map","variant","priceUsd","variantHasPrice","compareAtPriceUsd","soldOut","inventory","Number","oversellPolicy","sku","tags","length","subscription","subscriptionOptional","productHandler","req","res","hostId","String","query","status","json","error","firestore","app","snapshot","collection","doc","where","limit","get","docSnapshot","docs","raw","data","productListedOnline","liftLegacyProduct","detail","setHeader","console"],"mappings":"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"}
@@ -35,6 +35,13 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
35
35
  const firestore = firebaseAdmin.app().firestore();
36
36
  const hostRef = firestore.collection('hosts').doc(hostId);
37
37
  const productSnapshot = await hostRef.collection('products').doc(productId).get();
38
+ // A product sold in person only (AGL-4060) has no page to show neighbors
39
+ // on, and naming them would say what it is bought with.
40
+ if (productSnapshot.exists && !CommerceModel.productSellsOnline(productSnapshot.data())) {
41
+ return res.status(200).json({
42
+ productIds: []
43
+ });
44
+ }
38
45
  const product = CommerceModel.liftLegacyProduct((_productSnapshot_data = productSnapshot.data()) != null ? _productSnapshot_data : {});
39
46
  let ids = ((_product_relatedProductIds = product.relatedProductIds) != null ? _product_relatedProductIds : []).slice(0, 8);
40
47
  if (ids.length === 0) {
@@ -77,7 +84,7 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
77
84
  var _candidate_tags, _candidate_categoryIds;
78
85
  if (docSnapshot.id === productId) return false;
79
86
  const candidate = docSnapshot.data();
80
- if (candidate.deletedAt || candidate.status !== 'active') return false;
87
+ if (!CommerceModel.productListedOnline(candidate)) return false;
81
88
  const sharedTag = ((_candidate_tags = candidate.tags) != null ? _candidate_tags : []).some((tag)=>{
82
89
  var _product_tags;
83
90
  return ((_product_tags = product.tags) != null ? _product_tags : []).includes(tag);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/related.ts"],"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\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\n\n/**\n * Related products (AGL-325), best source first: the product's manual\n * list, then co-purchase pairs mined from recent orders (\"frequently\n * bought together\"), then tag/category neighbors. Returns catalog ids —\n * the block renders them through the ids-filtered catalog API.\n */\nexport const relatedHandler: PluginApiHandler = async (req, res) => {\n const hostId = String(req.query.hostId ?? '')\n const productId = String(req.query.productId ?? '')\n if (!hostId || !productId) {\n return res.status(400).json({ error: 'Missing hostId or productId' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const productSnapshot = await hostRef\n .collection('products')\n .doc(productId)\n .get()\n const product = CommerceModel.liftLegacyProduct(\n (productSnapshot.data() as any) ?? {},\n )\n\n let ids = (product.relatedProductIds ?? []).slice(0, 8)\n\n if (ids.length === 0) {\n // Co-purchase pairs mined from a bounded sample of multi-line orders.\n //\n // Deliberately NOT \"recent\", which is what this said before: there is\n // no orderable recency field to ask for. Orders carry their timestamp\n // as `createdAtMs` on some documents and `createdAt: {seconds}` on\n // others — `orderCreatedAtMs` exists precisely to paper over that — and\n // an `orderBy` on either one DROPS every order written in the other\n // shape. Ordering by document id instead makes the sample the SAME\n // hundred orders on every call rather than an arbitrary hundred the\n // backend chose, so a product's \"bought together\" list stops changing\n // between requests for no reason. Genuine recency needs one timestamp\n // field across the collection first.\n const orders = await hostRef\n .collection('orders')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(100)\n .get()\n const counts = new Map<string, number>()\n for (const docSnapshot of orders.docs) {\n const order = CommerceModel.liftLegacyOrder(docSnapshot.data() as any)\n const lineIds = (order.lineItems ?? []).map((line) => line.productId)\n if (!lineIds.includes(productId)) continue\n for (const id of lineIds) {\n if (id === productId) continue\n counts.set(id, (counts.get(id) ?? 0) + 1)\n }\n }\n ids = [...counts.entries()]\n .sort((a, b) => b[1] - a[1])\n .slice(0, 8)\n .map(([id]) => id)\n }\n\n if (ids.length === 0 && (product.tags?.length || product.categoryIds?.length)) {\n // Tag/category neighbors, from the same bounded deterministic sample:\n // an unordered `limit` let a catalog larger than 300 answer one request\n // from one arbitrary slice and the next from another, so a product's\n // neighbours appeared and disappeared without anything changing.\n const catalog = await hostRef\n .collection('products')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(300)\n .get()\n ids = catalog.docs\n .filter((docSnapshot) => {\n if (docSnapshot.id === productId) return false\n const candidate = docSnapshot.data() as any\n if (candidate.deletedAt || candidate.status !== 'active') return false\n const sharedTag = (candidate.tags ?? []).some((tag: string) =>\n (product.tags ?? []).includes(tag),\n )\n const sharedCategory = (candidate.categoryIds ?? []).some(\n (id: string) => (product.categoryIds ?? []).includes(id),\n )\n return sharedTag || sharedCategory\n })\n .slice(0, 8)\n .map((docSnapshot) => docSnapshot.id)\n }\n\n res.setHeader(\n 'Cache-Control',\n 'public, s-maxage=300, stale-while-revalidate=600',\n )\n return res.status(200).json({ productIds: ids })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Related unavailable' })\n }\n}\n"],"names":["CommerceModel","firebaseAdmin","relatedHandler","req","res","hostId","String","query","productId","status","json","error","productSnapshot","product","firestore","app","hostRef","collection","doc","get","liftLegacyProduct","data","ids","relatedProductIds","slice","length","orders","orderBy","FieldPath","documentId","limit","counts","Map","docSnapshot","docs","order","liftLegacyOrder","lineIds","lineItems","map","line","includes","id","set","entries","sort","a","b","tags","categoryIds","catalog","filter","candidate","deletedAt","sharedTag","some","tag","sharedCategory","setHeader","productIds","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/related.ts"],"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\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\n\n/**\n * Related products (AGL-325), best source first: the product's manual\n * list, then co-purchase pairs mined from recent orders (\"frequently\n * bought together\"), then tag/category neighbors. Returns catalog ids —\n * the block renders them through the ids-filtered catalog API.\n */\nexport const relatedHandler: PluginApiHandler = async (req, res) => {\n const hostId = String(req.query.hostId ?? '')\n const productId = String(req.query.productId ?? '')\n if (!hostId || !productId) {\n return res.status(400).json({ error: 'Missing hostId or productId' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const productSnapshot = await hostRef\n .collection('products')\n .doc(productId)\n .get()\n // A product sold in person only (AGL-4060) has no page to show neighbors\n // on, and naming them would say what it is bought with.\n if (productSnapshot.exists && !CommerceModel.productSellsOnline(productSnapshot.data())) {\n return res.status(200).json({ productIds: [] })\n }\n const product = CommerceModel.liftLegacyProduct(\n (productSnapshot.data() as any) ?? {},\n )\n\n let ids = (product.relatedProductIds ?? []).slice(0, 8)\n\n if (ids.length === 0) {\n // Co-purchase pairs mined from a bounded sample of multi-line orders.\n //\n // Deliberately NOT \"recent\", which is what this said before: there is\n // no orderable recency field to ask for. Orders carry their timestamp\n // as `createdAtMs` on some documents and `createdAt: {seconds}` on\n // others — `orderCreatedAtMs` exists precisely to paper over that — and\n // an `orderBy` on either one DROPS every order written in the other\n // shape. Ordering by document id instead makes the sample the SAME\n // hundred orders on every call rather than an arbitrary hundred the\n // backend chose, so a product's \"bought together\" list stops changing\n // between requests for no reason. Genuine recency needs one timestamp\n // field across the collection first.\n const orders = await hostRef\n .collection('orders')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(100)\n .get()\n const counts = new Map<string, number>()\n for (const docSnapshot of orders.docs) {\n const order = CommerceModel.liftLegacyOrder(docSnapshot.data() as any)\n const lineIds = (order.lineItems ?? []).map((line) => line.productId)\n if (!lineIds.includes(productId)) continue\n for (const id of lineIds) {\n if (id === productId) continue\n counts.set(id, (counts.get(id) ?? 0) + 1)\n }\n }\n ids = [...counts.entries()]\n .sort((a, b) => b[1] - a[1])\n .slice(0, 8)\n .map(([id]) => id)\n }\n\n if (ids.length === 0 && (product.tags?.length || product.categoryIds?.length)) {\n // Tag/category neighbors, from the same bounded deterministic sample:\n // an unordered `limit` let a catalog larger than 300 answer one request\n // from one arbitrary slice and the next from another, so a product's\n // neighbours appeared and disappeared without anything changing.\n const catalog = await hostRef\n .collection('products')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(300)\n .get()\n ids = catalog.docs\n .filter((docSnapshot) => {\n if (docSnapshot.id === productId) return false\n const candidate = docSnapshot.data() as any\n if (!CommerceModel.productListedOnline(candidate)) return false\n const sharedTag = (candidate.tags ?? []).some((tag: string) =>\n (product.tags ?? []).includes(tag),\n )\n const sharedCategory = (candidate.categoryIds ?? []).some(\n (id: string) => (product.categoryIds ?? []).includes(id),\n )\n return sharedTag || sharedCategory\n })\n .slice(0, 8)\n .map((docSnapshot) => docSnapshot.id)\n }\n\n res.setHeader(\n 'Cache-Control',\n 'public, s-maxage=300, stale-while-revalidate=600',\n )\n return res.status(200).json({ productIds: ids })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Related unavailable' })\n }\n}\n"],"names":["CommerceModel","firebaseAdmin","relatedHandler","req","res","hostId","String","query","productId","status","json","error","productSnapshot","product","firestore","app","hostRef","collection","doc","get","exists","productSellsOnline","data","productIds","liftLegacyProduct","ids","relatedProductIds","slice","length","orders","orderBy","FieldPath","documentId","limit","counts","Map","docSnapshot","docs","order","liftLegacyOrder","lineIds","lineItems","map","line","includes","id","set","entries","sort","a","b","tags","categoryIds","catalog","filter","candidate","productListedOnline","sharedTag","some","tag","sharedCategory","setHeader","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAID,YAAYA,mBAAmB,oBAAU;AACzC,SAASC,aAAa,QAAQ,2BAA0B;AAExD;;;;;CAKC,GACD,OAAO,MAAMC,iBAAmC,OAAOC,KAAKC;QACpCD,mBACGA;IADzB,MAAME,SAASC,QAAOH,oBAAAA,IAAII,KAAK,CAACF,MAAM,YAAhBF,oBAAoB;IAC1C,MAAMK,YAAYF,QAAOH,uBAAAA,IAAII,KAAK,CAACC,SAAS,YAAnBL,uBAAuB;IAChD,IAAI,CAACE,UAAU,CAACG,WAAW;QACzB,OAAOJ,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA8B;IACrE;IACA,IAAI;YAaCC,uBAGQC;YAoCcA,eAAwBA;QAnDjD,MAAMC,YAAYb,cAAcc,GAAG,GAAGD,SAAS;QAC/C,MAAME,UAAUF,UAAUG,UAAU,CAAC,SAASC,GAAG,CAACb;QAClD,MAAMO,kBAAkB,MAAMI,QAC3BC,UAAU,CAAC,YACXC,GAAG,CAACV,WACJW,GAAG;QACN,yEAAyE;QACzE,wDAAwD;QACxD,IAAIP,gBAAgBQ,MAAM,IAAI,CAACpB,cAAcqB,kBAAkB,CAACT,gBAAgBU,IAAI,KAAK;YACvF,OAAOlB,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEa,YAAY,EAAE;YAAC;QAC/C;QACA,MAAMV,UAAUb,cAAcwB,iBAAiB,EAC5CZ,wBAAAA,gBAAgBU,IAAI,cAApBV,wBAAkC,CAAC;QAGtC,IAAIa,MAAM,EAACZ,6BAAAA,QAAQa,iBAAiB,YAAzBb,6BAA6B,EAAE,EAAEc,KAAK,CAAC,GAAG;QAErD,IAAIF,IAAIG,MAAM,KAAK,GAAG;YACpB,sEAAsE;YACtE,EAAE;YACF,sEAAsE;YACtE,sEAAsE;YACtE,mEAAmE;YACnE,wEAAwE;YACxE,oEAAoE;YACpE,mEAAmE;YACnE,oEAAoE;YACpE,sEAAsE;YACtE,sEAAsE;YACtE,qCAAqC;YACrC,MAAMC,SAAS,MAAMb,QAClBC,UAAU,CAAC,UACXa,OAAO,CAAC7B,cAAca,SAAS,CAACiB,SAAS,CAACC,UAAU,IACpDC,KAAK,CAAC,KACNd,GAAG;YACN,MAAMe,SAAS,IAAIC;YACnB,KAAK,MAAMC,eAAeP,OAAOQ,IAAI,CAAE;oBAEpBC;gBADjB,MAAMA,QAAQtC,cAAcuC,eAAe,CAACH,YAAYd,IAAI;gBAC5D,MAAMkB,UAAU,EAACF,mBAAAA,MAAMG,SAAS,YAAfH,mBAAmB,EAAE,EAAEI,GAAG,CAAC,CAACC,OAASA,KAAKnC,SAAS;gBACpE,IAAI,CAACgC,QAAQI,QAAQ,CAACpC,YAAY;gBAClC,KAAK,MAAMqC,MAAML,QAAS;wBAERN;oBADhB,IAAIW,OAAOrC,WAAW;oBACtB0B,OAAOY,GAAG,CAACD,IAAI,EAACX,cAAAA,OAAOf,GAAG,CAAC0B,eAAXX,cAAkB,KAAK;gBACzC;YACF;YACAT,MAAM;mBAAIS,OAAOa,OAAO;aAAG,CACxBC,IAAI,CAAC,CAACC,GAAGC,IAAMA,CAAC,CAAC,EAAE,GAAGD,CAAC,CAAC,EAAE,EAC1BtB,KAAK,CAAC,GAAG,GACTe,GAAG,CAAC,CAAC,CAACG,GAAG,GAAKA;QACnB;QAEA,IAAIpB,IAAIG,MAAM,KAAK,KAAMf,CAAAA,EAAAA,gBAAAA,QAAQsC,IAAI,qBAAZtC,cAAce,MAAM,OAAIf,uBAAAA,QAAQuC,WAAW,qBAAnBvC,qBAAqBe,MAAM,CAAD,GAAI;YAC7E,sEAAsE;YACtE,wEAAwE;YACxE,qEAAqE;YACrE,iEAAiE;YACjE,MAAMyB,UAAU,MAAMrC,QACnBC,UAAU,CAAC,YACXa,OAAO,CAAC7B,cAAca,SAAS,CAACiB,SAAS,CAACC,UAAU,IACpDC,KAAK,CAAC,KACNd,GAAG;YACNM,MAAM4B,QAAQhB,IAAI,CACfiB,MAAM,CAAC,CAAClB;oBAIYmB,iBAGKA;gBANxB,IAAInB,YAAYS,EAAE,KAAKrC,WAAW,OAAO;gBACzC,MAAM+C,YAAYnB,YAAYd,IAAI;gBAClC,IAAI,CAACtB,cAAcwD,mBAAmB,CAACD,YAAY,OAAO;gBAC1D,MAAME,YAAY,EAACF,kBAAAA,UAAUJ,IAAI,YAAdI,kBAAkB,EAAE,EAAEG,IAAI,CAAC,CAACC;wBAC5C9C;2BAAD,EAACA,gBAAAA,QAAQsC,IAAI,YAAZtC,gBAAgB,EAAE,EAAE+B,QAAQ,CAACe;;gBAEhC,MAAMC,iBAAiB,EAACL,yBAAAA,UAAUH,WAAW,YAArBG,yBAAyB,EAAE,EAAEG,IAAI,CACvD,CAACb;wBAAgBhC;2BAAD,EAACA,uBAAAA,QAAQuC,WAAW,YAAnBvC,uBAAuB,EAAE,EAAE+B,QAAQ,CAACC;;gBAEvD,OAAOY,aAAaG;YACtB,GACCjC,KAAK,CAAC,GAAG,GACTe,GAAG,CAAC,CAACN,cAAgBA,YAAYS,EAAE;QACxC;QAEAzC,IAAIyD,SAAS,CACX,iBACA;QAEF,OAAOzD,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEa,YAAYE;QAAI;IAChD,EAAE,OAAOd,OAAO;QACdmD,QAAQnD,KAAK,CAACA;QACd,OAAOP,IAAIK,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAsB;IAC7D;AACF,EAAC"}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type * as CommerceModel from '../model';
18
+ /** The query's minimal shape, which the Admin SDK and spec doubles share. */
19
+ interface QueryLike {
20
+ where(field: string, op: string, value: unknown): QueryLike;
21
+ orderBy(field: string): QueryLike;
22
+ limit(count: number): QueryLike;
23
+ }
24
+ /** Every stay of one resource that ends after `checkInDayMs`, nearest first. */
25
+ export declare function reservationAvailabilityQuery<Q extends QueryLike>(reservations: Q, resourceId: string, checkInDayMs: number): Q;
26
+ /**
27
+ * The fields `isRangeAvailable` reads, off the query's documents.
28
+ * `createdAtMs` matters: a `pending` hold lapses on it.
29
+ */
30
+ export declare function readLiveReservations(snapshot: {
31
+ docs: Array<{
32
+ get(field: string): unknown;
33
+ }>;
34
+ }): CommerceModel.HostReservation[];
35
+ export {};
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /** Every stay of one resource that ends after `checkInDayMs`, nearest first. */ export function reservationAvailabilityQuery(reservations, resourceId, checkInDayMs) {
17
+ return reservations.where('resourceId', '==', resourceId).where('checkOutDayMs', '>', checkInDayMs).orderBy('checkOutDayMs').limit(500);
18
+ }
19
+ /**
20
+ * The fields `isRangeAvailable` reads, off the query's documents.
21
+ * `createdAtMs` matters: a `pending` hold lapses on it.
22
+ */ export function readLiveReservations(snapshot) {
23
+ return snapshot.docs.map((docSnapshot)=>{
24
+ var _docSnapshot_get;
25
+ return {
26
+ checkInDayMs: Number(docSnapshot.get('checkInDayMs')),
27
+ checkOutDayMs: Number(docSnapshot.get('checkOutDayMs')),
28
+ status: String(docSnapshot.get('status')),
29
+ createdAtMs: Number((_docSnapshot_get = docSnapshot.get('createdAtMs')) != null ? _docSnapshot_get : 0)
30
+ };
31
+ });
32
+ }
33
+
34
+ //# sourceMappingURL=reservation-availability-query.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/reservation-availability-query.ts"],"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\nimport type * as CommerceModel from '../model'\n\n/*\n * The availability read a booking is checked against, shared by the two\n * doors that write a stay under a transaction: the storefront's\n * `/api/commerce/reserve` and the staff walk-in (AGL-4046). One copy, so the\n * two cannot drift into answering \"is this room free?\" differently. The\n * reasoning behind the query's shape (the inequality that drops the past, the\n * ordering that decides what survives the 500 cap, the composite index it\n * needs) is written beside its first use in `reserve.ts`.\n */\n\n/** The query's minimal shape, which the Admin SDK and spec doubles share. */\ninterface QueryLike {\n where(field: string, op: string, value: unknown): QueryLike\n orderBy(field: string): QueryLike\n limit(count: number): QueryLike\n}\n\n/** Every stay of one resource that ends after `checkInDayMs`, nearest first. */\nexport function reservationAvailabilityQuery<Q extends QueryLike>(\n reservations: Q,\n resourceId: string,\n checkInDayMs: number,\n): Q {\n return reservations\n .where('resourceId', '==', resourceId)\n .where('checkOutDayMs', '>', checkInDayMs)\n .orderBy('checkOutDayMs')\n .limit(500) as Q\n}\n\n/**\n * The fields `isRangeAvailable` reads, off the query's documents.\n * `createdAtMs` matters: a `pending` hold lapses on it.\n */\nexport function readLiveReservations(snapshot: {\n docs: Array<{ get(field: string): unknown }>\n}): CommerceModel.HostReservation[] {\n return snapshot.docs.map((docSnapshot) => ({\n checkInDayMs: Number(docSnapshot.get('checkInDayMs')),\n checkOutDayMs: Number(docSnapshot.get('checkOutDayMs')),\n status: String(docSnapshot.get('status')) as CommerceModel.ReservationStatus,\n createdAtMs: Number(docSnapshot.get('createdAtMs') ?? 0),\n })) as CommerceModel.HostReservation[]\n}\n"],"names":["reservationAvailabilityQuery","reservations","resourceId","checkInDayMs","where","orderBy","limit","readLiveReservations","snapshot","docs","map","docSnapshot","Number","get","checkOutDayMs","status","String","createdAtMs"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAqBD,8EAA8E,GAC9E,OAAO,SAASA,6BACdC,YAAe,EACfC,UAAkB,EAClBC,YAAoB;IAEpB,OAAOF,aACJG,KAAK,CAAC,cAAc,MAAMF,YAC1BE,KAAK,CAAC,iBAAiB,KAAKD,cAC5BE,OAAO,CAAC,iBACRC,KAAK,CAAC;AACX;AAEA;;;CAGC,GACD,OAAO,SAASC,qBAAqBC,QAEpC;IACC,OAAOA,SAASC,IAAI,CAACC,GAAG,CAAC,CAACC;YAIJA;eAJqB;YACzCR,cAAcS,OAAOD,YAAYE,GAAG,CAAC;YACrCC,eAAeF,OAAOD,YAAYE,GAAG,CAAC;YACtCE,QAAQC,OAAOL,YAAYE,GAAG,CAAC;YAC/BI,aAAaL,QAAOD,mBAAAA,YAAYE,GAAG,CAAC,0BAAhBF,mBAAkC;QACxD;;AACF"}
@@ -15,9 +15,30 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import type { PluginApiHandler } from '@aglyn/aglyn/server';
18
+ import * as CommerceModel from '../model';
18
19
  /**
19
20
  * Public reservation availability (AGL-310): resource display info plus
20
21
  * the booked/blocked day ranges the calendar greys out. Guest details
21
22
  * never leave the server.
22
23
  */
23
24
  export declare const reservationAvailabilityHandler: PluginApiHandler;
25
+ /**
26
+ * How far ahead a several-unit resource's nights are counted: two years. A
27
+ * stay booked further out still blocks its own nights at the booking door,
28
+ * which counts every night it asks for; the calendar simply stops drawing.
29
+ */
30
+ export declare const AVAILABILITY_HORIZON_NIGHTS = 730;
31
+ /**
32
+ * A several-unit resource's calendar (AGL-4044): the runs of nights with no
33
+ * unit left, as closed ranges, and the nights with only a few left.
34
+ */
35
+ export declare function resourceNightsAvailability(resource: Pick<CommerceModel.HostResource, 'units'>, held: CommerceModel.HeldStay[], todayMs: number, nowMs: number): {
36
+ full: Array<{
37
+ fromDayMs: number;
38
+ toDayMs: number;
39
+ }>;
40
+ fewLeft: Array<{
41
+ dayMs: number;
42
+ unitsLeft: number;
43
+ }>;
44
+ };
@@ -13,7 +13,8 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import * as CommerceModel from "../model/index.js";
16
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import * as CommerceModel from "../model/index.js";
17
18
  import { firebaseAdmin } from "@aglyn/tenant-data-admin";
18
19
  /**
19
20
  * Public reservation availability (AGL-310): resource display info plus
@@ -73,18 +74,27 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
73
74
  // those dates out on the date-picker forever, while `reserve.ts` would
74
75
  // have sold them to the next guest who asked for them directly.
75
76
  const nowMs = Date.now();
76
- const unavailable = reservationsSnapshot.docs.filter((docSnapshot)=>{
77
+ const held = reservationsSnapshot.docs.map((docSnapshot)=>{
77
78
  var _docSnapshot_get;
78
- return CommerceModel.reservationHoldsDates({
79
+ return {
80
+ checkInDayMs: Number(docSnapshot.get('checkInDayMs')),
81
+ checkOutDayMs: Number(docSnapshot.get('checkOutDayMs')),
79
82
  status: String(docSnapshot.get('status')),
80
83
  createdAtMs: Number((_docSnapshot_get = docSnapshot.get('createdAtMs')) != null ? _docSnapshot_get : 0)
81
- }, nowMs);
82
- }).map((docSnapshot)=>({
83
- fromDayMs: Number(docSnapshot.get('checkInDayMs')),
84
- toDayMs: Number(docSnapshot.get('checkOutDayMs'))
85
- })).concat((_resource_blocks = resource.blocks) != null ? _resource_blocks : []);
84
+ };
85
+ });
86
+ const units = CommerceModel.resourceUnits(resource);
87
+ const nights = units === 1 ? null : resourceNightsAvailability(resource, held, todayMs, nowMs);
88
+ // One unit: every stay that still holds its dates is a closed range, as
89
+ // it always was. Several (AGL-4044): only the nights with no unit left
90
+ // are, so the widget's own overlap check (which treats each range as one
91
+ // stay on a one-unit resource) refuses exactly the full nights.
92
+ const unavailable = (nights ? nights.full : held.filter((stay)=>CommerceModel.reservationHoldsDates(stay, nowMs)).map((stay)=>({
93
+ fromDayMs: stay.checkInDayMs,
94
+ toDayMs: stay.checkOutDayMs
95
+ }))).concat((_resource_blocks = resource.blocks) != null ? _resource_blocks : []);
86
96
  res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=120');
87
- return res.status(200).json({
97
+ return res.status(200).json(_extends({
88
98
  resource: {
89
99
  name: resource.name,
90
100
  description: (_resource_description = resource.description) != null ? _resource_description : null,
@@ -99,7 +109,9 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
99
109
  cancellationHours: (_resource_cancellationHours = resource.cancellationHours) != null ? _resource_cancellationHours : null
100
110
  },
101
111
  unavailable
102
- });
112
+ }, nights ? {
113
+ fewLeft: nights.fewLeft
114
+ } : {}));
103
115
  } catch (error) {
104
116
  console.error(error);
105
117
  return res.status(500).json({
@@ -107,5 +119,47 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
107
119
  });
108
120
  }
109
121
  };
122
+ /**
123
+ * How far ahead a several-unit resource's nights are counted: two years. A
124
+ * stay booked further out still blocks its own nights at the booking door,
125
+ * which counts every night it asks for; the calendar simply stops drawing.
126
+ */ export const AVAILABILITY_HORIZON_NIGHTS = 730;
127
+ /**
128
+ * A several-unit resource's calendar (AGL-4044): the runs of nights with no
129
+ * unit left, as closed ranges, and the nights with only a few left.
130
+ */ export function resourceNightsAvailability(resource, held, todayMs, nowMs) {
131
+ const lastCheckOut = held.filter((stay)=>CommerceModel.reservationHoldsDates(stay, nowMs)).reduce((latest, stay)=>Math.max(latest, stay.checkOutDayMs), todayMs);
132
+ const nights = Math.min(AVAILABILITY_HORIZON_NIGHTS, Math.max(0, Math.round((lastCheckOut - todayMs) / CommerceModel.DAY_MS)));
133
+ // Blocks are left out here: the handler sends them as their own ranges.
134
+ const perNight = CommerceModel.unitsLeftByNight({
135
+ units: resource.units
136
+ }, held, todayMs, nights, nowMs);
137
+ const full = [];
138
+ const fewLeft = [];
139
+ for (const night of perNight){
140
+ if (night.unitsLeft === 0) {
141
+ const last = full[full.length - 1];
142
+ if (last && last.toDayMs === night.dayMs) {
143
+ last.toDayMs = night.dayMs + CommerceModel.DAY_MS;
144
+ } else {
145
+ full.push({
146
+ fromDayMs: night.dayMs,
147
+ toDayMs: night.dayMs + CommerceModel.DAY_MS
148
+ });
149
+ }
150
+ } else if (// A few left, and fewer than it has: a three-unit resource nobody has
151
+ // booked does not have "only 3 left".
152
+ night.unitsLeft <= CommerceModel.FEW_UNITS_LEFT && night.unitsLeft < night.units) {
153
+ fewLeft.push({
154
+ dayMs: night.dayMs,
155
+ unitsLeft: night.unitsLeft
156
+ });
157
+ }
158
+ }
159
+ return {
160
+ full,
161
+ fewLeft
162
+ };
163
+ }
110
164
 
111
165
  //# sourceMappingURL=reservation-availability.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/reservation-availability.ts"],"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\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\n\n/**\n * Public reservation availability (AGL-310): resource display info plus\n * the booked/blocked day ranges the calendar greys out. Guest details\n * never leave the server.\n */\nexport const reservationAvailabilityHandler: PluginApiHandler = async (req, res) => {\n const hostId = String(req.query.hostId ?? '')\n const resourceId = String(req.query.resourceId ?? '')\n if (!hostId || !resourceId) {\n return res.status(400).json({ error: 'Missing hostId or resourceId' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n /*\n * ONLY STAYS THAT HAVE NOT ENDED, NEAREST FIRST (AGL-2159).\n *\n * `.where('resourceId','==',resourceId).limit(500)` with no ordering\n * returns 500 documents in `__name__` order — an arbitrary slice of the\n * resource's ENTIRE booking history. Every stay it has ever had competes\n * for those 500 places with the ones that are still ahead, so past a few\n * hundred lifetime bookings the calendar starts leaving LIVE stays out of\n * the greyed-out days and offering an occupied room to the next visitor.\n *\n * This is the same defect, on the same collection, that `reserve.ts`\n * carries the fix for. That one guards the authoritative overlap check;\n * this one draws the calendar the visitor picks from, and the two must\n * agree — a calendar offering a day the booking path then refuses is the\n * same bug wearing a friendlier face.\n *\n * `checkOutDayMs >= todayMs` removes the whole of the past: a stay that\n * ended before today cannot block a day anyone can still book. Ordering\n * on the same field puts the nearest stays inside the limit rather than\n * whichever sort early by id. Uses the existing composite index\n * `reservations (resourceId ASC, checkOutDayMs ASC)`.\n *\n * Every writer sets `checkOutDayMs` — it is required on the reservation\n * model — which matters because an `orderBy` silently drops documents\n * missing the field it sorts on.\n */\n const todayMs = Date.parse(\n `${new Date().toISOString().slice(0, 10)}T00:00:00Z`,\n )\n const [resourceSnapshot, reservationsSnapshot] = await Promise.all([\n hostRef.collection('resources').doc(resourceId).get(),\n hostRef\n .collection('reservations')\n .where('resourceId', '==', resourceId)\n .where('checkOutDayMs', '>=', todayMs)\n .orderBy('checkOutDayMs')\n .limit(500)\n .get(),\n ])\n const resource = resourceSnapshot.data() as CommerceModel.HostResource | undefined\n if (!resource) return res.status(404).json({ error: 'Unknown resource' })\n\n // The SAME hold rule the booking door applies (`reserve.ts`), through the\n // one predicate in the model. An inline dead-status set here knew nothing\n // about the 30-minute lapse on an unpaid `pending`, and nothing ever\n // clears such a row — so one guest abandoning the payment screen greyed\n // those dates out on the date-picker forever, while `reserve.ts` would\n // have sold them to the next guest who asked for them directly.\n const nowMs = Date.now()\n const unavailable = reservationsSnapshot.docs\n .filter((docSnapshot) =>\n CommerceModel.reservationHoldsDates(\n {\n status: String(\n docSnapshot.get('status'),\n ) as CommerceModel.ReservationStatus,\n createdAtMs: Number(docSnapshot.get('createdAtMs') ?? 0),\n },\n nowMs,\n ),\n )\n .map((docSnapshot) => ({\n fromDayMs: Number(docSnapshot.get('checkInDayMs')),\n toDayMs: Number(docSnapshot.get('checkOutDayMs')),\n }))\n .concat(resource.blocks ?? [])\n\n res.setHeader(\n 'Cache-Control',\n 'public, s-maxage=60, stale-while-revalidate=120',\n )\n return res.status(200).json({\n resource: {\n name: resource.name,\n description: resource.description ?? null,\n capacity: resource.capacity ?? null,\n photoUrls: resource.photoUrls ?? [],\n amenities: resource.amenities ?? [],\n nightlyRateUsd: resource.nightlyRateUsd,\n weekendMultiplier: resource.weekendMultiplier ?? null,\n seasons: resource.seasons ?? [],\n minNights: resource.minNights ?? null,\n depositPct: resource.depositPct ?? null,\n cancellationHours: resource.cancellationHours ?? null,\n },\n unavailable,\n })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Availability unavailable' })\n }\n}\n"],"names":["CommerceModel","firebaseAdmin","reservationAvailabilityHandler","req","res","hostId","String","query","resourceId","status","json","error","resource","firestore","app","hostRef","collection","doc","todayMs","Date","parse","toISOString","slice","resourceSnapshot","reservationsSnapshot","Promise","all","get","where","orderBy","limit","data","nowMs","now","unavailable","docs","filter","docSnapshot","reservationHoldsDates","createdAtMs","Number","map","fromDayMs","toDayMs","concat","blocks","setHeader","name","description","capacity","photoUrls","amenities","nightlyRateUsd","weekendMultiplier","seasons","minNights","depositPct","cancellationHours","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/reservation-availability.ts"],"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\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\n\n/**\n * Public reservation availability (AGL-310): resource display info plus\n * the booked/blocked day ranges the calendar greys out. Guest details\n * never leave the server.\n */\nexport const reservationAvailabilityHandler: PluginApiHandler = async (req, res) => {\n const hostId = String(req.query.hostId ?? '')\n const resourceId = String(req.query.resourceId ?? '')\n if (!hostId || !resourceId) {\n return res.status(400).json({ error: 'Missing hostId or resourceId' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n /*\n * ONLY STAYS THAT HAVE NOT ENDED, NEAREST FIRST (AGL-2159).\n *\n * `.where('resourceId','==',resourceId).limit(500)` with no ordering\n * returns 500 documents in `__name__` order — an arbitrary slice of the\n * resource's ENTIRE booking history. Every stay it has ever had competes\n * for those 500 places with the ones that are still ahead, so past a few\n * hundred lifetime bookings the calendar starts leaving LIVE stays out of\n * the greyed-out days and offering an occupied room to the next visitor.\n *\n * This is the same defect, on the same collection, that `reserve.ts`\n * carries the fix for. That one guards the authoritative overlap check;\n * this one draws the calendar the visitor picks from, and the two must\n * agree — a calendar offering a day the booking path then refuses is the\n * same bug wearing a friendlier face.\n *\n * `checkOutDayMs >= todayMs` removes the whole of the past: a stay that\n * ended before today cannot block a day anyone can still book. Ordering\n * on the same field puts the nearest stays inside the limit rather than\n * whichever sort early by id. Uses the existing composite index\n * `reservations (resourceId ASC, checkOutDayMs ASC)`.\n *\n * Every writer sets `checkOutDayMs` — it is required on the reservation\n * model — which matters because an `orderBy` silently drops documents\n * missing the field it sorts on.\n */\n const todayMs = Date.parse(\n `${new Date().toISOString().slice(0, 10)}T00:00:00Z`,\n )\n const [resourceSnapshot, reservationsSnapshot] = await Promise.all([\n hostRef.collection('resources').doc(resourceId).get(),\n hostRef\n .collection('reservations')\n .where('resourceId', '==', resourceId)\n .where('checkOutDayMs', '>=', todayMs)\n .orderBy('checkOutDayMs')\n .limit(500)\n .get(),\n ])\n const resource = resourceSnapshot.data() as CommerceModel.HostResource | undefined\n if (!resource) return res.status(404).json({ error: 'Unknown resource' })\n\n // The SAME hold rule the booking door applies (`reserve.ts`), through the\n // one predicate in the model. An inline dead-status set here knew nothing\n // about the 30-minute lapse on an unpaid `pending`, and nothing ever\n // clears such a row — so one guest abandoning the payment screen greyed\n // those dates out on the date-picker forever, while `reserve.ts` would\n // have sold them to the next guest who asked for them directly.\n const nowMs = Date.now()\n const held = reservationsSnapshot.docs.map((docSnapshot) => ({\n checkInDayMs: Number(docSnapshot.get('checkInDayMs')),\n checkOutDayMs: Number(docSnapshot.get('checkOutDayMs')),\n status: String(\n docSnapshot.get('status'),\n ) as CommerceModel.ReservationStatus,\n createdAtMs: Number(docSnapshot.get('createdAtMs') ?? 0),\n }))\n const units = CommerceModel.resourceUnits(resource)\n const nights =\n units === 1\n ? null\n : resourceNightsAvailability(resource, held, todayMs, nowMs)\n // One unit: every stay that still holds its dates is a closed range, as\n // it always was. Several (AGL-4044): only the nights with no unit left\n // are, so the widget's own overlap check (which treats each range as one\n // stay on a one-unit resource) refuses exactly the full nights.\n const unavailable = (\n nights\n ? nights.full\n : held\n .filter((stay) => CommerceModel.reservationHoldsDates(stay, nowMs))\n .map((stay) => ({\n fromDayMs: stay.checkInDayMs,\n toDayMs: stay.checkOutDayMs,\n }))\n ).concat(resource.blocks ?? [])\n\n res.setHeader(\n 'Cache-Control',\n 'public, s-maxage=60, stale-while-revalidate=120',\n )\n return res.status(200).json({\n resource: {\n name: resource.name,\n description: resource.description ?? null,\n capacity: resource.capacity ?? null,\n photoUrls: resource.photoUrls ?? [],\n amenities: resource.amenities ?? [],\n nightlyRateUsd: resource.nightlyRateUsd,\n weekendMultiplier: resource.weekendMultiplier ?? null,\n seasons: resource.seasons ?? [],\n minNights: resource.minNights ?? null,\n depositPct: resource.depositPct ?? null,\n cancellationHours: resource.cancellationHours ?? null,\n },\n unavailable,\n // The nights with only a few units left, for the widget's \"Only 2\n // left\" line. Several-unit resources only: absent on one unit.\n ...(nights ? { fewLeft: nights.fewLeft } : {}),\n })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Availability unavailable' })\n }\n}\n\n/**\n * How far ahead a several-unit resource's nights are counted: two years. A\n * stay booked further out still blocks its own nights at the booking door,\n * which counts every night it asks for; the calendar simply stops drawing.\n */\nexport const AVAILABILITY_HORIZON_NIGHTS = 730\n\n/**\n * A several-unit resource's calendar (AGL-4044): the runs of nights with no\n * unit left, as closed ranges, and the nights with only a few left.\n */\nexport function resourceNightsAvailability(\n resource: Pick<CommerceModel.HostResource, 'units'>,\n held: CommerceModel.HeldStay[],\n todayMs: number,\n nowMs: number,\n): {\n full: Array<{ fromDayMs: number; toDayMs: number }>\n fewLeft: Array<{ dayMs: number; unitsLeft: number }>\n} {\n const lastCheckOut = held\n .filter((stay) => CommerceModel.reservationHoldsDates(stay, nowMs))\n .reduce((latest, stay) => Math.max(latest, stay.checkOutDayMs), todayMs)\n const nights = Math.min(\n AVAILABILITY_HORIZON_NIGHTS,\n Math.max(0, Math.round((lastCheckOut - todayMs) / CommerceModel.DAY_MS)),\n )\n // Blocks are left out here: the handler sends them as their own ranges.\n const perNight = CommerceModel.unitsLeftByNight(\n { units: resource.units },\n held,\n todayMs,\n nights,\n nowMs,\n )\n const full: Array<{ fromDayMs: number; toDayMs: number }> = []\n const fewLeft: Array<{ dayMs: number; unitsLeft: number }> = []\n for (const night of perNight) {\n if (night.unitsLeft === 0) {\n const last = full[full.length - 1]\n if (last && last.toDayMs === night.dayMs) {\n last.toDayMs = night.dayMs + CommerceModel.DAY_MS\n } else {\n full.push({\n fromDayMs: night.dayMs,\n toDayMs: night.dayMs + CommerceModel.DAY_MS,\n })\n }\n } else if (\n // A few left, and fewer than it has: a three-unit resource nobody has\n // booked does not have \"only 3 left\".\n night.unitsLeft <= CommerceModel.FEW_UNITS_LEFT &&\n night.unitsLeft < night.units\n ) {\n fewLeft.push({ dayMs: night.dayMs, unitsLeft: night.unitsLeft })\n }\n }\n return { full, fewLeft }\n}\n"],"names":["CommerceModel","firebaseAdmin","reservationAvailabilityHandler","req","res","hostId","String","query","resourceId","status","json","error","resource","firestore","app","hostRef","collection","doc","todayMs","Date","parse","toISOString","slice","resourceSnapshot","reservationsSnapshot","Promise","all","get","where","orderBy","limit","data","nowMs","now","held","docs","map","docSnapshot","checkInDayMs","Number","checkOutDayMs","createdAtMs","units","resourceUnits","nights","resourceNightsAvailability","unavailable","full","filter","stay","reservationHoldsDates","fromDayMs","toDayMs","concat","blocks","setHeader","name","description","capacity","photoUrls","amenities","nightlyRateUsd","weekendMultiplier","seasons","minNights","depositPct","cancellationHours","fewLeft","console","AVAILABILITY_HORIZON_NIGHTS","lastCheckOut","reduce","latest","Math","max","min","round","DAY_MS","perNight","unitsLeftByNight","night","unitsLeft","last","length","dayMs","push","FEW_UNITS_LEFT"],"mappings":"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"}