@mohasinac/appkit 4.53.2 → 4.54.0-next.0

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 (179) hide show
  1. package/dist/.build-stamp.json +5 -5
  2. package/dist/_internal/server/features/auth/capabilities.js +7 -5
  3. package/dist/_internal/server/features/checkout/supplier-identity.js +28 -6
  4. package/dist/_internal/server/features/products/listing-tab-counts.js +81 -25
  5. package/dist/_internal/server/features/search/index-build/sources.js +43 -2
  6. package/dist/_internal/server/jobs/core/jobRunners.js +0 -10
  7. package/dist/_internal/server/jobs/core/onOrderCreate.js +10 -104
  8. package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
  9. package/dist/_internal/shared/actions/action-registry.js +128 -0
  10. package/dist/_internal/shared/features/categories/bundle-copy.d.ts +1 -1
  11. package/dist/_internal/shared/features/site-settings/supplier-of-record.d.ts +56 -0
  12. package/dist/_internal/shared/features/site-settings/supplier-of-record.js +57 -0
  13. package/dist/_internal/shared/listing-types/tiers.d.ts +94 -0
  14. package/dist/_internal/shared/listing-types/tiers.js +150 -0
  15. package/dist/cli/index.js +0 -2
  16. package/dist/client.d.ts +21 -2
  17. package/dist/client.js +23 -4
  18. package/dist/constants/api-endpoints.d.ts +21 -0
  19. package/dist/constants/api-endpoints.js +7 -0
  20. package/dist/features/account/components/UserOrdersView.js +26 -7
  21. package/dist/features/addresses/utils/postal-autofill.d.ts +39 -0
  22. package/dist/features/addresses/utils/postal-autofill.js +54 -0
  23. package/dist/features/admin/components/AdminStoreEditorView.js +73 -34
  24. package/dist/features/admin/components/OwnerQuickActions.d.ts +29 -0
  25. package/dist/features/admin/components/OwnerQuickActions.js +142 -0
  26. package/dist/features/admin/schemas/admin-editor-forms.d.ts +1 -1
  27. package/dist/features/admin/schemas/admin-editor-forms.js +4 -1
  28. package/dist/features/auctions/components/MarketplaceAuctionCard.d.ts +11 -1
  29. package/dist/features/auctions/components/MarketplaceAuctionCard.js +9 -2
  30. package/dist/features/auth/permissions/constants.d.ts +29 -2
  31. package/dist/features/auth/permissions/constants.js +68 -1
  32. package/dist/features/blog/components/BlogFeaturedCard.d.ts +11 -1
  33. package/dist/features/blog/components/BlogFeaturedCard.js +9 -2
  34. package/dist/features/cart/components/CartDrawer.js +3 -2
  35. package/dist/features/cart/components/CartGroupLineRow.js +2 -1
  36. package/dist/features/cart/hooks/useAddToCart.js +10 -5
  37. package/dist/features/cart/utils/pending-ops.d.ts +11 -1
  38. package/dist/features/cart/utils/pending-ops.js +14 -2
  39. package/dist/features/categories/components/BrandDetailPageView.js +2 -1
  40. package/dist/features/categories/components/CategoryDetailPageView.js +2 -1
  41. package/dist/features/categories/components/CategoryGrid.d.ts +11 -1
  42. package/dist/features/categories/components/CategoryGrid.js +9 -2
  43. package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +10 -5
  44. package/dist/features/checkout/actions/checkout-value-otp-actions.js +2 -88
  45. package/dist/features/events/components/EventCard.d.ts +11 -1
  46. package/dist/features/events/components/EventCard.js +9 -2
  47. package/dist/features/layout/AppLayoutShell.js +2 -1
  48. package/dist/features/layout/BottomActions.d.ts +1 -0
  49. package/dist/features/layout/BottomActions.js +330 -0
  50. package/dist/features/layout/BottomChrome.d.ts +45 -0
  51. package/dist/features/layout/BottomChrome.js +5 -17
  52. package/dist/features/layout/SideActionDock.js +36 -13
  53. package/dist/features/layout/index.d.ts +2 -7
  54. package/dist/features/layout/index.js +7 -7
  55. package/dist/features/layout/useIsDesktop.d.ts +36 -0
  56. package/dist/features/layout/useIsDesktop.js +48 -0
  57. package/dist/features/media/messages/en.json +67 -67
  58. package/dist/features/orders/components/YourPurchaseStrip.d.ts +24 -0
  59. package/dist/features/orders/components/YourPurchaseStrip.js +77 -0
  60. package/dist/features/orders/components/index.d.ts +2 -1
  61. package/dist/features/orders/components/index.js +1 -1
  62. package/dist/features/orders/utils/order-announcement-message.d.ts +18 -0
  63. package/dist/features/orders/utils/order-announcement-message.js +16 -0
  64. package/dist/features/orders/utils/payment-proof-message.d.ts +20 -0
  65. package/dist/features/orders/utils/payment-proof-message.js +17 -0
  66. package/dist/features/parts/schemas/firestore.d.ts +227 -0
  67. package/dist/features/parts/schemas/firestore.js +6 -6
  68. package/dist/features/pre-orders/components/MarketplacePreorderCard.d.ts +11 -1
  69. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +10 -3
  70. package/dist/features/products/api/[id]/route.js +8 -1
  71. package/dist/features/products/api/route.js +16 -4
  72. package/dist/features/products/components/AvailabilityScopeHint.js +1 -1
  73. package/dist/features/products/components/InteractiveProductCard.d.ts +7 -1
  74. package/dist/features/products/components/InteractiveProductCard.js +16 -4
  75. package/dist/features/products/components/MarketplaceBundleCard.d.ts +11 -1
  76. package/dist/features/products/components/MarketplaceBundleCard.js +9 -2
  77. package/dist/features/products/components/MarketplacePrizeDrawCard.d.ts +11 -1
  78. package/dist/features/products/components/MarketplacePrizeDrawCard.js +9 -2
  79. package/dist/features/products/components/ProductDetailPageView.js +27 -2
  80. package/dist/features/products/components/ProductGrid.d.ts +14 -1
  81. package/dist/features/products/components/ProductGrid.js +22 -5
  82. package/dist/features/products/components/ProductsIndexListing.js +2 -2
  83. package/dist/features/reviews/api/[id]/route.js +83 -23
  84. package/dist/features/reviews/components/PurchaseReviewAffordance.d.ts +6 -0
  85. package/dist/features/reviews/components/PurchaseReviewAffordance.js +42 -0
  86. package/dist/features/reviews/components/ReviewFormDrawer.d.ts +34 -0
  87. package/dist/features/reviews/components/ReviewFormDrawer.js +127 -0
  88. package/dist/features/reviews/components/index.d.ts +4 -0
  89. package/dist/features/reviews/components/index.js +2 -0
  90. package/dist/features/seller/actions/seller-actions.d.ts +1 -1
  91. package/dist/features/seller/actions/seller-actions.js +49 -3
  92. package/dist/features/seller/components/SellerPayoutSettingsView.js +30 -2
  93. package/dist/features/seller/components/SellerProductShell.d.ts +17 -1
  94. package/dist/features/seller/hooks/useStoreListingEntitlements.d.ts +8 -0
  95. package/dist/features/seller/hooks/useStoreListingEntitlements.js +70 -0
  96. package/dist/features/stores/components/InteractiveStoreCard.d.ts +11 -1
  97. package/dist/features/stores/components/InteractiveStoreCard.js +9 -2
  98. package/dist/features/stores/components/index.d.ts +0 -2
  99. package/dist/features/stores/components/index.js +0 -1
  100. package/dist/features/stores/schemas/index.d.ts +1 -1
  101. package/dist/features/stores/schemas/index.js +10 -1
  102. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +403 -0
  103. package/dist/index.d.ts +8 -27
  104. package/dist/index.js +14 -35
  105. package/dist/next/routing/route-map.d.ts +2 -0
  106. package/dist/next/routing/route-map.js +9 -0
  107. package/dist/providers/shipping-shiprocket/index.d.ts +98 -0
  108. package/dist/providers/shipping-shiprocket/index.js +353 -0
  109. package/dist/schemas/registry.d.ts +2 -2
  110. package/dist/security/csp.d.ts +16 -1
  111. package/dist/security/csp.js +5 -3
  112. package/dist/seed/_helpers/category-forest.js +27 -0
  113. package/dist/seed/_helpers/category-parts-glossary-map.js +1816 -86
  114. package/dist/seed/_helpers/category-parts.d.ts +1 -1
  115. package/dist/seed/_helpers/category-parts.js +13412 -3020
  116. package/dist/seed/categories-seed-data.js +48 -6
  117. package/dist/seed/homepage-sections-seed-data.js +41 -28
  118. package/dist/seed/model-composition-seed-data.js +6 -8
  119. package/dist/seed/part-glossary/chunk0.d.ts +9 -0
  120. package/dist/seed/part-glossary/chunk0.js +35044 -0
  121. package/dist/seed/part-glossary/chunk1.d.ts +9 -0
  122. package/dist/seed/part-glossary/chunk1.js +39955 -0
  123. package/dist/seed/part-glossary/chunk2.d.ts +9 -0
  124. package/dist/seed/part-glossary/chunk2.js +50335 -0
  125. package/dist/seed/part-glossary/chunk3.d.ts +9 -0
  126. package/dist/seed/part-glossary/chunk3.js +52386 -0
  127. package/dist/seed/part-glossary/chunk4.d.ts +9 -0
  128. package/dist/seed/part-glossary/chunk4.js +50549 -0
  129. package/dist/seed/part-glossary-seed-data.d.ts +1 -1
  130. package/dist/seed/part-glossary-seed-data.js +23 -47483
  131. package/dist/server.d.ts +4 -8
  132. package/dist/server.js +8 -20
  133. package/dist/styles.css +516 -358
  134. package/dist/tailwind-utilities.css +1 -1
  135. package/dist/tokens/themes/index.d.ts +3 -1
  136. package/dist/tokens/themes/index.js +11 -1
  137. package/dist/tokens/themes/technical-dark.d.ts +30 -0
  138. package/dist/tokens/themes/technical-dark.js +103 -0
  139. package/dist/tokens/themes/technical-light.d.ts +22 -0
  140. package/dist/tokens/themes/technical-light.js +89 -0
  141. package/dist/tokens/tokens.css +155 -0
  142. package/dist/ui/components/GatedPrice.js +30 -3
  143. package/dist/ui/components/GatedPrice.style.css +22 -19
  144. package/dist/ui/components/QuickCreateModal.style.css +78 -78
  145. package/dist/ui/forms/FormShell.style.css +260 -260
  146. package/package.json +1 -1
  147. package/dist/_internal/server/jobs/core/whatsappCatalogImport.d.ts +0 -27
  148. package/dist/_internal/server/jobs/core/whatsappCatalogImport.js +0 -172
  149. package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +0 -35
  150. package/dist/_internal/server/jobs/core/whatsappNotify.js +0 -140
  151. package/dist/features/admin/components/AdminWhatsAppGuideView.d.ts +0 -2
  152. package/dist/features/admin/components/AdminWhatsAppGuideView.js +0 -35
  153. package/dist/features/orders/components/MarketplaceOrderCard.d.ts +0 -36
  154. package/dist/features/orders/components/MarketplaceOrderCard.js +0 -57
  155. package/dist/features/stores/components/StoreWhatsAppGuideView.d.ts +0 -3
  156. package/dist/features/stores/components/StoreWhatsAppGuideView.js +0 -12
  157. package/dist/features/whatsapp-bot/api/send-status/route.d.ts +0 -12
  158. package/dist/features/whatsapp-bot/api/send-status/route.js +0 -38
  159. package/dist/features/whatsapp-bot/api/webhook/route.d.ts +0 -12
  160. package/dist/features/whatsapp-bot/api/webhook/route.js +0 -43
  161. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +0 -6
  162. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +0 -270
  163. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +0 -9
  164. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +0 -7
  165. package/dist/features/whatsapp-bot/components/index.d.ts +0 -3
  166. package/dist/features/whatsapp-bot/components/index.js +0 -2
  167. package/dist/features/whatsapp-bot/helpers/payment-proof-message.d.ts +0 -14
  168. package/dist/features/whatsapp-bot/helpers/payment-proof-message.js +0 -17
  169. package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +0 -52
  170. package/dist/features/whatsapp-bot/helpers/whatsapp.js +0 -283
  171. package/dist/features/whatsapp-bot/index.d.ts +0 -3
  172. package/dist/features/whatsapp-bot/index.js +0 -3
  173. package/dist/features/whatsapp-bot/manifest.d.ts +0 -2
  174. package/dist/features/whatsapp-bot/manifest.js +0 -13
  175. package/dist/features/whatsapp-bot/messages/en.json +0 -7
  176. package/dist/features/whatsapp-bot/server.d.ts +0 -7
  177. package/dist/features/whatsapp-bot/server.js +0 -7
  178. package/dist/features/whatsapp-bot/types/index.d.ts +0 -126
  179. package/dist/features/whatsapp-bot/types/index.js +0 -1
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.53.2",
4
- "head": "94f4715428f35ab362a11a468072ef933ca8a46d",
5
- "branch": "main",
6
- "dirty": false,
7
- "builtAt": "2026-10-19T22:24:09.397Z"
3
+ "version": "4.54.0-next.0",
4
+ "head": "91b142ab99bc17b915e4f285d548d9f0cd36d4ea",
5
+ "branch": "feat/mohasinac-site",
6
+ "dirty": true,
7
+ "builtAt": "2026-10-20T12:40:18.408Z"
8
8
  }
