@mohasinac/appkit 4.53.1 → 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.
- package/dist/.build-stamp.json +5 -5
- package/dist/_internal/client/theme/ThemeProvider.js +15 -1
- package/dist/_internal/server/features/auth/capabilities.js +7 -5
- package/dist/_internal/server/features/categories/price-guidance-resolve.d.ts +66 -0
- package/dist/_internal/server/features/categories/price-guidance-resolve.js +138 -0
- package/dist/_internal/server/features/checkout/supplier-identity.js +28 -6
- package/dist/_internal/server/features/price-index/market.d.ts +57 -0
- package/dist/_internal/server/features/price-index/market.js +120 -0
- package/dist/_internal/server/features/products/list-public.d.ts +12 -0
- package/dist/_internal/server/features/products/list-public.js +10 -1
- package/dist/_internal/server/features/products/listing-tab-counts.d.ts +25 -1
- package/dist/_internal/server/features/products/listing-tab-counts.js +184 -10
- package/dist/_internal/server/features/search/index-build/sources.js +43 -2
- package/dist/_internal/server/features/stores/tabs.d.ts +7 -2
- package/dist/_internal/server/features/stores/tabs.js +5 -3
- package/dist/_internal/server/jobs/core/jobRunners.js +0 -10
- package/dist/_internal/server/jobs/core/onOrderCreate.js +10 -104
- package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
- package/dist/_internal/shared/actions/action-registry.js +128 -0
- package/dist/_internal/shared/features/categories/bundle-copy.d.ts +1 -1
- package/dist/_internal/shared/features/site-settings/supplier-of-record.d.ts +56 -0
- package/dist/_internal/shared/features/site-settings/supplier-of-record.js +57 -0
- package/dist/_internal/shared/listing-types/tiers.d.ts +94 -0
- package/dist/_internal/shared/listing-types/tiers.js +150 -0
- package/dist/_internal/shared/search/match.js +28 -2
- package/dist/cli/index.js +0 -2
- package/dist/client.d.ts +21 -2
- package/dist/client.js +23 -4
- package/dist/constants/api-endpoints.d.ts +28 -0
- package/dist/constants/api-endpoints.js +14 -0
- package/dist/features/account/components/UserOrdersView.js +26 -7
- package/dist/features/addresses/utils/postal-autofill.d.ts +39 -0
- package/dist/features/addresses/utils/postal-autofill.js +54 -0
- package/dist/features/admin/components/AdminStoreEditorView.js +73 -34
- package/dist/features/admin/components/OwnerQuickActions.d.ts +29 -0
- package/dist/features/admin/components/OwnerQuickActions.js +142 -0
- package/dist/features/admin/schemas/admin-editor-forms.d.ts +1 -1
- package/dist/features/admin/schemas/admin-editor-forms.js +4 -1
- package/dist/features/auctions/components/AuctionDetailPageView.js +8 -7
- package/dist/features/auctions/components/MarketplaceAuctionCard.d.ts +11 -1
- package/dist/features/auctions/components/MarketplaceAuctionCard.js +9 -2
- package/dist/features/auth/permissions/constants.d.ts +29 -2
- package/dist/features/auth/permissions/constants.js +68 -1
- package/dist/features/blog/components/BlogFeaturedCard.d.ts +11 -1
- package/dist/features/blog/components/BlogFeaturedCard.js +9 -2
- package/dist/features/cart/components/CartDrawer.js +3 -2
- package/dist/features/cart/components/CartGroupLineRow.js +2 -1
- package/dist/features/cart/hooks/useAddToCart.js +10 -5
- package/dist/features/cart/utils/pending-ops.d.ts +11 -1
- package/dist/features/cart/utils/pending-ops.js +14 -2
- package/dist/features/categories/api/[id]/price-guidance/route.js +1 -24
- package/dist/features/categories/components/BrandDetailPageView.js +15 -6
- package/dist/features/categories/components/BrandDetailTabs.d.ts +7 -1
- package/dist/features/categories/components/BrandDetailTabs.js +18 -3
- package/dist/features/categories/components/BundleCollage.js +3 -2
- package/dist/features/categories/components/BundleItemsPicker.js +3 -4
- package/dist/features/categories/components/CategoryDetailPageView.js +19 -8
- package/dist/features/categories/components/CategoryDetailTabs.d.ts +7 -1
- package/dist/features/categories/components/CategoryDetailTabs.js +29 -7
- package/dist/features/categories/components/CategoryGrid.d.ts +11 -1
- package/dist/features/categories/components/CategoryGrid.js +9 -2
- package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +10 -5
- package/dist/features/checkout/actions/checkout-value-otp-actions.js +2 -88
- package/dist/features/classified/components/ClassifiedDetailPageView.js +10 -1
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +10 -1
- package/dist/features/events/components/EventCard.d.ts +11 -1
- package/dist/features/events/components/EventCard.js +9 -2
- package/dist/features/layout/AppLayoutShell.js +2 -1
- package/dist/features/layout/BottomActions.d.ts +1 -0
- package/dist/features/layout/BottomActions.js +330 -0
- package/dist/features/layout/BottomChrome.d.ts +45 -0
- package/dist/features/layout/BottomChrome.js +5 -17
- package/dist/features/layout/SideActionDock.js +36 -13
- package/dist/features/layout/index.d.ts +2 -7
- package/dist/features/layout/index.js +7 -7
- package/dist/features/layout/useIsDesktop.d.ts +36 -0
- package/dist/features/layout/useIsDesktop.js +48 -0
- package/dist/features/live/components/LiveItemDetailPageView.js +10 -1
- package/dist/features/media/messages/en.json +67 -67
- package/dist/features/orders/components/YourPurchaseStrip.d.ts +24 -0
- package/dist/features/orders/components/YourPurchaseStrip.js +77 -0
- package/dist/features/orders/components/index.d.ts +2 -1
- package/dist/features/orders/components/index.js +1 -1
- package/dist/features/orders/utils/order-announcement-message.d.ts +18 -0
- package/dist/features/orders/utils/order-announcement-message.js +16 -0
- package/dist/features/orders/utils/payment-proof-message.d.ts +20 -0
- package/dist/features/orders/utils/payment-proof-message.js +17 -0
- package/dist/features/parts/schemas/firestore.d.ts +227 -0
- package/dist/features/parts/schemas/firestore.js +6 -6
- package/dist/features/pre-orders/components/MarketplacePreorderCard.d.ts +11 -1
- package/dist/features/pre-orders/components/MarketplacePreorderCard.js +10 -3
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +8 -7
- package/dist/features/price-index/components/PriceIndexToolView.d.ts +21 -0
- package/dist/features/price-index/components/PriceIndexToolView.js +230 -27
- package/dist/features/price-index/lookup-target.d.ts +48 -0
- package/dist/features/price-index/lookup-target.js +107 -0
- package/dist/features/products/api/[id]/route.js +8 -1
- package/dist/features/products/api/route.js +16 -4
- package/dist/features/products/components/AvailabilityScopeHint.d.ts +22 -0
- package/dist/features/products/components/AvailabilityScopeHint.js +27 -0
- package/dist/features/products/components/CompareOverlay.d.ts +2 -1
- package/dist/features/products/components/CompareOverlay.js +2 -1
- package/dist/features/products/components/GroupSettingsPanel.js +2 -1
- package/dist/features/products/components/InteractiveProductCard.d.ts +7 -1
- package/dist/features/products/components/InteractiveProductCard.js +16 -4
- package/dist/features/products/components/MarketplaceBundleCard.d.ts +11 -1
- package/dist/features/products/components/MarketplaceBundleCard.js +9 -2
- package/dist/features/products/components/MarketplacePrizeDrawCard.d.ts +11 -1
- package/dist/features/products/components/MarketplacePrizeDrawCard.js +9 -2
- package/dist/features/products/components/ProductDetailPageView.js +34 -4
- package/dist/features/products/components/ProductGrid.d.ts +14 -1
- package/dist/features/products/components/ProductGrid.js +22 -5
- package/dist/features/products/components/ProductsIndexListing.js +2 -2
- package/dist/features/products/components/SublistingCarouselSection.js +2 -1
- package/dist/features/products/repository/products.repository.d.ts +1 -0
- package/dist/features/products/repository/products.repository.js +5 -1
- package/dist/features/products/utils/group-member-image.d.ts +9 -2
- package/dist/features/products/utils/group-member-image.js +2 -1
- package/dist/features/reviews/api/[id]/route.js +83 -23
- package/dist/features/reviews/components/PurchaseReviewAffordance.d.ts +6 -0
- package/dist/features/reviews/components/PurchaseReviewAffordance.js +42 -0
- package/dist/features/reviews/components/ReviewFormDrawer.d.ts +34 -0
- package/dist/features/reviews/components/ReviewFormDrawer.js +127 -0
- package/dist/features/reviews/components/index.d.ts +4 -0
- package/dist/features/reviews/components/index.js +2 -0
- package/dist/features/seller/actions/seller-actions.d.ts +1 -1
- package/dist/features/seller/actions/seller-actions.js +49 -3
- package/dist/features/seller/components/SellerPayoutSettingsView.js +30 -2
- package/dist/features/seller/components/SellerProductShell.d.ts +17 -1
- package/dist/features/seller/components/SellerProductsView.js +3 -2
- package/dist/features/seller/hooks/useStoreListingEntitlements.d.ts +8 -0
- package/dist/features/seller/hooks/useStoreListingEntitlements.js +70 -0
- package/dist/features/stores/components/InteractiveStoreCard.d.ts +11 -1
- package/dist/features/stores/components/InteractiveStoreCard.js +9 -2
- package/dist/features/stores/components/StoreAuctionsListing.js +2 -1
- package/dist/features/stores/components/index.d.ts +0 -2
- package/dist/features/stores/components/index.js +0 -1
- package/dist/features/stores/schemas/index.d.ts +1 -1
- package/dist/features/stores/schemas/index.js +10 -1
- package/dist/features/tester/seed-data/authored/content-discovery__authenticity-checker.js +114 -2
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +415 -0
- package/dist/index.d.ts +8 -27
- package/dist/index.js +14 -35
- package/dist/next/routing/route-map.d.ts +2 -0
- package/dist/next/routing/route-map.js +9 -0
- package/dist/providers/shipping-shiprocket/index.d.ts +98 -0
- package/dist/providers/shipping-shiprocket/index.js +353 -0
- package/dist/schemas/registry.d.ts +2 -2
- package/dist/security/csp.d.ts +16 -1
- package/dist/security/csp.js +5 -3
- package/dist/seed/_helpers/category-forest.js +27 -0
- package/dist/seed/_helpers/category-parts-glossary-map.js +1816 -86
- package/dist/seed/_helpers/category-parts.d.ts +1 -1
- package/dist/seed/_helpers/category-parts.js +13412 -3020
- package/dist/seed/categories-seed-data.js +48 -6
- package/dist/seed/homepage-sections-seed-data.js +41 -28
- package/dist/seed/model-composition-seed-data.js +6 -8
- package/dist/seed/part-glossary/chunk0.d.ts +9 -0
- package/dist/seed/part-glossary/chunk0.js +35044 -0
- package/dist/seed/part-glossary/chunk1.d.ts +9 -0
- package/dist/seed/part-glossary/chunk1.js +39955 -0
- package/dist/seed/part-glossary/chunk2.d.ts +9 -0
- package/dist/seed/part-glossary/chunk2.js +50335 -0
- package/dist/seed/part-glossary/chunk3.d.ts +9 -0
- package/dist/seed/part-glossary/chunk3.js +52386 -0
- package/dist/seed/part-glossary/chunk4.d.ts +9 -0
- package/dist/seed/part-glossary/chunk4.js +50549 -0
- package/dist/seed/part-glossary-seed-data.d.ts +1 -1
- package/dist/seed/part-glossary-seed-data.js +23 -47483
- package/dist/seed/products-beyblade-x-seed-data.js +36 -1
- package/dist/server.d.ts +6 -8
- package/dist/server.js +16 -20
- package/dist/styles.css +516 -358
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/themes/index.d.ts +3 -1
- package/dist/tokens/themes/index.js +11 -1
- package/dist/tokens/themes/technical-dark.d.ts +30 -0
- package/dist/tokens/themes/technical-dark.js +103 -0
- package/dist/tokens/themes/technical-light.d.ts +22 -0
- package/dist/tokens/themes/technical-light.js +89 -0
- package/dist/tokens/tokens.css +155 -0
- package/dist/ui/components/GatedPrice.js +30 -3
- package/dist/ui/components/GatedPrice.style.css +22 -19
- package/dist/ui/components/QuickCreateModal.style.css +78 -78
- package/dist/ui/forms/FormShell.style.css +260 -260
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +133 -27
- package/dist/_internal/server/jobs/core/whatsappCatalogImport.d.ts +0 -27
- package/dist/_internal/server/jobs/core/whatsappCatalogImport.js +0 -172
- package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +0 -35
- package/dist/_internal/server/jobs/core/whatsappNotify.js +0 -140
- package/dist/features/admin/components/AdminWhatsAppGuideView.d.ts +0 -2
- package/dist/features/admin/components/AdminWhatsAppGuideView.js +0 -35
- package/dist/features/orders/components/MarketplaceOrderCard.d.ts +0 -36
- package/dist/features/orders/components/MarketplaceOrderCard.js +0 -57
- package/dist/features/stores/components/StoreWhatsAppGuideView.d.ts +0 -3
- package/dist/features/stores/components/StoreWhatsAppGuideView.js +0 -12
- package/dist/features/whatsapp-bot/api/send-status/route.d.ts +0 -12
- package/dist/features/whatsapp-bot/api/send-status/route.js +0 -38
- package/dist/features/whatsapp-bot/api/webhook/route.d.ts +0 -12
- package/dist/features/whatsapp-bot/api/webhook/route.js +0 -43
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +0 -6
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +0 -270
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +0 -9
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +0 -7
- package/dist/features/whatsapp-bot/components/index.d.ts +0 -3
- package/dist/features/whatsapp-bot/components/index.js +0 -2
- package/dist/features/whatsapp-bot/helpers/payment-proof-message.d.ts +0 -14
- package/dist/features/whatsapp-bot/helpers/payment-proof-message.js +0 -17
- package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +0 -52
- package/dist/features/whatsapp-bot/helpers/whatsapp.js +0 -283
- package/dist/features/whatsapp-bot/index.d.ts +0 -3
- package/dist/features/whatsapp-bot/index.js +0 -3
- package/dist/features/whatsapp-bot/manifest.d.ts +0 -2
- package/dist/features/whatsapp-bot/manifest.js +0 -13
- package/dist/features/whatsapp-bot/messages/en.json +0 -7
- package/dist/features/whatsapp-bot/server.d.ts +0 -7
- package/dist/features/whatsapp-bot/server.js +0 -7
- package/dist/features/whatsapp-bot/types/index.d.ts +0 -126
- package/dist/features/whatsapp-bot/types/index.js +0 -1
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* the tab bar renders, so a tenth listing type is counted automatically.
|
|
12
12
|
*
|
|
13
13
|
* EXPORTS:
|
|
14
|
-
* ListingTabScope
|
|
15
|
-
* ListingTabCounts
|
|
16
|
-
*
|
|
14
|
+
* ListingTabScope — the surface's scope (category / brand / store)
|
|
15
|
+
* ListingTabCounts — tabSlug -> count, `undefined` meaning "unknown"
|
|
16
|
+
* ListingTabCountResult — the AVAILABLE and TOTAL maps, see below
|
|
17
|
+
* listingTabCounts — resolve every tab's counts in one parallel batch
|
|
17
18
|
*
|
|
18
19
|
* @tag domain:products,categories,stores
|
|
19
20
|
* @tag layer:server
|
|
@@ -25,6 +26,11 @@
|
|
|
25
26
|
import { categoriesRepository, productRepository } from "../../../../repositories/index.js";
|
|
26
27
|
import { serverLogger } from "../../../../monitoring/index.js";
|
|
27
28
|
import { normalizeError } from "../../../../errors/normalize.js";
|
|
29
|
+
import { sieveAnd, sieveFilter, SIEVE_OP, sortBy } from "../../../../utils/sieve-builder.js";
|
|
30
|
+
import { PRODUCT_FIELDS } from "../../../../constants/field-names.js";
|
|
31
|
+
import { isListingRowAvailable, pluginFor, } from "../../../shared/listing-types/_registry.js";
|
|
32
|
+
import { normalizeListingType } from "../../../../features/products/utils/listing-type.js";
|
|
33
|
+
import { clauseToSieve, PUBLIC_PRODUCT_MAX_PAGE_SIZE } from "./list-public.js";
|
|
28
34
|
const DEFAULT_STATUS = "published";
|
|
29
35
|
/** Bundles a category owns, resolved by the same rule the bundles tab renders. */
|
|
30
36
|
async function countBundles(scope) {
|
|
@@ -65,6 +71,154 @@ function bundleMatchesScope(bundle, scope) {
|
|
|
65
71
|
}
|
|
66
72
|
return true;
|
|
67
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* How many rows in scope are NOT available, per listing type.
|
|
76
|
+
*
|
|
77
|
+
* ── 🛑 WHY THIS IS NOT AN AGGREGATE COUNT ─────────────────────────────────
|
|
78
|
+
*
|
|
79
|
+
* Two independent reasons, either of which alone is disqualifying.
|
|
80
|
+
*
|
|
81
|
+
* 1. Unavailability is an OR across DIFFERENT fields (`availableQuantity == 0`
|
|
82
|
+
* OR `isSold == true` OR `prizeRevealStatus == closed` …). Firestore cannot
|
|
83
|
+
* express that in one query, and counting each clause separately and adding
|
|
84
|
+
* them double-counts every row matching two of them.
|
|
85
|
+
*
|
|
86
|
+
* 2. A clause selects a SUPERSET, deliberately. `madeToOrder` is pointedly NOT
|
|
87
|
+
* a clause (see the note in `availability.ts`), so a print-on-demand sticker
|
|
88
|
+
* sitting at `availableQuantity: 0` matches `availableQuantity == 0` and is
|
|
89
|
+
* nevertheless AVAILABLE. Only `isListingRowAvailable` knows that, and it is
|
|
90
|
+
* a per-row predicate. Measured on the category that prompted this work,
|
|
91
|
+
* a clause-count would have badged "Art & Stickers 2" over three available
|
|
92
|
+
* rows — trading one wrong number for another.
|
|
93
|
+
*
|
|
94
|
+
* So this is Path C of `listPublicProducts` reused in miniature: one bounded
|
|
95
|
+
* query per DISTINCT clause, in parallel, merged and deduped by id, then every
|
|
96
|
+
* surviving row confirmed with the real predicate. Nine listing types collapse
|
|
97
|
+
* to five clauses, so the whole tab bar costs ~5 × 50 documents in one parallel
|
|
98
|
+
* round — the same fan-out and the same budget that scope already spends.
|
|
99
|
+
*
|
|
100
|
+
* 🛑 The queries are NOT narrowed to the tab's listing types. Firestore permits
|
|
101
|
+
* one array operator per query and the category scope already spends it on
|
|
102
|
+
* `categorySlugs array-contains-any`, so adding `listingType in (…)` risks an
|
|
103
|
+
* INVALID_ARGUMENT for no gain: unavailable rows are sparse, and tallying each
|
|
104
|
+
* row by its own `listingType` afterwards answers every tab from one pass.
|
|
105
|
+
*/
|
|
106
|
+
async function unavailableByType(scope, types, now) {
|
|
107
|
+
const byType = new Map();
|
|
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 };
|
|
124
|
+
const status = scope.status ?? DEFAULT_STATUS;
|
|
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
|
+
*/
|
|
158
|
+
const sorts = clause.op === "lt" && clause.sortField
|
|
159
|
+
? sortBy(clause.sortField, "DESC")
|
|
160
|
+
: undefined;
|
|
161
|
+
try {
|
|
162
|
+
const result = await productRepository.list({
|
|
163
|
+
filters: sieveAnd(sieveFilter(PRODUCT_FIELDS.STATUS, SIEVE_OP.EQ, status), clauseToSieve(clause, now)),
|
|
164
|
+
...(sorts ? { sorts } : {}),
|
|
165
|
+
page: 1,
|
|
166
|
+
pageSize: PUBLIC_PRODUCT_MAX_PAGE_SIZE,
|
|
167
|
+
}, {
|
|
168
|
+
storeId: scope.storeId,
|
|
169
|
+
categoriesIn: scope.categoriesIn,
|
|
170
|
+
brandName: scope.brandName,
|
|
171
|
+
});
|
|
172
|
+
return { entry, result };
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
const e = normalizeError(error);
|
|
176
|
+
// ERROR, not warn: a clause that cannot run is almost always a missing
|
|
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 };
|
|
187
|
+
}
|
|
188
|
+
}));
|
|
189
|
+
const merged = new Map();
|
|
190
|
+
for (const { entry, result } of results) {
|
|
191
|
+
if (!result) {
|
|
192
|
+
for (const type of entry.types)
|
|
193
|
+
unknown.add(type);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const rows = result.items;
|
|
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
|
+
}
|
|
203
|
+
for (const row of rows) {
|
|
204
|
+
const id = typeof row.id === "string" ? row.id : JSON.stringify(row.id);
|
|
205
|
+
if (!merged.has(id))
|
|
206
|
+
merged.set(id, row);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (const row of merged.values()) {
|
|
210
|
+
// The clause matched a superset; this is the real answer.
|
|
211
|
+
if (isListingRowAvailable(row, now))
|
|
212
|
+
continue;
|
|
213
|
+
const type = normalizeListingType(row);
|
|
214
|
+
byType.set(type, (byType.get(type) ?? 0) + 1);
|
|
215
|
+
}
|
|
216
|
+
return { byType, unknown };
|
|
217
|
+
}
|
|
218
|
+
/** The listing types one tab spans — `"art|stickers"` is a pipe OR-group. */
|
|
219
|
+
function typesOf(tab) {
|
|
220
|
+
return String(tab.listingType).split("|").filter(Boolean);
|
|
221
|
+
}
|
|
68
222
|
/**
|
|
69
223
|
* Count every tab in `tabs` within `scope`, in one parallel batch.
|
|
70
224
|
*
|
|
@@ -75,10 +229,12 @@ function bundleMatchesScope(bundle, scope) {
|
|
|
75
229
|
* `stores` is not counted here: only the category page has that tab and it
|
|
76
230
|
* already resolves its store list eagerly to render the tab body.
|
|
77
231
|
*/
|
|
78
|
-
export async function listingTabCounts(tabs, scope) {
|
|
232
|
+
export async function listingTabCounts(tabs, scope, opts) {
|
|
233
|
+
const now = opts?.now ?? new Date();
|
|
79
234
|
const listingTabs = tabs.filter((tab) => Boolean(tab.listingType));
|
|
80
235
|
const wantsBundles = tabs.some((tab) => tab.collection === "bundles");
|
|
81
|
-
const
|
|
236
|
+
const allTypes = [...new Set(listingTabs.flatMap(typesOf))];
|
|
237
|
+
const [typeCounts, bundleCount, unavailable] = await Promise.all([
|
|
82
238
|
productRepository.countByListingTypes(listingTabs.map((tab) => String(tab.listingType)), {
|
|
83
239
|
status: scope.status ?? DEFAULT_STATUS,
|
|
84
240
|
storeId: scope.storeId,
|
|
@@ -86,12 +242,30 @@ export async function listingTabCounts(tabs, scope) {
|
|
|
86
242
|
brandName: scope.brandName,
|
|
87
243
|
}),
|
|
88
244
|
wantsBundles ? countBundles(scope) : Promise.resolve(undefined),
|
|
245
|
+
unavailableByType(scope, allTypes, now),
|
|
89
246
|
]);
|
|
90
|
-
const
|
|
247
|
+
const total = {};
|
|
248
|
+
const available = {};
|
|
91
249
|
for (const tab of listingTabs) {
|
|
92
|
-
|
|
250
|
+
const tabTypes = typesOf(tab);
|
|
251
|
+
const totalForTab = typeCounts[String(tab.listingType)];
|
|
252
|
+
total[tab.id] = totalForTab;
|
|
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.
|
|
258
|
+
available[tab.id] = totalForTab;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const gone = tabTypes.reduce((sum, type) => sum + (unavailable.byType.get(type) ?? 0), 0);
|
|
262
|
+
available[tab.id] = Math.max(0, totalForTab - gone);
|
|
263
|
+
}
|
|
264
|
+
if (wantsBundles) {
|
|
265
|
+
// A bundle has no availability scope of its own — the bundles tab renders
|
|
266
|
+
// active bundle ROWS, not products, so the two numbers are one number.
|
|
267
|
+
total.bundles = bundleCount;
|
|
268
|
+
available.bundles = bundleCount;
|
|
93
269
|
}
|
|
94
|
-
|
|
95
|
-
counts.bundles = bundleCount;
|
|
96
|
-
return counts;
|
|
270
|
+
return { available, total };
|
|
97
271
|
}
|
|
@@ -195,7 +195,26 @@ export const PUBLIC_INDEX_SOURCES = [
|
|
|
195
195
|
{
|
|
196
196
|
typeKey: "part",
|
|
197
197
|
collection: "partGlossary",
|
|
198
|
-
|
|
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
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isListingTypeEnabled } from "../../../shared/listing-types/feature-flags";
|
|
2
|
-
import { type
|
|
2
|
+
import { type ListingTabCountResult } from "../products/listing-tab-counts";
|
|
3
3
|
type SiteSettingsSnapshot = Parameters<typeof isListingTypeEnabled>[1];
|
|
4
4
|
export interface StoreTabLink {
|
|
5
5
|
value: string;
|
|
@@ -11,7 +11,12 @@ export interface ResolvedStoreTabs {
|
|
|
11
11
|
dropdownTabs: StoreTabLink[];
|
|
12
12
|
/** Coupons (if any), Reviews (if any), About — always non-empty. */
|
|
13
13
|
tabs: StoreTabLink[];
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Both numbers, because they answer different questions: `available` is what
|
|
16
|
+
* each tab badges, `total` is what decides whether the tab exists at all.
|
|
17
|
+
* See `ListingTabCountResult`.
|
|
18
|
+
*/
|
|
19
|
+
listingCounts: ListingTabCountResult;
|
|
15
20
|
couponsCount: number;
|
|
16
21
|
reviewsCount: number;
|
|
17
22
|
}
|
|
@@ -73,7 +73,7 @@ export async function resolveStoreTabs(storeSlug, storeId, settings) {
|
|
|
73
73
|
.then((r) => r.total)
|
|
74
74
|
.catch(() => 0),
|
|
75
75
|
])
|
|
76
|
-
: [{}, 0, 0];
|
|
76
|
+
: [{ available: {}, total: {} }, 0, 0];
|
|
77
77
|
const visibleStoreTabs = STORE_PAGE_TABS.filter((tab) => {
|
|
78
78
|
if (tab.id === "bundles") {
|
|
79
79
|
if (!isCategoryTypeEnabled("bundle", settings))
|
|
@@ -92,12 +92,14 @@ export async function resolveStoreTabs(storeSlug, storeId, settings) {
|
|
|
92
92
|
// that leads to an empty page. `undefined` means the count query FAILED,
|
|
93
93
|
// not zero, so the tab stays visible rather than hiding real stock
|
|
94
94
|
// behind a swallowed error (Root Cause #59).
|
|
95
|
-
|
|
95
|
+
// TOTAL, not the badge: a tab whose rows have all ended still has an
|
|
96
|
+
// archive behind the Ended/All scopes, and hiding it would strand that.
|
|
97
|
+
const count = listingCounts.total[tab.id];
|
|
96
98
|
return count === undefined || count > 0;
|
|
97
99
|
});
|
|
98
100
|
const dropdownTabs = visibleStoreTabs.map((tab) => ({
|
|
99
101
|
value: tab.id,
|
|
100
|
-
label: tabLabel(tab.label, listingCounts[tab.id]),
|
|
102
|
+
label: tabLabel(tab.label, listingCounts.available[tab.id]),
|
|
101
103
|
href: STORE_LISTING_HREF[tab.id](storeSlug),
|
|
102
104
|
}));
|
|
103
105
|
const tabs = [
|
|
@@ -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
|
|
4
|
-
import {
|
|
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
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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
|
-
|
|
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",
|