@mohasinac/appkit 4.30.17 → 4.30.19
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/_internal/client/features/digital-code/CodeRevealPanel.js +5 -2
- package/dist/_internal/client/features/layout/DashboardLayoutClient.js +3 -3
- package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +3 -2
- package/dist/_internal/client/features/maintenance/views/AnalysisRunnerView.js +5 -2
- package/dist/_internal/client/features/maintenance/views/CloudLogsListView.js +11 -3
- package/dist/_internal/client/hand-mode/HandModeProvider.js +2 -0
- package/dist/_internal/server/features/auctions/actions.js +14 -3
- package/dist/_internal/server/features/auctions/service.js +1 -1
- package/dist/_internal/server/features/blog/data.js +2 -2
- package/dist/_internal/server/features/blog/service.js +3 -3
- package/dist/_internal/server/features/brands/data.js +1 -1
- package/dist/_internal/server/features/brands/service.js +3 -3
- package/dist/_internal/server/features/bundles/actions.js +6 -1
- package/dist/_internal/server/features/bundles/data.js +21 -4
- package/dist/_internal/server/features/cart/data.js +3 -1
- package/dist/_internal/server/features/cart/service.js +13 -2
- package/dist/_internal/server/features/categories/data.js +12 -3
- package/dist/_internal/server/features/events/actions.js +17 -4
- package/dist/_internal/server/features/events/data.js +1 -1
- package/dist/_internal/server/features/events/service.js +1 -1
- package/dist/_internal/server/features/grouped/data.js +7 -2
- package/dist/_internal/server/features/item-requests/data.js +1 -1
- package/dist/_internal/server/features/lottery/actions.js +2 -2
- package/dist/_internal/server/features/lottery/data.js +1 -1
- package/dist/_internal/server/features/orders/actions.js +7 -7
- package/dist/_internal/server/features/orders/data.d.ts +1 -1
- package/dist/_internal/server/features/orders/data.js +14 -2
- package/dist/_internal/server/features/orders/service.js +1 -1
- package/dist/_internal/server/features/pre-orders/service.js +1 -1
- package/dist/_internal/server/features/prize-draws/actions.js +1 -1
- package/dist/_internal/server/features/products/data.js +7 -2
- package/dist/_internal/server/features/products/service.js +1 -1
- package/dist/_internal/server/features/promotions/actions.js +1 -1
- package/dist/_internal/server/features/promotions/data.js +1 -1
- package/dist/_internal/server/features/promotions/service.js +1 -1
- package/dist/_internal/server/features/reviews/data.d.ts +1 -1
- package/dist/_internal/server/features/reviews/data.js +6 -1
- package/dist/_internal/server/features/reviews/og-data.js +1 -1
- package/dist/_internal/server/features/shared/listing-data-factory.js +7 -2
- package/dist/_internal/server/features/stores/data.js +1 -1
- package/dist/_internal/server/features/sublisting-categories/data.js +1 -1
- package/dist/_internal/server/jobs/core/auctionSettlement.js +6 -1
- package/dist/_internal/server/jobs/core/onOrderStatusChange.js +7 -15
- package/dist/_internal/server/jobs/core/revenueRollup.js +7 -1
- package/dist/_internal/server/jobs/core/testerSandboxRefresh.js +27 -2
- package/dist/client/api/useApiQuery.js +17 -6
- package/dist/client.d.ts +1 -0
- package/dist/client.js +3 -0
- package/dist/contracts/client-realtime.d.ts +6 -0
- package/dist/contracts/realtime-session.d.ts +12 -12
- package/dist/contracts/realtime-session.js +72 -77
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/features/about/components/DeveloperView.js +7 -2
- package/dist/features/about/components/PolicyPageView.js +9 -1
- package/dist/features/about/components/PublicProfileView.js +23 -5
- package/dist/features/account/components/AddressForm.js +1 -1
- package/dist/features/account/components/NotificationBell.js +4 -3
- package/dist/features/account/components/NotificationPreferencesPanel.js +1 -1
- package/dist/features/account/components/UserOffersPanel.js +1 -1
- package/dist/features/admin/components/AdminFulfillmentView.js +5 -2
- package/dist/features/admin/components/AdminMediaView.js +3 -3
- package/dist/features/analytics/types.d.ts +1 -1
- package/dist/features/analytics/types.js +13 -0
- package/dist/features/auctions/components/AuctionDetailPageView.js +6 -2
- package/dist/features/auctions/components/MarketplaceAuctionCard.js +3 -1
- package/dist/features/auth/actions/profile-actions.js +4 -3
- package/dist/features/auth/components/ForgotPasswordView.js +1 -1
- package/dist/features/auth/components/LoginForm.js +1 -1
- package/dist/features/auth/components/RegisterForm.js +1 -1
- package/dist/features/auth/components/ResetPasswordView.js +1 -1
- package/dist/features/auth/hooks/useAuth.js +3 -0
- package/dist/features/blog/repository/blog.repository.js +3 -1
- package/dist/features/cart/actions/cart-actions.js +13 -4
- package/dist/features/cart/utils/pending-ops.js +1 -1
- package/dist/features/categories/components/BrandDetailPageView.js +28 -12
- package/dist/features/categories/components/CategoryDetailPageView.js +37 -20
- package/dist/features/categories/repository/categories.repository.js +3 -1
- package/dist/features/checkout/actions/checkout-value-otp-actions.js +10 -1
- package/dist/features/classified/components/ClassifiedContactSellerPanel.js +1 -1
- package/dist/features/classified/components/ClassifiedDetailPageView.js +10 -2
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +4 -1
- package/dist/features/events/components/EventBanner.js +5 -2
- package/dist/features/events/components/EventOfferCard.js +3 -1
- package/dist/features/events/components/EventPollWidget.js +1 -1
- package/dist/features/events/components/SpinWheelView.js +3 -2
- package/dist/features/grouped/components/GroupedListingEditorView.js +2 -1
- package/dist/features/history/hooks/useHistory.js +29 -27
- package/dist/features/homepage/components/AdSlot.js +3 -1
- package/dist/features/homepage/components/GoogleReviewsSection.js +8 -2
- package/dist/features/homepage/components/MarketplaceHomepageView.js +34 -10
- package/dist/features/homepage/components/SocialFeedSection.js +4 -1
- package/dist/features/homepage/lib/live-stats.js +8 -1
- package/dist/features/homepage/repository/carousel.repository.js +2 -1
- package/dist/features/live/components/LiveItemDetailPageView.js +4 -1
- package/dist/features/media/finalize.js +3 -1
- package/dist/features/media/modals/ImageEditor.js +1 -1
- package/dist/features/media/modals/VideoThumbnailSelector.js +5 -2
- package/dist/features/media/modals/VideoTrimModal.js +5 -2
- package/dist/features/media/upload/MediaUploadField.js +2 -2
- package/dist/features/messages/hooks/useConversation.js +18 -4
- package/dist/features/messages/hooks/useConversations.js +25 -8
- package/dist/features/orders/components/RefundRequestView.js +5 -2
- package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -1
- package/dist/features/pre-orders/components/PreOrderActionsClient.js +1 -1
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +4 -1
- package/dist/features/products/components/PrizeDrawDetailPageView.js +4 -1
- package/dist/features/products/components/ProductDetailPageView.js +8 -2
- package/dist/features/products/components/ShareButton.js +3 -1
- package/dist/features/products/repository/loadProductFeatures.js +10 -2
- package/dist/features/products/repository/products.repository.js +2 -1
- package/dist/features/promotions/components/CouponCard.js +3 -1
- package/dist/features/reviews/actions/review-actions.js +8 -1
- package/dist/features/reviews/components/ReviewDetailPageView.js +13 -3
- package/dist/features/reviews/components/ReviewDetailShell.js +3 -1
- package/dist/features/scams/actions/scam-actions.d.ts +9 -1
- package/dist/features/scams/actions/scam-actions.js +52 -12
- package/dist/features/scams/components/SellerTrustBadge.d.ts +1 -1
- package/dist/features/scams/components/SellerTrustBadge.js +13 -0
- package/dist/features/scams/repository/scammer.repository.js +2 -1
- package/dist/features/seller/actions/offer-actions.js +6 -1
- package/dist/features/seller/components/FulfillmentView.js +5 -2
- package/dist/features/seller/components/PhysicalLocationModal.js +1 -1
- package/dist/features/seller/components/PrintCenterView.js +3 -1
- package/dist/features/seller/components/SellerBidsView.js +1 -1
- package/dist/features/seller/components/SellerCouponsView.js +1 -1
- package/dist/features/seller/components/SellerGoogleReviewsView.js +2 -2
- package/dist/features/seller/components/SellerOrdersView.js +3 -3
- package/dist/features/seller/components/SellerPayoutMethodsView.js +2 -2
- package/dist/features/seller/components/SellerProductShell.d.ts +5 -0
- package/dist/features/seller/components/SellerProductShell.js +15 -5
- package/dist/features/seller/components/SellerProductsView.js +24 -10
- package/dist/features/seller/components/SellerReviewsView.js +47 -16
- package/dist/features/seller/components/SellerShippingConfigsView.js +5 -4
- package/dist/features/seller/components/SellerStoreCategoriesView.js +3 -2
- package/dist/features/stores/components/StoreDetailLayoutView.d.ts +1 -1
- package/dist/features/stores/components/StoreDetailLayoutView.js +12 -3
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +16 -1
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +3 -2
- package/dist/features/wishlist/hooks/useWishlistToggle.js +8 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -2
- package/dist/next/api/routeHandler.js +3 -1
- package/dist/providers/auth-firebase/session.js +3 -1
- package/dist/providers/db-firebase/admin-app-lite.js +8 -8
- package/dist/providers/db-firebase/admin.js +12 -12
- package/dist/providers/db-firebase/browser-stub.d.ts +1 -11
- package/dist/providers/db-firebase/browser-stub.js +6 -11
- package/dist/providers/db-firebase/index.d.ts +0 -1
- package/dist/providers/db-firebase/index.js +0 -1
- package/dist/providers/db-firebase/rtdb-paths.d.ts +15 -6
- package/dist/providers/db-firebase/rtdb-paths.js +15 -6
- package/dist/providers/db-firebase/rtdb-url.d.ts +27 -0
- package/dist/providers/db-firebase/rtdb-url.js +50 -0
- package/dist/providers/firebase-client/realtime.d.ts +20 -0
- package/dist/providers/firebase-client/realtime.js +34 -1
- package/dist/providers.d.ts +1 -1
- package/dist/providers.js +1 -1
- package/dist/react/hooks/use-action-dispatch.js +8 -2
- package/dist/react/hooks/useFormStatePreservation.js +3 -1
- package/dist/react/hooks/useRealtimeEvent.js +20 -9
- package/dist/ui/components/toolbar-collapse.js +1 -2
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use server";
|
|
2
2
|
import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit";
|
|
3
3
|
import { scammerRepository } from "../repository/scammer.repository";
|
|
4
|
+
import { safeRead } from "../../../errors/safe-read";
|
|
4
5
|
import { userRepository } from "../../auth/repository/user.repository";
|
|
5
6
|
import { storeRepository } from "../../stores/repository/store.repository";
|
|
6
7
|
import { safeFireAndForget } from "../../../utils/safe-fire-forget";
|
|
@@ -39,9 +40,12 @@ export async function listVerifiedScammers(searchParams) {
|
|
|
39
40
|
*/
|
|
40
41
|
export async function getPublicScammerById(id) {
|
|
41
42
|
// Try direct id lookup first; fall back to seoSlug query.
|
|
42
|
-
|
|
43
|
+
// The profile IS the caller's subject, and both lookups already return null
|
|
44
|
+
// for "no such id/slug" — swallowing a read failure into that same null would
|
|
45
|
+
// report a real outage as a missing profile.
|
|
46
|
+
let doc = await scammerRepository.findById(id);
|
|
43
47
|
if (!doc) {
|
|
44
|
-
doc = await scammerRepository.findBySeoSlug(id)
|
|
48
|
+
doc = await scammerRepository.findBySeoSlug(id);
|
|
45
49
|
}
|
|
46
50
|
if (!doc || doc.status !== "verified")
|
|
47
51
|
return null;
|
|
@@ -66,27 +70,63 @@ export async function getScammerProfilePageData(id) {
|
|
|
66
70
|
return { scammer, incidents, comments, relatedScammers, similarScamReports };
|
|
67
71
|
}
|
|
68
72
|
const TRUST_STATUS_CLEAR = { status: "clear", matchedProfileSlugs: [] };
|
|
73
|
+
const TRUST_STATUS_UNKNOWN = { status: "unknown", matchedProfileSlugs: [] };
|
|
74
|
+
/**
|
|
75
|
+
* Wrap a read so a FAILURE is distinguishable from a legitimate empty result.
|
|
76
|
+
*
|
|
77
|
+
* `safeRead` alone cannot express this: its fallback has the same type as a
|
|
78
|
+
* successful read, so `null` from a failed store lookup and `null` from a store
|
|
79
|
+
* that does not exist are the same value. Tagging the outcome is what lets the
|
|
80
|
+
* caller answer "unknown" instead of "clear".
|
|
81
|
+
*/
|
|
82
|
+
async function readTagged(fn, key) {
|
|
83
|
+
return safeRead(async () => ({ ok: true, value: await fn() }), {
|
|
84
|
+
route: "sellerTrust",
|
|
85
|
+
key,
|
|
86
|
+
fallback: { ok: false, value: null },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
69
89
|
/**
|
|
70
90
|
* P-12 — resolves a store owner's phone/email against the verified (admin-published)
|
|
71
91
|
* scammer registry only. Never surfaces "under review" reports to buyers — a report
|
|
72
92
|
* that hasn't been verified must not damage a seller's storefront reputation.
|
|
73
93
|
*/
|
|
74
94
|
export async function getSellerTrustStatus(storeId) {
|
|
75
|
-
|
|
76
|
-
|
|
95
|
+
// Every read below fails OPEN to "clear" — a deliberate choice, because a
|
|
96
|
+
// registry outage must not brand a legitimate seller a scammer. But "clear"
|
|
97
|
+
// is a positive safety claim, so a failure that produces one has to be
|
|
98
|
+
// recorded rather than inferred from a silent null.
|
|
99
|
+
const store = await readTagged(() => storeRepository.findById(storeId), "sellerTrust.store");
|
|
100
|
+
if (!store.ok)
|
|
101
|
+
return TRUST_STATUS_UNKNOWN;
|
|
102
|
+
if (!store.value?.ownerId)
|
|
77
103
|
return TRUST_STATUS_CLEAR;
|
|
78
|
-
const
|
|
79
|
-
|
|
104
|
+
const ownerId = store.value.ownerId;
|
|
105
|
+
const owner = await readTagged(() => userRepository.findById(ownerId), "sellerTrust.owner");
|
|
106
|
+
if (!owner.ok)
|
|
107
|
+
return TRUST_STATUS_UNKNOWN;
|
|
108
|
+
if (!owner.value)
|
|
80
109
|
return TRUST_STATUS_CLEAR;
|
|
110
|
+
const ownerDoc = owner.value;
|
|
81
111
|
const lookups = [];
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
112
|
+
if (ownerDoc.phoneNumber) {
|
|
113
|
+
const phone = ownerDoc.phoneNumber;
|
|
114
|
+
lookups.push(() => scammerRepository.findByContactField("phones", phone));
|
|
115
|
+
}
|
|
116
|
+
if (ownerDoc.email) {
|
|
117
|
+
const email = ownerDoc.email;
|
|
118
|
+
lookups.push(() => scammerRepository.findByContactField("emails", email));
|
|
119
|
+
}
|
|
86
120
|
if (lookups.length === 0)
|
|
87
121
|
return TRUST_STATUS_CLEAR;
|
|
88
|
-
const results = await Promise.all(lookups.map((
|
|
89
|
-
|
|
122
|
+
const results = await Promise.all(lookups.map((fn) => readTagged(fn, "sellerTrust.registryLookup")));
|
|
123
|
+
// A partial answer is not an answer: if ANY registry lookup failed we cannot
|
|
124
|
+
// say the seller is clear, only that we could not check.
|
|
125
|
+
if (results.some((r) => !r.ok))
|
|
126
|
+
return TRUST_STATUS_UNKNOWN;
|
|
127
|
+
const verified = results
|
|
128
|
+
.flatMap((r) => r.value ?? [])
|
|
129
|
+
.filter((s) => s.status === "verified");
|
|
90
130
|
if (verified.length === 0)
|
|
91
131
|
return TRUST_STATUS_CLEAR;
|
|
92
132
|
return {
|
|
@@ -13,4 +13,4 @@ export interface SellerTrustBadgeProps {
|
|
|
13
13
|
* unrelated homepage-section `TrustBadge` config type in
|
|
14
14
|
* features/homepage/index.ts (marketing trust-badge icons row).
|
|
15
15
|
*/
|
|
16
|
-
export declare function SellerTrustBadge({ trust, className }: SellerTrustBadgeProps): import("react").JSX.Element;
|
|
16
|
+
export declare function SellerTrustBadge({ trust, className }: SellerTrustBadgeProps): import("react").JSX.Element | null;
|
|
@@ -13,6 +13,19 @@ import { ROUTES } from "../../../next";
|
|
|
13
13
|
* features/homepage/index.ts (marketing trust-badge icons row).
|
|
14
14
|
*/
|
|
15
15
|
export function SellerTrustBadge({ trust, className = "" }) {
|
|
16
|
+
// 🛑 `unknown` MUST be handled before the flagged fallthrough below, and must
|
|
17
|
+
// never render as "Verified Safe".
|
|
18
|
+
//
|
|
19
|
+
// Every read in getSellerTrustStatus used to fail open to "clear", so a
|
|
20
|
+
// scam-registry outage produced an explicit safety endorsement for a seller
|
|
21
|
+
// nobody had checked. Failing open is still right — an outage must not brand
|
|
22
|
+
// a legitimate seller a scammer — but the honest failure state is "we could
|
|
23
|
+
// not check", which is neither of the two claims this badge otherwise makes.
|
|
24
|
+
// Rendering nothing is deliberate: a neutral badge would still be a statement,
|
|
25
|
+
// and the absence of a badge already means "no verified match" everywhere the
|
|
26
|
+
// registry is not consulted.
|
|
27
|
+
if (trust.status === "unknown")
|
|
28
|
+
return null;
|
|
16
29
|
if (trust.status === "clear") {
|
|
17
30
|
return (_jsx(Badge, { variant: "success", className: className, children: "\u2713 Verified Safe" }));
|
|
18
31
|
}
|
|
@@ -122,10 +122,11 @@ class ScammerRepository extends BaseRepository {
|
|
|
122
122
|
await this.getCollection()
|
|
123
123
|
.doc(id)
|
|
124
124
|
.update({ [SCAMMER_FIELDS.VIEWS]: increment(1) });
|
|
125
|
+
// Fire-and-forget analytics: a lost view increment changes no rendered
|
|
126
|
+
// content, so it must never break the profile read it rides along with.
|
|
125
127
|
}
|
|
126
128
|
catch (_err) {
|
|
127
129
|
void normalizeError(_err);
|
|
128
|
-
// Fire-and-forget by design.
|
|
129
130
|
}
|
|
130
131
|
}
|
|
131
132
|
/**
|
|
@@ -15,6 +15,7 @@ import { cartRepository } from "../../cart/repository/cart.repository";
|
|
|
15
15
|
import { maskOfferForSeller } from "../../../security";
|
|
16
16
|
import { ERROR_MESSAGES, AuthorizationError, ValidationError, NotFoundError, OFFER_ERROR_CODES, } from "../../../errors";
|
|
17
17
|
import { siteSettingsRepository } from "../../admin/repository/site-settings.repository";
|
|
18
|
+
import { safeRead } from "../../../errors/safe-read";
|
|
18
19
|
import { canMakeOffer } from "../../../_internal/shared/listing-types/capabilities";
|
|
19
20
|
import { normalizeListingType } from "../../products/utils/listing-type";
|
|
20
21
|
import { OfferStatusValues } from "../schemas";
|
|
@@ -36,7 +37,11 @@ export async function makeOffer(userId, userEmail, input) {
|
|
|
36
37
|
* without stranding in-flight ones, so accept/decline/counter/withdraw and
|
|
37
38
|
* `checkoutOffer` deliberately stay open.
|
|
38
39
|
*/
|
|
39
|
-
|
|
40
|
+
// Fails OPEN — a settings read that dies must not block offers site-wide. But
|
|
41
|
+
// `null` here is indistinguishable from "the flag is on", so it is recorded.
|
|
42
|
+
const settings = await safeRead(() => siteSettingsRepository.getSingleton(), {
|
|
43
|
+
route: "offers.makeOffer", key: "makeOffer.siteSettings", fallback: null,
|
|
44
|
+
});
|
|
40
45
|
if (settings?.featureFlags?.offers === false) {
|
|
41
46
|
throw new ValidationError(ERROR_MESSAGES.OFFER.DISABLED, {
|
|
42
47
|
code: OFFER_ERROR_CODES.DISABLED,
|
|
@@ -37,12 +37,15 @@ export function FulfillmentView({ currentUserId, }) {
|
|
|
37
37
|
setOrders(res.orders ?? []);
|
|
38
38
|
}
|
|
39
39
|
catch (err) {
|
|
40
|
-
|
|
40
|
+
// An empty fulfilment queue is a normal, expected state, so a swallowed
|
|
41
|
+
// failure here reads as "nothing to pack" — matching the rest of this
|
|
42
|
+
// file, surface it instead.
|
|
43
|
+
showToast(normalizeError(err).message, "error");
|
|
41
44
|
}
|
|
42
45
|
finally {
|
|
43
46
|
setIsLoadingOrders(false);
|
|
44
47
|
}
|
|
45
|
-
}, []);
|
|
48
|
+
}, [showToast]);
|
|
46
49
|
useEffect(() => {
|
|
47
50
|
void fetchOrders();
|
|
48
51
|
}, [fetchOrders]);
|
|
@@ -22,7 +22,7 @@ export function PhysicalLocationModal({ count, onSave, onClose }) {
|
|
|
22
22
|
}
|
|
23
23
|
catch (e) {
|
|
24
24
|
void normalizeError(e);
|
|
25
|
-
setError(
|
|
25
|
+
setError("Failed to save location");
|
|
26
26
|
}
|
|
27
27
|
finally {
|
|
28
28
|
setSaving(false);
|
|
@@ -20,10 +20,12 @@ function ProductLabel({ product }) {
|
|
|
20
20
|
fontSize: 12,
|
|
21
21
|
margin: 8,
|
|
22
22
|
});
|
|
23
|
+
// JsBarcode throws on a value CODE128 cannot encode. The <svg> is left
|
|
24
|
+
// empty and the label still prints its title and price — there is no
|
|
25
|
+
// recovery beyond fixing the barcodeId on the product itself.
|
|
23
26
|
}
|
|
24
27
|
catch (_err) {
|
|
25
28
|
void normalizeError(_err);
|
|
26
|
-
// Invalid barcode value — render nothing
|
|
27
29
|
}
|
|
28
30
|
}
|
|
29
31
|
}, [product.barcodeId]);
|
|
@@ -88,7 +88,7 @@ export function SellerBidsView({ endpoint = SELLER_ENDPOINTS.BIDS }) {
|
|
|
88
88
|
}
|
|
89
89
|
catch (err) {
|
|
90
90
|
void normalizeError(err);
|
|
91
|
-
showToast(
|
|
91
|
+
showToast("Failed to cancel bids.", "error");
|
|
92
92
|
}
|
|
93
93
|
}, [showToast]);
|
|
94
94
|
const config = {
|
|
@@ -32,7 +32,7 @@ export function SellerCouponsView({ onCreateClick, onEditClick, onToggle, onDele
|
|
|
32
32
|
}
|
|
33
33
|
catch (err) {
|
|
34
34
|
void normalizeError(err);
|
|
35
|
-
showToast(
|
|
35
|
+
showToast("Failed to update coupon.", "error");
|
|
36
36
|
}
|
|
37
37
|
finally {
|
|
38
38
|
setTogglingId(null);
|
|
@@ -76,7 +76,7 @@ export function SellerGoogleReviewsView({ onSave, onSync, }) {
|
|
|
76
76
|
catch (err) {
|
|
77
77
|
void normalizeError(err);
|
|
78
78
|
setSaveMessage({ text: "Save failed. Please try again.", ok: false });
|
|
79
|
-
showToast(
|
|
79
|
+
showToast("Failed to save settings.", "error");
|
|
80
80
|
}
|
|
81
81
|
finally {
|
|
82
82
|
setSaving(false);
|
|
@@ -101,7 +101,7 @@ export function SellerGoogleReviewsView({ onSave, onSync, }) {
|
|
|
101
101
|
catch (err) {
|
|
102
102
|
void normalizeError(err);
|
|
103
103
|
setSaveMessage({ text: "Sync failed. Please try again.", ok: false });
|
|
104
|
-
showToast(
|
|
104
|
+
showToast("Failed to sync reviews.", "error");
|
|
105
105
|
}
|
|
106
106
|
finally {
|
|
107
107
|
setSyncing(false);
|
|
@@ -210,7 +210,7 @@ export function SellerOrdersView({ orderDetailApiBase = SELLER_ENDPOINTS.ORDERS,
|
|
|
210
210
|
}
|
|
211
211
|
catch (err) {
|
|
212
212
|
void normalizeError(err);
|
|
213
|
-
showToast(
|
|
213
|
+
showToast("Failed to mark order shipped.", "error");
|
|
214
214
|
}
|
|
215
215
|
finally {
|
|
216
216
|
setShippingRowId(null);
|
|
@@ -232,7 +232,7 @@ export function SellerOrdersView({ orderDetailApiBase = SELLER_ENDPOINTS.ORDERS,
|
|
|
232
232
|
}
|
|
233
233
|
catch (err) {
|
|
234
234
|
void normalizeError(err);
|
|
235
|
-
showToast(
|
|
235
|
+
showToast("Failed to update location.", "error");
|
|
236
236
|
}
|
|
237
237
|
}, [showToast]);
|
|
238
238
|
const requestPayoutForSelection = useCallback(async (selection) => {
|
|
@@ -253,7 +253,7 @@ export function SellerOrdersView({ orderDetailApiBase = SELLER_ENDPOINTS.ORDERS,
|
|
|
253
253
|
}
|
|
254
254
|
catch (err) {
|
|
255
255
|
void normalizeError(err);
|
|
256
|
-
showToast(
|
|
256
|
+
showToast("Failed to request payout.", "error");
|
|
257
257
|
}
|
|
258
258
|
}, [showToast]);
|
|
259
259
|
const [selectedIdsForLocation, setSelectedIdsForLocation] = useState([]);
|
|
@@ -57,7 +57,7 @@ export function SellerPayoutMethodsView({ onCreateClick, onEditClick, onDelete,
|
|
|
57
57
|
}
|
|
58
58
|
catch (err) {
|
|
59
59
|
void normalizeError(err);
|
|
60
|
-
showToast(
|
|
60
|
+
showToast("Failed to delete payout method", "error");
|
|
61
61
|
}
|
|
62
62
|
setDeleteTargetId(null);
|
|
63
63
|
}, [performDelete, showToast]);
|
|
@@ -79,7 +79,7 @@ export function SellerPayoutMethodsView({ onCreateClick, onEditClick, onDelete,
|
|
|
79
79
|
}
|
|
80
80
|
catch (err) {
|
|
81
81
|
void normalizeError(err);
|
|
82
|
-
showToast(
|
|
82
|
+
showToast("Failed to update default.", "error");
|
|
83
83
|
}
|
|
84
84
|
finally {
|
|
85
85
|
setSettingDefaultId(null);
|
|
@@ -74,6 +74,11 @@ export interface SellerProductDraft {
|
|
|
74
74
|
/** Shape returned by `onSave`/`onPublish` — structurally compatible with appkit's `ActionResult<T>` failure/success arms, without importing the server-only type into this client component. */
|
|
75
75
|
export interface ProductActionResult {
|
|
76
76
|
ok: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Stable error code. This is what the failure toast resolves — `error` is the
|
|
79
|
+
* server's own sentence and is reported, never shown (audit-raw-error-text).
|
|
80
|
+
*/
|
|
81
|
+
code?: string;
|
|
77
82
|
error?: string;
|
|
78
83
|
issues?: unknown[];
|
|
79
84
|
}
|
|
@@ -13,6 +13,7 @@ import { BarcodeField } from "./BarcodeField";
|
|
|
13
13
|
import { pluginFor } from "../../../_internal/shared/listing-types/_registry";
|
|
14
14
|
import { DEFAULT_MIN_OFFER_PERCENT } from "../../../_internal/shared/features/offers/config";
|
|
15
15
|
import { normalizeError } from "../../../errors/normalize";
|
|
16
|
+
import { toUserMessage } from "../../../errors/error-display-map";
|
|
16
17
|
/**
|
|
17
18
|
* "bundle" is a categoryType, not a ListingType, so it can't live in
|
|
18
19
|
* LISTING_TYPE_REGISTRY — this is the single, explicit escape hatch that
|
|
@@ -346,18 +347,25 @@ export function SellerProductShell({ mode, listingType = "standard", initialValu
|
|
|
346
347
|
return;
|
|
347
348
|
}
|
|
348
349
|
applyZodIssues(result.issues ?? [], setFieldError);
|
|
349
|
-
if (!silent)
|
|
350
|
-
showToast(result.
|
|
350
|
+
if (!silent) {
|
|
351
|
+
showToast(toUserMessage(result.code, undefined, {
|
|
352
|
+
fallback: "Fix the highlighted errors and try again.",
|
|
353
|
+
}), "error");
|
|
354
|
+
}
|
|
351
355
|
}
|
|
352
356
|
catch (err) {
|
|
353
357
|
void normalizeError(err);
|
|
354
358
|
if (!silent)
|
|
355
|
-
showToast(
|
|
359
|
+
showToast("Failed to save.", "error");
|
|
356
360
|
}
|
|
357
361
|
}, [draft, onSave, markClean, clearErrors, setFieldError, showToast]);
|
|
358
362
|
const handleAutoSave = useCallback(async () => {
|
|
359
363
|
try {
|
|
360
364
|
await handleSave(true);
|
|
365
|
+
// handleSave was called with silent=true, which is the whole point of
|
|
366
|
+
// auto-save: it has already applied the field errors and populated
|
|
367
|
+
// FormErrorSummary, so a toast every two seconds while the seller is
|
|
368
|
+
// still typing would be worse than saying nothing at all here.
|
|
361
369
|
}
|
|
362
370
|
catch (err) {
|
|
363
371
|
void normalizeError(err);
|
|
@@ -383,11 +391,13 @@ export function SellerProductShell({ mode, listingType = "standard", initialValu
|
|
|
383
391
|
return;
|
|
384
392
|
}
|
|
385
393
|
applyZodIssues(result.issues ?? [], setFieldError);
|
|
386
|
-
showToast(result.
|
|
394
|
+
showToast(toUserMessage(result.code, undefined, {
|
|
395
|
+
fallback: "Fix the highlighted errors and try again.",
|
|
396
|
+
}), "error");
|
|
387
397
|
}
|
|
388
398
|
catch (err) {
|
|
389
399
|
void normalizeError(err);
|
|
390
|
-
showToast(
|
|
400
|
+
showToast("Failed to publish.", "error");
|
|
391
401
|
}
|
|
392
402
|
}, [draft, onPublish, markClean, clearErrors, setFieldError, showToast]);
|
|
393
403
|
const listingTypeLabel = pluginForMode(listingType).typeLabel;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { normalizeError } from "../../../errors/normalize";
|
|
4
|
+
import { toUserMessage } from "../../../errors/error-display-map";
|
|
4
5
|
import { SIEVE_OP, Stack, sieveFilter } from "@mohasinac/appkit/client";
|
|
5
6
|
import { sortBy } from "@mohasinac/appkit/client";
|
|
6
7
|
import { useState, useCallback } from "react";
|
|
@@ -148,15 +149,23 @@ export function SellerProductsView({ onDeleteProduct, onCreateClick, }) {
|
|
|
148
149
|
await performDelete(row.id);
|
|
149
150
|
};
|
|
150
151
|
const handleDuplicate = async (row) => {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
152
|
+
// A swallowed failure here does nothing at all and says nothing — the
|
|
153
|
+
// seller clicks Duplicate and no copy appears, with no way to tell whether
|
|
154
|
+
// the request even left the browser.
|
|
155
|
+
try {
|
|
156
|
+
const res = await fetch(SELLER_ENDPOINTS.PRODUCT_DUPLICATE(row.id), {
|
|
157
|
+
method: "POST",
|
|
158
|
+
});
|
|
159
|
+
if (!res.ok)
|
|
160
|
+
throw new Error(`Duplicate failed (${res.status})`);
|
|
155
161
|
const json = await res.json().catch(() => null);
|
|
156
162
|
const newId = json?.data?.id;
|
|
157
163
|
if (newId)
|
|
158
164
|
handleEdit({ ...row, id: newId });
|
|
159
165
|
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
showToast(normalizeError(err).message, "error");
|
|
168
|
+
}
|
|
160
169
|
};
|
|
161
170
|
const handleTogglePublish = async (row) => {
|
|
162
171
|
const currentStatus = statusOverrides.get(row.id) ?? row.status;
|
|
@@ -167,10 +176,15 @@ export function SellerProductsView({ onDeleteProduct, onCreateClick, }) {
|
|
|
167
176
|
method: "PATCH",
|
|
168
177
|
headers: { "Content-Type": "application/json" },
|
|
169
178
|
body: JSON.stringify({ status: newStatus }),
|
|
170
|
-
})
|
|
171
|
-
if (res
|
|
172
|
-
|
|
173
|
-
|
|
179
|
+
});
|
|
180
|
+
if (!res.ok)
|
|
181
|
+
throw new Error(`Could not change status (${res.status})`);
|
|
182
|
+
setStatusOverrides((prev) => new Map([...prev, [row.id, newStatus]]));
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
// The row silently snapped back to its old status before this — a seller
|
|
186
|
+
// could not tell a rejected publish from a UI that had not repainted.
|
|
187
|
+
showToast(normalizeError(err).message, "error");
|
|
174
188
|
}
|
|
175
189
|
finally {
|
|
176
190
|
setPublishingId(null);
|
|
@@ -191,8 +205,8 @@ export function SellerProductsView({ onDeleteProduct, onCreateClick, }) {
|
|
|
191
205
|
setSetLocationOpen(false);
|
|
192
206
|
}
|
|
193
207
|
catch (err) {
|
|
194
|
-
|
|
195
|
-
showToast(
|
|
208
|
+
const e = normalizeError(err);
|
|
209
|
+
showToast(toUserMessage(e.code, undefined, { fallback: "Failed to update location." }), "error");
|
|
196
210
|
}
|
|
197
211
|
}, [showToast]);
|
|
198
212
|
const config = {
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import { normalizeError } from "../../../errors/normalize";
|
|
4
4
|
import { useCallback, useState } from "react";
|
|
5
5
|
import { useQueryClient } from "@tanstack/react-query";
|
|
6
|
-
import { Alert, Badge, Button, Checkbox, Div, Modal, Row, Select, SideDrawer, Span, Stack, Text, Textarea } from "../../../ui";
|
|
6
|
+
import { Alert, Badge, Button, Checkbox, Div, Modal, Row, Select, SideDrawer, Span, Stack, Text, Textarea, useToast } from "../../../ui";
|
|
7
7
|
import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
|
|
8
8
|
import { SELLER_BULK_ACTIONS, ROW_ACTION_META } from "../../products/constants/action-defs";
|
|
9
9
|
import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
@@ -34,6 +34,7 @@ function statusBadge(status) {
|
|
|
34
34
|
}
|
|
35
35
|
export function SellerReviewsView({ reviewsApiBase = SELLER_ENDPOINTS.REVIEWS, replyApiBase = SELLER_ENDPOINTS.REVIEWS, }) {
|
|
36
36
|
const queryClient = useQueryClient();
|
|
37
|
+
const { showToast } = useToast();
|
|
37
38
|
const refetchReviews = useCallback(() => queryClient.invalidateQueries({ queryKey: ["seller", "reviews", "listing"] }), [queryClient]);
|
|
38
39
|
// Reply drawer
|
|
39
40
|
const [replyTarget, setReplyTarget] = useState(null);
|
|
@@ -80,45 +81,75 @@ export function SellerReviewsView({ reviewsApiBase = SELLER_ENDPOINTS.REVIEWS, r
|
|
|
80
81
|
setReplySaving(false);
|
|
81
82
|
}
|
|
82
83
|
};
|
|
84
|
+
// The three submits below all closed their modal unconditionally, so a
|
|
85
|
+
// rejected or unsent request looked exactly like a successful one — the
|
|
86
|
+
// seller believed the contest/feedback/reply had been filed.
|
|
83
87
|
const submitContest = useCallback(async (onDone) => {
|
|
84
88
|
if (!contestTarget)
|
|
85
89
|
return;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
try {
|
|
91
|
+
const res = await fetch(SELLER_ENDPOINTS.REVIEW_CONTEST(contestTarget.id), {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "Content-Type": "application/json" },
|
|
94
|
+
body: JSON.stringify({ reason: contestReason }),
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok)
|
|
97
|
+
throw new Error(`Could not submit the contest (${res.status})`);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
showToast(normalizeError(err).message, "error");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
91
103
|
setContestTarget(null);
|
|
92
104
|
setContestReason("");
|
|
93
105
|
onDone();
|
|
94
|
-
}, [contestTarget, contestReason]);
|
|
106
|
+
}, [contestTarget, contestReason, showToast]);
|
|
95
107
|
const submitFeedback = useCallback(async () => {
|
|
96
108
|
if (!feedbackTarget)
|
|
97
109
|
return;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
110
|
+
try {
|
|
111
|
+
const res = await fetch(SELLER_ENDPOINTS.REVIEW_FEEDBACK(feedbackTarget.id), {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "Content-Type": "application/json" },
|
|
114
|
+
body: JSON.stringify({ feedback: feedbackText }),
|
|
115
|
+
});
|
|
116
|
+
if (!res.ok)
|
|
117
|
+
throw new Error(`Could not send the feedback (${res.status})`);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
showToast(normalizeError(err).message, "error");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
103
123
|
setFeedbackTarget(null);
|
|
104
124
|
setFeedbackText("");
|
|
105
|
-
}, [feedbackTarget, feedbackText]);
|
|
125
|
+
}, [feedbackTarget, feedbackText, showToast]);
|
|
106
126
|
const submitBulkReply = useCallback(async () => {
|
|
107
127
|
if (!bulkReplyText.trim() || bulkTargetIds.length === 0)
|
|
108
128
|
return;
|
|
109
129
|
setBulkSaving(true);
|
|
110
|
-
|
|
130
|
+
// Partial success is the normal outcome of a bulk write, so report the
|
|
131
|
+
// count that failed rather than throwing away the whole batch's result.
|
|
132
|
+
const outcomes = await Promise.all(bulkTargetIds.map((id) => fetch(`${replyApiBase}/${id}/reply`, {
|
|
111
133
|
method: "POST",
|
|
112
134
|
headers: { "Content-Type": "application/json" },
|
|
113
135
|
body: JSON.stringify({ reply: bulkReplyText }),
|
|
114
|
-
})
|
|
136
|
+
})
|
|
137
|
+
.then((res) => res.ok)
|
|
138
|
+
.catch((err) => {
|
|
139
|
+
void normalizeError(err);
|
|
140
|
+
return false;
|
|
141
|
+
})));
|
|
142
|
+
const failed = outcomes.filter((ok) => !ok).length;
|
|
143
|
+
if (failed > 0) {
|
|
144
|
+
showToast(`${failed} of ${outcomes.length} replies could not be saved.`, "error");
|
|
145
|
+
}
|
|
115
146
|
setBulkSaving(false);
|
|
116
147
|
setBulkReplyOpen(false);
|
|
117
148
|
setBulkReplyText("");
|
|
118
149
|
bulkClearSelection?.();
|
|
119
150
|
setBulkTargetIds([]);
|
|
120
151
|
await refetchReviews();
|
|
121
|
-
}, [bulkReplyText, bulkTargetIds, replyApiBase, bulkClearSelection, refetchReviews]);
|
|
152
|
+
}, [bulkReplyText, bulkTargetIds, replyApiBase, bulkClearSelection, refetchReviews, showToast]);
|
|
122
153
|
const config = {
|
|
123
154
|
portal: "seller",
|
|
124
155
|
title: "Reviews",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { normalizeError } from "../../../errors/normalize";
|
|
4
|
+
import { toUserMessage } from "../../../errors/error-display-map";
|
|
4
5
|
import { sortBy } from "@mohasinac/appkit/client";
|
|
5
6
|
import { useState, useCallback } from "react";
|
|
6
7
|
import { useEntityDelete } from "../../../react/hooks/useEntityDelete";
|
|
@@ -53,8 +54,8 @@ export function SellerShippingConfigsView({ onCreateClick, onEditClick, onDelete
|
|
|
53
54
|
await performDelete(id);
|
|
54
55
|
}
|
|
55
56
|
catch (err) {
|
|
56
|
-
|
|
57
|
-
showToast(
|
|
57
|
+
const e = normalizeError(err);
|
|
58
|
+
showToast(toUserMessage(e.code, undefined, { fallback: "Failed to delete shipping config." }), "error");
|
|
58
59
|
}
|
|
59
60
|
setDeleteTargetId(null);
|
|
60
61
|
}, [performDelete, showToast]);
|
|
@@ -75,8 +76,8 @@ export function SellerShippingConfigsView({ onCreateClick, onEditClick, onDelete
|
|
|
75
76
|
showToast("Default shipping config updated.", "success");
|
|
76
77
|
}
|
|
77
78
|
catch (err) {
|
|
78
|
-
|
|
79
|
-
showToast(
|
|
79
|
+
const e = normalizeError(err);
|
|
80
|
+
showToast(toUserMessage(e.code, undefined, { fallback: "Failed to update default." }), "error");
|
|
80
81
|
}
|
|
81
82
|
finally {
|
|
82
83
|
setSettingDefaultId(null);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { normalizeError } from "../../../errors/normalize";
|
|
4
|
+
import { toUserMessage } from "../../../errors/error-display-map";
|
|
4
5
|
import { Badge, sortBy } from "@mohasinac/appkit/client";
|
|
5
6
|
import { useState, useCallback } from "react";
|
|
6
7
|
import { useEntityDelete } from "../../../react/hooks/useEntityDelete";
|
|
@@ -49,8 +50,8 @@ export function SellerStoreCategoriesView({ onCreateClick, onEditClick, onDelete
|
|
|
49
50
|
await performDelete(id);
|
|
50
51
|
}
|
|
51
52
|
catch (err) {
|
|
52
|
-
|
|
53
|
-
showToast(
|
|
53
|
+
const e = normalizeError(err);
|
|
54
|
+
showToast(toUserMessage(e.code, undefined, { fallback: "Failed to delete category." }), "error");
|
|
54
55
|
}
|
|
55
56
|
setDeleteTargetId(null);
|
|
56
57
|
}, [onDelete, performDelete, showToast]);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
|
-
export declare const getStoreBySlug: (slug: string) => Promise<import("@mohasinac/appkit").StoreDocument | null
|
|
3
|
+
export declare const getStoreBySlug: (slug: string) => Promise<import("@mohasinac/appkit").StoreDocument | null>;
|
|
4
4
|
export interface StoreDetailLayoutViewProps {
|
|
5
5
|
storeSlug: string;
|
|
6
6
|
/** The current active tab value: "products" | "auctions" | "reviews" | "about" */
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { sieveFilter, sieveAnd, SIEVE_OP } from "@mohasinac/appkit";
|
|
3
3
|
import { cache } from "react";
|
|
4
4
|
import { storeRepository, siteSettingsRepository, reviewRepository, couponsRepository } from "../../../repositories";
|
|
5
|
+
import { safeRead } from "../../../errors/safe-read";
|
|
5
6
|
import { ROUTES } from "../../../next";
|
|
6
7
|
import { Container, Main, Section, Text } from "../../../ui";
|
|
7
8
|
import { STORE_PAGE_TABS } from "../../products/constants/listing-tabs";
|
|
@@ -26,7 +27,10 @@ const STORE_LISTING_HREF = {
|
|
|
26
27
|
// a real non-zero count and landed on a page with none of those items.
|
|
27
28
|
art: (slug) => String(ROUTES.PUBLIC.STORE_ART(slug)),
|
|
28
29
|
};
|
|
29
|
-
|
|
30
|
+
// The store IS this layout's subject, and `findBySlug` already returns null for
|
|
31
|
+
// "no such slug" — swallowing a read failure into the same value would render
|
|
32
|
+
// the "Store not found." branch below for a live store during an outage.
|
|
33
|
+
export const getStoreBySlug = cache((slug) => storeRepository.findBySlug(slug));
|
|
30
34
|
function tabLabel(base, count) {
|
|
31
35
|
if (!count)
|
|
32
36
|
return base;
|
|
@@ -39,9 +43,14 @@ export async function StoreDetailLayoutView({ storeSlug, activeTab, children, sc
|
|
|
39
43
|
}
|
|
40
44
|
const storeId = store?.id;
|
|
41
45
|
const [settings, trust] = await Promise.all([
|
|
42
|
-
siteSettingsRepository.findById("global")
|
|
46
|
+
safeRead(() => siteSettingsRepository.findById("global"), {
|
|
47
|
+
route: "/stores/[slug]", key: "store.siteSettings", fallback: null,
|
|
48
|
+
}),
|
|
43
49
|
scamRegistryEnabled && storeId
|
|
44
|
-
|
|
50
|
+
// Fails open to "no trust verdict", which hides the badge entirely rather
|
|
51
|
+
// than asserting a seller is clear — but that has to be recorded, since a
|
|
52
|
+
// missing badge is exactly what a clean seller looks like.
|
|
53
|
+
? safeRead(() => getSellerTrustStatus(storeId), { route: "/stores/[slug]", key: "store.sellerTrust", fallback: undefined })
|
|
45
54
|
: Promise.resolve(undefined),
|
|
46
55
|
]);
|
|
47
56
|
// One derived batch instead of nine copy-pasted per-type queries. The tabs
|