@@ -5,7 +5,7 @@
5
5
  * Call this server-side only — never trust client-provided capability arrays.
6
6
  */
7
7
  import { storeRepository } from "../../../../repositories/index.js";
8
- import { DEFAULT_STORE_CAPABILITIES, } from "../../../../features/auth/permissions/constants.js";
8
+ import { DEFAULT_STORE_CAPABILITIES, resolveStoreCapabilities, } from "../../../../features/auth/permissions/constants.js";
9
9
  /**
10
10
  * Return the resolved capability set for a store.
11
11
  * Falls back to DEFAULT_STORE_CAPABILITIES when the store has no capabilities field.
@@ -16,10 +16,12 @@ export async function getStoreCapabilities(storeId) {
16
16
  const store = await storeRepository.findById(storeId);
17
17
  if (!store)
18
18
  return [...DEFAULT_STORE_CAPABILITIES];
19
- const raw = store.capabilities;
20
- if (Array.isArray(raw) && raw.length > 0)
21
- return raw;
22
- return [...DEFAULT_STORE_CAPABILITIES];
19
+ /*
20
+ * The merge itself lives in `resolveStoreCapabilities` so a caller holding
21
+ * the store document already can run the same rule without a second read.
22
+ * This function is now just "fetch, then resolve".
23
+ */
24
+ return resolveStoreCapabilities(store.capabilities);
23
25
  }
24
26
  /**
25
27
  * Check whether a store has a specific capability.
@@ -29,6 +29,7 @@
29
29
  * registration as it stood at the moment of supply" — an admin correcting a
30
30
  * typo in the GSTIN must not silently reissue every invoice ever produced.
31
31
  */
32
+ import { getPlatformSupplier } from "../../../shared/features/site-settings/supplier-of-record.js";
32
33
  const trimmed = (v) => {
33
34
  const s = (v ?? "").trim();
34
35
  return s.length > 0 ? s : undefined;
@@ -61,18 +62,39 @@ export function resolveSupplierIdentity(store, settings) {
61
62
  };
62
63
  }
63
64
  /*
64
- * Platform branch. Gated on `gst.enabled`, matching what the checkout
65
- * already did before sellers could have a GSTIN at all — disabling GST must
66
- * continue to produce plain invoices rather than tax invoices.
65
+ * Platform branch. Still gated on `gst.enabled` — disabling GST must
66
+ * continue to produce plain invoices rather than tax invoices, and
67
+ * `source: "none"` must stay reachable.
68
+ *
69
+ * 🛑 But the IDENTITY now comes from `appkit.config.js → business`, not from
70
+ * `siteSettings.gst.*`.
71
+ *
72
+ * That block documents itself as "THE one owner of the business's statutory
73
+ * identity", and records that before it existed the legal name, address,
74
+ * GSTIN and support channels lived in five places that already disagreed —
75
+ * four different support addresses published, the registered address saying
76
+ * Solapur while the warehouse said Hyderabad. Reading the GSTIN from site
77
+ * settings here made this invoice path a SIXTH owner, and the one that
78
+ * decides what a tax invoice says.
79
+ *
80
+ * `siteSettings.gst.enabled` stays the switch because whether to charge GST
81
+ * at all is an operational decision an admin makes. WHO the supplier is, is
82
+ * not: it is a fact about the registered entity, it is legally required to be
83
+ * published, and it belongs in a committed, code-reviewed file.
84
+ *
85
+ * The snapshot contract is unchanged — the caller writes the result onto the
86
+ * order at creation and never recomputes it, so correcting a typo in the
87
+ * business block does not reissue historical invoices.
67
88
  */
68
89
  const gst = settings?.gst;
69
90
  if (gst?.enabled) {
70
- const platformGstin = trimmed(gst.gstin);
91
+ const platform = getPlatformSupplier();
92
+ const platformGstin = trimmed(platform.gstin);
71
93
  if (platformGstin) {
72
94
  return {
73
95
  gstin: platformGstin,
74
- legalName: trimmed(gst.legalName),
75
- address: trimmed(gst.address),
96
+ legalName: trimmed(platform.legalName),
97
+ address: trimmed(platform.address),
76
98
  source: "platform",
77
99
  };
78
100
  }
@@ -28,7 +28,7 @@ import { serverLogger } from "../../../../monitoring/index.js";
28
28
  import { normalizeError } from "../../../../errors/normalize.js";
29
29
  import { sieveAnd, sieveFilter, SIEVE_OP, sortBy } from "../../../../utils/sieve-builder.js";
30
30
  import { PRODUCT_FIELDS } from "../../../../constants/field-names.js";
31
- import { isListingRowAvailable, unavailableClausesFor, } from "../../../shared/listing-types/_registry.js";
31
+ import { isListingRowAvailable, pluginFor, } from "../../../shared/listing-types/_registry.js";
32
32
  import { normalizeListingType } from "../../../../features/products/utils/listing-type.js";
33
33
  import { clauseToSieve, PUBLIC_PRODUCT_MAX_PAGE_SIZE } from "./list-public.js";
34
34
  const DEFAULT_STATUS = "published";
@@ -105,21 +105,63 @@ function bundleMatchesScope(bundle, scope) {
105
105
  */
106
106
  async function unavailableByType(scope, types, now) {
107
107
  const byType = new Map();
108
- const clauses = unavailableClausesFor(types);
109
- if (clauses.length === 0)
110
- return { byType, truncated: false };
108
+ const unknown = new Set();
109
+ /*
110
+ * One entry per DISTINCT clause, remembering which types depend on it, so a
111
+ * clause that cannot run takes down only those types' badges.
112
+ */
113
+ const distinct = new Map();
114
+ for (const type of types) {
115
+ for (const clause of pluginFor(type).unavailableClauses) {
116
+ const key = clause.field + "|" + clause.op + "|" + String(clause.value);
117
+ const entry = distinct.get(key) ?? { clause, types: new Set() };
118
+ entry.types.add(type);
119
+ distinct.set(key, entry);
120
+ }
121
+ }
122
+ if (distinct.size === 0)
123
+ return { byType, unknown };
111
124
  const status = scope.status ?? DEFAULT_STATUS;
112
- const results = await Promise.all(clauses.map(async (clause) => {
113
- // An inequality must be ordered by its own field — Firestore appends it
114
- // to the `orderBy` implicitly, so any other sort demands a composite
115
- // index nobody declared (Root Cause #59).
125
+ const results = await Promise.all([...distinct.values()].map(async (entry) => {
126
+ const { clause } = entry;
127
+ /*
128
+ * 🛑 NO SORT on an equality clause — load-bearing, not an omission.
129
+ *
130
+ * A tally does not care about order, and adding `orderBy createdAt`
131
+ * turns a query Firestore can merge-join from single-field indexes into
132
+ * one demanding a composite index PER CLAUSE PER SCOPE: fifteen new
133
+ * indexes on a collection already carrying 223, every one of them write
134
+ * amplification on every product write. Measured against the live
135
+ * project: all four equality clauses run index-free without the sort and
136
+ * return FAILED_PRECONDITION with it.
137
+ *
138
+ * An inequality is the exception Firestore forces — its first `orderBy`
139
+ * must be its own field (Root Cause #59) — so that one clause does need
140
+ * an index, which is exactly why `unknown` is tracked per type.
141
+ *
142
+ * 🛑 THE AUCTION INDEXES ARE NOT DECLARED, deliberately. Until they are,
143
+ * the auction clause returns FAILED_PRECONDITION, `unknown` gains
144
+ * `auction`, and that ONE badge falls back to its total — every other
145
+ * type stays exact. Adding them to
146
+ * `appkit/firebase/base/firestore.indexes.json` is only half the change:
147
+ * an undeployed index file fails `audit-firebase-rules-deployed`, so the
148
+ * three entries below and `npm run firebase -- deploy --only
149
+ * firestore:indexes` belong in the same session.
150
+ *
151
+ * products: status ASC, categorySlugs CONTAINS, auctionEndDate DESC
152
+ * products: brand ASC, status ASC, auctionEndDate DESC
153
+ * products: storeId ASC, status ASC, auctionEndDate DESC
154
+ *
155
+ * One per scope the tab bars count in, because `buildScopedQuery` leads
156
+ * with a different equality for each.
157
+ */
116
158
  const sorts = clause.op === "lt" && clause.sortField
117
159
  ? sortBy(clause.sortField, "DESC")
118
- : sortBy(PRODUCT_FIELDS.CREATED_AT, "DESC");
160
+ : undefined;
119
161
  try {
120
- return await productRepository.list({
162
+ const result = await productRepository.list({
121
163
  filters: sieveAnd(sieveFilter(PRODUCT_FIELDS.STATUS, SIEVE_OP.EQ, status), clauseToSieve(clause, now)),
122
- sorts,
164
+ ...(sorts ? { sorts } : {}),
123
165
  page: 1,
124
166
  pageSize: PUBLIC_PRODUCT_MAX_PAGE_SIZE,
125
167
  }, {
@@ -127,26 +169,37 @@ async function unavailableByType(scope, types, now) {
127
169
  categoriesIn: scope.categoriesIn,
128
170
  brandName: scope.brandName,
129
171
  });
172
+ return { entry, result };
130
173
  }
131
174
  catch (error) {
132
175
  const e = normalizeError(error);
133
176
  // ERROR, not warn: a clause that cannot run is almost always a missing
134
- // composite index — a permanent, silent skew of every badge on the
135
- // page rather than a transient blip.
136
- serverLogger.error(`listingTabCounts: unavailable clause failed (${clause.field} ${clause.op}); badges fall back to totals`, { field: clause.field, op: clause.op, error: e.message });
137
- return null;
177
+ // composite index — a permanent, silent skew of the badges that depend
178
+ // on it rather than a transient blip. The fields are named so the log
179
+ // says which index to create.
180
+ serverLogger.error("listingTabCounts: unavailable clause failed; those badges fall back to totals", {
181
+ field: clause.field,
182
+ op: clause.op,
183
+ types: [...entry.types],
184
+ error: e.message,
185
+ });
186
+ return { entry, result: null };
138
187
  }
139
188
  }));
140
189
  const merged = new Map();
141
- let truncated = false;
142
- for (const result of results) {
190
+ for (const { entry, result } of results) {
143
191
  if (!result) {
144
- truncated = true;
192
+ for (const type of entry.types)
193
+ unknown.add(type);
145
194
  continue;
146
195
  }
147
196
  const rows = result.items;
148
- if (rows.length >= PUBLIC_PRODUCT_MAX_PAGE_SIZE)
149
- truncated = true;
197
+ // A saturated window means there are more unavailable rows than were seen,
198
+ // so the tally is a floor for every type this clause serves.
199
+ if (rows.length >= PUBLIC_PRODUCT_MAX_PAGE_SIZE) {
200
+ for (const type of entry.types)
201
+ unknown.add(type);
202
+ }
150
203
  for (const row of rows) {
151
204
  const id = typeof row.id === "string" ? row.id : JSON.stringify(row.id);
152
205
  if (!merged.has(id))
@@ -160,7 +213,7 @@ async function unavailableByType(scope, types, now) {
160
213
  const type = normalizeListingType(row);
161
214
  byType.set(type, (byType.get(type) ?? 0) + 1);
162
215
  }
163
- return { byType, truncated };
216
+ return { byType, unknown };
164
217
  }
165
218
  /** The listing types one tab spans — `"art|stickers"` is a pipe OR-group. */
166
219
  function typesOf(tab) {
@@ -194,15 +247,18 @@ export async function listingTabCounts(tabs, scope, opts) {
194
247
  const total = {};
195
248
  const available = {};
196
249
  for (const tab of listingTabs) {
250
+ const tabTypes = typesOf(tab);
197
251
  const totalForTab = typeCounts[String(tab.listingType)];
198
252
  total[tab.id] = totalForTab;
199
- if (totalForTab === undefined || unavailable.truncated) {
200
- // Unknown beats a number that is quietly too high. Falling back to the
201
- // total reproduces the pre-2026-10-20 badge rather than inventing one.
253
+ if (totalForTab === undefined ||
254
+ tabTypes.some((t) => unavailable.unknown.has(t))) {
255
+ // Falling back to the total reproduces the pre-split badge rather than
256
+ // inventing a number — and it keeps the hint silent, because the hint
257
+ // only speaks when both figures are known.
202
258
  available[tab.id] = totalForTab;
203
259
  continue;
204
260
  }
205
- const gone = typesOf(tab).reduce((sum, type) => sum + (unavailable.byType.get(type) ?? 0), 0);
261
+ const gone = tabTypes.reduce((sum, type) => sum + (unavailable.byType.get(type) ?? 0), 0);
206
262
  available[tab.id] = Math.max(0, totalForTab - gone);
207
263
  }
208
264
  if (wantsBundles) {
@@ -195,7 +195,26 @@ export const PUBLIC_INDEX_SOURCES = [
195
195
  {
196
196
  typeKey: "part",
197
197
  collection: "partGlossary",
198
- select: ["name", "code", "classification", "slot"],
198
+ /*
199
+ * 🛑 `aliases` IS IN THE SELECT BECAUSE AN ABBREVIATION IS HOW PEOPLE
200
+ * SEARCH FOR A PART.
201
+ *
202
+ * The wiki states a part's short form outright, and it reaches 1,072 of
203
+ * 2,490 rows: `EWD` is Eternal Wide Defense, `BWD` is Big Wide Defense,
204
+ * `ES` is Eternal Spike. Those are the names printed on the part and the
205
+ * names the community uses — but they lived only in Firestore, because
206
+ * this select carried name/code/classification/slot and nothing else.
207
+ *
208
+ * So the data was there, the page was there, and searching `EWD` found
209
+ * NOTHING. A gap between holding a fact and being able to find it is
210
+ * invisible from either end.
211
+ *
212
+ * 🛑 It must be `aliases`, not `aka`. The generator UNIONS the wiki's
213
+ * `aka` into the pre-existing `aliases` array rather than replacing it,
214
+ * so `aliases` is the superset — selecting `aka` would silently drop
215
+ * every alias the corpus contributed.
216
+ */
217
+ select: ["name", "code", "classification", "slot", "aliases"],
199
218
  slugOf: (id) => (id.includes("--") ? id.replace("--", "/") : null),
200
219
  toRecord: (id, d) => {
201
220
  const name = str(d.name);
@@ -214,11 +233,33 @@ export const PUBLIC_INDEX_SOURCES = [
214
233
  return null;
215
234
  const code = str(d.code);
216
235
  const classification = str(d.classification);
236
+ /*
237
+ * The abbreviations, appended to `detail` because that is the only
238
+ * other field the record carries and the matcher reads both.
239
+ *
240
+ * 🛑 Bounded to four and de-duplicated against the name and code. A
241
+ * part can carry a dozen aliases, and the artifact is a public file
242
+ * fetched by every visitor — the whole point of it is that it stays
243
+ * small enough to ship whole. Four covers the abbreviation plus the
244
+ * other vendor's name, which is what anyone actually types.
245
+ */
246
+ const aliases = Array.isArray(d.aliases) ? d.aliases.map(str).filter(Boolean) : [];
247
+ const seen = new Set([name.toLowerCase(), (code ?? "").toLowerCase()]);
248
+ const extra = [];
249
+ for (const a of aliases) {
250
+ const k = String(a).toLowerCase();
251
+ if (seen.has(k))
252
+ continue;
253
+ seen.add(k);
254
+ extra.push(String(a));
255
+ if (extra.length === 4)
256
+ break;
257
+ }
217
258
  return {
218
259
  title: name,
219
260
  // The classification IS the disambiguation — it is why a user
220
261
  // searching "105" can tell three results apart.
221
- detail: [classification, code].filter(Boolean).join(" · "),
262
+ detail: [classification, code, ...extra].filter(Boolean).join(" · "),
222
263
  };
223
264
  },
224
265
  },
@@ -11,9 +11,7 @@
11
11
  import { runWeeklyPayoutEligibility } from "./weeklyPayoutEligibility.js";
12
12
  import { runHardBanCascade } from "./hardBanCascade.js";
13
13
  import { runResetOtpVerification } from "./resetOtpVerification.js";
14
- import { runWhatsAppNotify } from "./whatsappNotify.js";
15
14
  import { runNewsletterExport } from "./newsletterExport.js";
16
- import { runWhatsAppCatalogImport } from "./whatsappCatalogImport.js";
17
15
  import { runStoreNameBackfill } from "./storeNameBackfill.js";
18
16
  import { runCategoryTaxonomyBackfill } from "./categoryTaxonomyBackfill.js";
19
17
  async function runPayoutsWeeklyJob(_payload, ctx) {
@@ -42,15 +40,9 @@ async function runHardBanCascadeJob(payload, ctx) {
42
40
  async function runResetOtpVerificationJob(_payload, ctx) {
43
41
  return runResetOtpVerification(ctx);
44
42
  }
45
- async function runWhatsAppNotifyJob(payload, ctx) {
46
- return runWhatsAppNotify(payload, ctx);
47
- }
48
43
  async function runNewsletterExportJob(_payload, ctx) {
49
44
  return runNewsletterExport(ctx);
50
45
  }
51
- async function runWhatsAppCatalogImportJob(payload, ctx) {
52
- return runWhatsAppCatalogImport({ storeSlug: String(payload.storeSlug ?? "") }, ctx);
53
- }
54
46
  async function runStoreNameBackfillJob(payload, ctx) {
55
47
  return runStoreNameBackfill({ storeId: String(payload.storeId ?? "") }, ctx);
56
48
  }
@@ -68,9 +60,7 @@ export const JOB_RUNNERS = {
68
60
  payoutsWeekly: runPayoutsWeeklyJob,
69
61
  hardBanCascade: runHardBanCascadeJob,
70
62
  resetOtpVerification: runResetOtpVerificationJob,
71
- whatsappNotify: runWhatsAppNotifyJob,
72
63
  newsletterExport: runNewsletterExportJob,
73
- whatsappCatalogImport: runWhatsAppCatalogImportJob,
74
64
  storeNameBackfill: runStoreNameBackfillJob,
75
65
  categoryTaxonomyBackfill: runCategoryTaxonomyBackfillJob,
76
66
  };
@@ -1,77 +1,7 @@
1
1
  import { normalizeError } from "../../../../errors/normalize.js";
2
2
  import { ORDER_PII_FIELDS, revealPii } from "../../../../security/index.js";
3
- import { adminNotificationsRepository, storeRepository, userRepository } from "../../../../repositories/index.js";
4
- import { decryptPii } from "../../../../security/index.js";
5
- import { sendWhatsAppBusinessMessage, buildPurchaseAnnouncementMessage, } from "../../../../features/whatsapp-bot/server.js";
6
- import { resolveKeys } from "../../../../core/integration-keys.js";
7
- import { guardSend, logSuppressedSend } from "../../notifications/send-guard.js";
8
- async function sendAnnouncement(ctx, toPhone, message, phoneNumberId, accessToken, label, orderId) {
9
- try {
10
- const sent = await sendWhatsAppBusinessMessage({
11
- toPhone,
12
- message,
13
- phoneNumberId,
14
- accessToken,
15
- });
16
- if (sent) {
17
- ctx.logger.info(`Announcement sent to ${label}`, {
18
- orderId,
19
- toPhone: `...${toPhone.slice(-4)}`,
20
- });
21
- }
22
- else {
23
- ctx.logger.error(`Announcement delivery failed for ${label} (non-fatal)`, null, { orderId });
24
- }
25
- }
26
- catch (err) {
27
- void normalizeError(err);
28
- ctx.logger.error(`Announcement send threw for ${label} (non-fatal)`, err, { orderId });
29
- }
30
- }
31
- async function notifyStoreOwner(ctx, storeId, message, phoneNumberId, accessToken, orderId) {
32
- try {
33
- const store = await storeRepository.findBySlug(storeId);
34
- if (!store?.ownerId)
35
- return;
36
- const owner = await userRepository.findById(store.ownerId);
37
- const encryptedPhone = owner?.phoneNumber;
38
- if (!encryptedPhone)
39
- return;
40
- const ownerPhone = decryptPii(encryptedPhone);
41
- if (ownerPhone) {
42
- await sendAnnouncement(ctx, ownerPhone, message, phoneNumberId, accessToken, "store-owner", orderId);
43
- }
44
- }
45
- catch (err) {
46
- void normalizeError(err);
47
- ctx.logger.error("Store owner lookup failed (non-fatal)", err, { orderId, storeId });
48
- }
49
- }
50
- /**
51
- * Seeded credentials are `*_PLACEHOLDER` strings rather than empty, so an
52
- * emptiness test alone would happily send with a fake token. Mirrors the guard
53
- * in checkout-value-otp-actions.ts.
54
- */
55
- function usable(value) {
56
- const v = value?.trim() ?? "";
57
- return v && !v.includes("PLACEHOLDER") ? v : "";
58
- }
59
- async function resolveWhatsAppCredentials(ctx, orderId) {
60
- let db = {};
61
- try {
62
- db = await resolveKeys();
63
- }
64
- catch (err) {
65
- void normalizeError(err);
66
- // Firestore unreachable from the Functions runtime — env-only is still a
67
- // valid configuration, so this is a warning, not a failure.
68
- ctx.logger.warn("Credential lookup failed — falling back to env", { orderId });
69
- }
70
- return {
71
- phoneNumberId: usable(db.whatsappPhoneNumberId) || usable(ctx.env("WHATSAPP_PHONE_NUMBER_ID")),
72
- accessToken: usable(db.whatsappCloudApiToken) || usable(ctx.env("WHATSAPP_CLOUD_API_TOKEN")),
73
- };
74
- }
3
+ import { adminNotificationsRepository } from "../../../../repositories/index.js";
4
+ import { buildPurchaseAnnouncementMessage } from "../../../../features/orders/utils/order-announcement-message.js";
75
5
  export async function handleOrderCreate(input, ctx) {
76
6
  const { orderId, order } = input;
77
7
  const items = order.items ?? [];
@@ -140,37 +70,13 @@ export async function handleOrderCreate(input, ctx) {
140
70
  ctx.logger.error("Failed to write admin order notification (non-fatal)", err, { orderId });
141
71
  }
142
72
  /*
143
- * Seller side keeps WhatsApp: it is ONE message to ONE person about their
144
- * own sale, which is the case the channel is good at. Credentials resolve
145
- * DB-first (Admin → Site Settings → WhatsApp) with an env fallback — this
146
- * read `ctx.env()` only until 2026-08-22, so credentials saved in Site
147
- * Settings never reached the announcement and it silently no-op'd.
73
+ * The seller is told about their own sale by the `order_placed`
74
+ * notification `createOrderForGroup` already sends (in-app + email). This
75
+ * handler used to ALSO push a WhatsApp Cloud API message here — a duplicate
76
+ * channel for the same event, and the only thing in this file that needed a
77
+ * Meta token, a registered number and a billing relationship. Removed with
78
+ * the rest of the WhatsApp integration on 2026-10-20; no signal was lost,
79
+ * because the notification path is independent and unchanged.
148
80
  */
149
- const storeId = order.storeId;
150
- if (!storeId) {
151
- ctx.logger.info("Order has no storeId — nothing to announce to a seller", { orderId });
152
- return;
153
- }
154
- const { phoneNumberId, accessToken } = await resolveWhatsAppCredentials(ctx, orderId);
155
- if (!phoneNumberId || !accessToken) {
156
- ctx.logger.info("WhatsApp Cloud API not configured — skipping seller announcement", { orderId });
157
- return;
158
- }
159
- // Audience is "user": a seller receiving news of their own sale is a
160
- // marketplace participant, not an operator of the site. Staff exemption is
161
- // for the digest and the payout summary, and widening it to "anyone we think
162
- // ought to see this" would make the kill switch mean nothing.
163
- const guard = {
164
- channel: "whatsapp",
165
- feature: "order_placed_seller",
166
- audience: "user",
167
- };
168
- const decision = await guardSend(guard);
169
- if (!decision.allow) {
170
- logSuppressedSend(guard, decision);
171
- ctx.logger.info("Seller order announcement suppressed by messaging guard", { orderId, storeId });
172
- return;
173
- }
174
- await notifyStoreOwner(ctx, storeId, message, phoneNumberId, accessToken, orderId);
175
- ctx.logger.info(`Order announcement complete`, { orderId, storeId });
81
+ ctx.logger.info("Order announcement complete", { orderId, storeId: order.storeId ?? null });
176
82
  }
@@ -29,7 +29,7 @@ import type { IconKey } from "../../../ui/icons/icon-registry";
29
29
  /** What KIND of action — drives default UI affordance (link vs button vs danger). */
30
30
  export type ActionKind = "primary" | "secondary" | "danger" | "ghost" | "link" | "chip";
31
31
  /** Resource bucket — keyed top-level in the ACTIONS tree. */
32
- export type ActionResource = "PRODUCT" | "AUCTION" | "PRE_ORDER" | "PRIZE_DRAW" | "DIGITAL_CODE" | "BUNDLE" | "GROUP" | "CATEGORY" | "BRAND" | "SUBLISTING" | "STORE" | "BLOG" | "EVENT" | "SHIPMENT" | "CATALOGUE" | "USER" | "SELLER" | "ADMIN" | "CART" | "CHECKOUT" | "NAV" | "MEDIA" | "SUPPORT" | "LOTTERY" | "TESTER";
32
+ export type ActionResource = "PRODUCT" | "AUCTION" | "PRE_ORDER" | "PRIZE_DRAW" | "DIGITAL_CODE" | "BUNDLE" | "GROUP" | "CATEGORY" | "BRAND" | "SUBLISTING" | "STORE" | "BLOG" | "EVENT" | "REVIEW" | "SHIPMENT" | "CATALOGUE" | "USER" | "SELLER" | "ADMIN" | "CART" | "CHECKOUT" | "NAV" | "MEDIA" | "SUPPORT" | "LOTTERY" | "TESTER";
33
33
  /** Confirmation modal config — when set, <Button action={...}> opens
34
34
  * a confirm dialog before firing the actual handler. */
35
35
  export interface ActionConfirmation {
@@ -111,6 +111,28 @@ export const ACTIONS = {
111
111
  confirmLabel: "Delete listing",
112
112
  },
113
113
  },
114
+ /*
115
+ * Clone — ADMIN ONLY, and no confirmation.
116
+ *
117
+ * `POST /api/store/products/[id]/duplicate` already strips identity,
118
+ * analytics, auction bid state and group membership, titles the copy
119
+ * "… (Copy)" and forces `status: "draft"`. So it creates a draft and
120
+ * destroys nothing: a confirm dialog on a non-destructive action trains
121
+ * people to dismiss confirm dialogs.
122
+ *
123
+ * 🛑 `permissions: ["admin"]` is narrower than `edit`/`delete` above on
124
+ * purpose. A seller editing or deleting their own listing is ordinary;
125
+ * duplicating across the catalogue is a staff tool.
126
+ */
127
+ duplicate: {
128
+ iconKey: "duplicate",
129
+ id: "product.duplicate",
130
+ label: "Duplicate",
131
+ ariaLabel: "Duplicate this listing as a draft",
132
+ description: "Copy this listing into a new draft, without its bids, analytics or group membership.",
133
+ kind: "secondary",
134
+ permissions: ["admin"],
135
+ },
114
136
  "add-to-cart": {
115
137
  iconKey: "cart",
116
138
  id: "product.add-to-cart",
@@ -321,6 +343,43 @@ export const ACTIONS = {
321
343
  kind: "primary",
322
344
  categoryTypeScope: ["bundle"],
323
345
  },
346
+ /*
347
+ * Edit and delete lived only in the ADMIN bucket (`delete-bundle`,
348
+ * `activate-bundle`, …), so a bundle's own surfaces had nothing
349
+ * resource-scoped to resolve against. Added here beside `buy-now` so the
350
+ * owner affordance keys on the resource it is acting on, like every other
351
+ * bucket.
352
+ *
353
+ * 🛑 No `duplicate`. A bundle's members, `bundleCategorySlugs` and
354
+ * `display.mosaicImages` are all resolved at WRITE time, so a copy would
355
+ * carry another bundle's resolved state.
356
+ */
357
+ edit: {
358
+ iconKey: "edit",
359
+ id: "bundle.edit",
360
+ label: "Edit",
361
+ ariaLabel: "Edit bundle",
362
+ description: "Edit this bundle.",
363
+ kind: "secondary",
364
+ permissions: ["admin", "seller"],
365
+ categoryTypeScope: ["bundle"],
366
+ },
367
+ delete: {
368
+ iconKey: "delete",
369
+ id: "bundle.delete",
370
+ label: "Delete",
371
+ ariaLabel: "Delete bundle",
372
+ description: "Permanently delete this bundle.",
373
+ kind: "danger",
374
+ permissions: ["admin", "seller"],
375
+ categoryTypeScope: ["bundle"],
376
+ confirmation: {
377
+ title: "Delete this bundle?",
378
+ body: "This cannot be undone. The products inside it are NOT deleted — only the bundle that groups them and its discounted price.",
379
+ confirmLabel: "Delete bundle",
380
+ confirmKind: "danger",
381
+ },
382
+ },
324
383
  },
325
384
  /*
326
385
  * These four buckets were literally `{}` for months while their editors
@@ -1042,7 +1101,76 @@ export const ACTIONS = {
1042
1101
  },
1043
1102
  },
1044
1103
  },
1104
+ /*
1105
+ * There was no REVIEW bucket at all, while review actions sat scattered
1106
+ * across three others — ADMIN (approve / reject / feature), STORE (reply /
1107
+ * contest) and USER (write-review) — and NOTHING anywhere for a reviewer
1108
+ * editing or deleting their own review. That is the gap a buyer hits on a
1109
+ * product page, so it is the one this bucket is for.
1110
+ */
1111
+ REVIEW: {
1112
+ edit: {
1113
+ iconKey: "edit",
1114
+ id: "review.edit",
1115
+ label: "Edit review",
1116
+ ariaLabel: "Edit your review",
1117
+ description: "Edit a review you wrote.",
1118
+ kind: "secondary",
1119
+ },
1120
+ delete: {
1121
+ iconKey: "delete",
1122
+ id: "review.delete",
1123
+ label: "Delete review",
1124
+ ariaLabel: "Delete your review",
1125
+ description: "Permanently delete a review you wrote.",
1126
+ kind: "danger",
1127
+ confirmation: {
1128
+ title: "Delete your review?",
1129
+ body: "This cannot be undone. The seller's reply to it goes too, and the product's rating is recalculated without your score.",
1130
+ confirmLabel: "Delete review",
1131
+ confirmKind: "danger",
1132
+ },
1133
+ },
1134
+ write: {
1135
+ iconKey: "review",
1136
+ id: "review.write",
1137
+ label: "Write a review",
1138
+ ariaLabel: "Write a review for this item",
1139
+ description: "Write a review for an item you bought.",
1140
+ kind: "primary",
1141
+ },
1142
+ },
1045
1143
  EVENT: {
1144
+ /*
1145
+ * This bucket had only register / cancel-registration / draw-raffle-winner
1146
+ * — no edit, no delete — so the owner affordance on an event card or detail
1147
+ * page had no registry entry to resolve its label and confirm copy from,
1148
+ * and Rule #7 forbids inlining them.
1149
+ */
1150
+ edit: {
1151
+ iconKey: "edit",
1152
+ id: "event.edit",
1153
+ label: "Edit",
1154
+ ariaLabel: "Edit event",
1155
+ description: "Edit this event.",
1156
+ kind: "secondary",
1157
+ permissions: ["admin"],
1158
+ },
1159
+ delete: {
1160
+ iconKey: "delete",
1161
+ id: "event.delete",
1162
+ label: "Delete",
1163
+ ariaLabel: "Delete event",
1164
+ description: "Permanently delete this event.",
1165
+ kind: "danger",
1166
+ permissions: ["admin"],
1167
+ confirmation: {
1168
+ title: "Delete this event?",
1169
+ body: "This cannot be undone. Entries already recorded against it are removed with it, so a participant loses their place.",
1170
+ confirmLabel: "Delete event",
1171
+ confirmKind: "danger",
1172
+ },
1173
+ },
1046
1174
  "register": {
1047
1175
  iconKey: "user",
1048
1176
  id: "event.register",
@@ -72,7 +72,7 @@ export declare const BUNDLE_COPY: {
72
72
  readonly adminEditor: {
73
73
  readonly loading: "Loading bundle…";
74
74
  readonly saveButton: (saving: boolean, isEdit: boolean) => "Save changes" | "Saving…" | "Create bundle";
75
- readonly deleteButton: (deleting: boolean) => "Deleting…" | "Delete bundle";
75
+ readonly deleteButton: (deleting: boolean) => "Delete bundle" | "Deleting…";
76
76
  readonly deleteConfirm: "Delete this bundle? This action cannot be undone.";
77
77
  readonly fields: {
78
78
  readonly nameLabel: "Name *";