@stapel/listings-react 0.18.2 → 0.19.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/CHANGELOG.md +41 -0
- package/dist/api/generated/schema.d.ts +101 -13
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/listingsApi.d.ts +26 -1
- package/dist/api/listingsApi.d.ts.map +1 -1
- package/dist/api/listingsApi.js +9 -0
- package/dist/api/listingsApi.js.map +1 -1
- package/dist/api/types.d.ts +91 -6
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js +32 -0
- package/dist/api/types.js.map +1 -1
- package/dist/default/GateReasonPopover.d.ts.map +1 -1
- package/dist/default/GateReasonPopover.js +47 -2
- package/dist/default/GateReasonPopover.js.map +1 -1
- package/dist/default/ListingCard.d.ts +32 -1
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +65 -14
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +12 -7
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingFeedCard.d.ts +32 -7
- package/dist/default/ListingFeedCard.d.ts.map +1 -1
- package/dist/default/ListingFeedCard.js +11 -3
- package/dist/default/ListingFeedCard.js.map +1 -1
- package/dist/default/ListingPrice.d.ts +13 -2
- package/dist/default/ListingPrice.d.ts.map +1 -1
- package/dist/default/ListingPrice.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +10 -2
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/favorite.d.ts +11 -0
- package/dist/default/favorite.d.ts.map +1 -1
- package/dist/default/favorite.js +24 -9
- package/dist/default/favorite.js.map +1 -1
- package/dist/default/icons.d.ts +17 -3
- package/dist/default/icons.d.ts.map +1 -1
- package/dist/default/icons.js +2 -1
- package/dist/default/icons.js.map +1 -1
- package/dist/default/index.d.ts +1 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +4 -1
- package/dist/default/index.js.map +1 -1
- package/dist/headless/Engagement.d.ts +62 -0
- package/dist/headless/Engagement.d.ts.map +1 -0
- package/dist/headless/Engagement.js +116 -0
- package/dist/headless/Engagement.js.map +1 -0
- package/dist/headless/Favorites.d.ts +50 -0
- package/dist/headless/Favorites.d.ts.map +1 -1
- package/dist/headless/Favorites.js +63 -3
- package/dist/headless/Favorites.js.map +1 -1
- package/dist/headless/ListingDetail.d.ts +21 -1
- package/dist/headless/ListingDetail.d.ts.map +1 -1
- package/dist/headless/ListingDetail.js +39 -3
- package/dist/headless/ListingDetail.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +4 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.es.gen.js +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +12 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +6 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -1
- package/dist/i18n/keys.d.ts +7 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +8 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +6 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/model/engagement.d.ts +85 -0
- package/dist/model/engagement.d.ts.map +1 -0
- package/dist/model/engagement.js +59 -0
- package/dist/model/engagement.js.map +1 -0
- package/dist/model/queries.d.ts +28 -1
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +36 -0
- package/dist/model/queries.js.map +1 -1
- package/dist/model/queryKeys.d.ts +12 -13
- package/dist/model/queryKeys.d.ts.map +1 -1
- package/dist/model/queryKeys.js +15 -0
- package/dist/model/queryKeys.js.map +1 -1
- package/llms.txt +6 -3
- package/manifest.json +71 -3
- package/nav-manifest.json +1 -1
- package/package.json +8 -8
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +101 -13
- package/src/api/listingsApi.ts +39 -0
- package/src/api/types.ts +115 -6
- package/src/default/GateReasonPopover.tsx +62 -3
- package/src/default/ListingCard.tsx +84 -10
- package/src/default/ListingDetailPane.tsx +48 -16
- package/src/default/ListingFeedCard.tsx +45 -8
- package/src/default/ListingPrice.tsx +13 -2
- package/src/default/ListingSerpCard.tsx +11 -1
- package/src/default/favorite.tsx +49 -21
- package/src/default/icons.tsx +21 -3
- package/src/default/index.ts +4 -0
- package/src/headless/Engagement.tsx +177 -0
- package/src/headless/Favorites.tsx +93 -3
- package/src/headless/ListingDetail.tsx +64 -4
- package/src/i18n/es.ts +7 -0
- package/src/i18n/generated/errors.es.gen.ts +1 -1
- package/src/i18n/generated/errors.gen.ts +6 -0
- package/src/i18n/generated/errors.json +14 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/keys.ts +8 -0
- package/src/i18n/ru.ts +9 -0
- package/src/index.ts +27 -0
- package/src/model/engagement.ts +118 -0
- package/src/model/queries.ts +41 -0
- package/src/model/queryKeys.ts +14 -0
package/src/i18n/es.ts
CHANGED
|
@@ -102,6 +102,7 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
102
102
|
"listings.detail.published_at": "Publicado el {date}",
|
|
103
103
|
"listings.detail.expires_at": "A la venta hasta el {date}",
|
|
104
104
|
"listings.detail.stock": "Disponibles",
|
|
105
|
+
"listings.detail.views": "Visitas",
|
|
105
106
|
"listings.detail.edit": "Editar el anuncio",
|
|
106
107
|
"listings.detail.take_down": "Retirarlo",
|
|
107
108
|
|
|
@@ -206,6 +207,12 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
206
207
|
"listings.favorites.sign_in_hint":
|
|
207
208
|
"Los favoritos se guardan en tu cuenta, así que te siguen entre dispositivos.",
|
|
208
209
|
|
|
210
|
+
// Las dos comprobaciones de publicación que llegaron con el contrato 0.17.
|
|
211
|
+
"error.400.listing_location_required":
|
|
212
|
+
"Indica dónde está el artículo antes de publicarlo",
|
|
213
|
+
"error.400.listing_zero_price_not_allowed":
|
|
214
|
+
"En esta categoría no se permite un precio de 0. Deja el precio vacío para «precio no indicado».",
|
|
215
|
+
|
|
209
216
|
"listings.blocked.sign_in": "Inicia sesión para hacer esto",
|
|
210
217
|
"listings.blocked.guest":
|
|
211
218
|
"Esta cuenta todavía no puede hacerlo — termina de configurarla primero",
|
|
@@ -8,7 +8,7 @@ import type { ListingsErrorCode } from "./errors.gen.js";
|
|
|
8
8
|
/**
|
|
9
9
|
* `es` texts for the backend error codes this catalog carries.
|
|
10
10
|
*
|
|
11
|
-
* PARTIAL, and deliberately typed to say so:
|
|
11
|
+
* PARTIAL, and deliberately typed to say so: 26 key(s) owned by
|
|
12
12
|
* stapel_attributes, stapel_listings are absent, because that owner ships no locale catalog
|
|
13
13
|
* (ERRORS_LOCALE_EXEMPT_OWNERS). English for them still comes from the registry
|
|
14
14
|
* artifact via the en bundle; the pair layers its own authored `es`
|
|
@@ -68,6 +68,8 @@ export const LISTINGS_ERRORS = {
|
|
|
68
68
|
"error.400.invalid_ad_id": { status: 400, params: [], remediation: "fix_input", en: "Invalid advertisement ID" },
|
|
69
69
|
"error.400.listing_feature_not_allowed": { status: 400, params: ["feature"], remediation: "fix_input", en: "Feature '{feature}' is not allowed for this category" },
|
|
70
70
|
"error.400.listing_invalid_status_filter": { status: 400, params: ["status"], remediation: "fix_input", en: "Unknown listing status '{status}'" },
|
|
71
|
+
"error.400.listing_location_required": { status: 400, params: [], remediation: "fix_input", en: "Choose where the item is before publishing" },
|
|
72
|
+
"error.400.listing_zero_price_not_allowed": { status: 400, params: [], remediation: "fix_input", en: "A price of 0 is not allowed in this category. Leave the price empty for \"price not stated\"." },
|
|
71
73
|
"error.400.publish_validation_failed": { status: 400, params: [], remediation: "fix_input", en: "Listing validation failed" },
|
|
72
74
|
"error.400.validation_error": { status: 400, params: [], remediation: "fix_input", en: "Validation error" },
|
|
73
75
|
"error.400.verification_failed": { status: 400, params: [], remediation: "verify", en: "Verification failed" },
|
|
@@ -140,6 +142,8 @@ export const LISTINGS_ERROR_CODES: readonly ListingsErrorCode[] = [
|
|
|
140
142
|
"error.400.invalid_ad_id",
|
|
141
143
|
"error.400.listing_feature_not_allowed",
|
|
142
144
|
"error.400.listing_invalid_status_filter",
|
|
145
|
+
"error.400.listing_location_required",
|
|
146
|
+
"error.400.listing_zero_price_not_allowed",
|
|
143
147
|
"error.400.publish_validation_failed",
|
|
144
148
|
"error.400.validation_error",
|
|
145
149
|
"error.400.verification_failed",
|
|
@@ -214,6 +218,8 @@ export const listingsErrorBundleEn: Record<ListingsErrorCode, string> = {
|
|
|
214
218
|
"error.400.invalid_ad_id": "Invalid advertisement ID",
|
|
215
219
|
"error.400.listing_feature_not_allowed": "Feature '{feature}' is not allowed for this category",
|
|
216
220
|
"error.400.listing_invalid_status_filter": "Unknown listing status '{status}'",
|
|
221
|
+
"error.400.listing_location_required": "Choose where the item is before publishing",
|
|
222
|
+
"error.400.listing_zero_price_not_allowed": "A price of 0 is not allowed in this category. Leave the price empty for \"price not stated\".",
|
|
217
223
|
"error.400.publish_validation_failed": "Listing validation failed",
|
|
218
224
|
"error.400.validation_error": "Validation error",
|
|
219
225
|
"error.400.verification_failed": "Verification failed",
|
|
@@ -286,6 +286,20 @@
|
|
|
286
286
|
"remediation": "fix_input",
|
|
287
287
|
"en": "Unknown listing status '{status}'"
|
|
288
288
|
},
|
|
289
|
+
{
|
|
290
|
+
"code": "error.400.listing_location_required",
|
|
291
|
+
"status": 400,
|
|
292
|
+
"params": [],
|
|
293
|
+
"remediation": "fix_input",
|
|
294
|
+
"en": "Choose where the item is before publishing"
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
"code": "error.400.listing_zero_price_not_allowed",
|
|
298
|
+
"status": 400,
|
|
299
|
+
"params": [],
|
|
300
|
+
"remediation": "fix_input",
|
|
301
|
+
"en": "A price of 0 is not allowed in this category. Leave the price empty for \"price not stated\"."
|
|
302
|
+
},
|
|
289
303
|
{
|
|
290
304
|
"code": "error.400.publish_validation_failed",
|
|
291
305
|
"status": 400,
|
|
@@ -8,7 +8,7 @@ import type { ListingsErrorCode } from "./errors.gen.js";
|
|
|
8
8
|
/**
|
|
9
9
|
* `ru` texts for the backend error codes this catalog carries.
|
|
10
10
|
*
|
|
11
|
-
* PARTIAL, and deliberately typed to say so:
|
|
11
|
+
* PARTIAL, and deliberately typed to say so: 26 key(s) owned by
|
|
12
12
|
* stapel_attributes, stapel_listings are absent, because that owner ships no locale catalog
|
|
13
13
|
* (ERRORS_LOCALE_EXEMPT_OWNERS). English for them still comes from the registry
|
|
14
14
|
* artifact via the en bundle; the pair layers its own authored `ru`
|
package/src/i18n/keys.ts
CHANGED
|
@@ -114,6 +114,13 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
114
114
|
* quantity in the value cell, so this key carries no `{count}` — it used to,
|
|
115
115
|
* and the live page printed the placeholder. */
|
|
116
116
|
detailStock: "listings.detail.stock",
|
|
117
|
+
/**
|
|
118
|
+
* The VIEWS row's label. Same shape as the stock row and for the same
|
|
119
|
+
* reason: `<Descriptions>` renders the label cell and the number separately,
|
|
120
|
+
* so the key carries no `{count}` — a key that did printed the placeholder
|
|
121
|
+
* on a live page once already.
|
|
122
|
+
*/
|
|
123
|
+
detailViews: "listings.detail.views",
|
|
117
124
|
/** The owner's two primaries on their own listing page. */
|
|
118
125
|
detailEdit: "listings.detail.edit",
|
|
119
126
|
detailTakeDown: "listings.detail.take_down",
|
|
@@ -318,6 +325,7 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
318
325
|
"listings.detail.published_at": "Published {date}",
|
|
319
326
|
"listings.detail.expires_at": "Listed until {date}",
|
|
320
327
|
"listings.detail.stock": "In stock",
|
|
328
|
+
"listings.detail.views": "Views",
|
|
321
329
|
"listings.detail.edit": "Edit listing",
|
|
322
330
|
"listings.detail.take_down": "Take it down",
|
|
323
331
|
|
package/src/i18n/ru.ts
CHANGED
|
@@ -118,6 +118,7 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
118
118
|
"listings.detail.published_at": "Опубликовано {date}",
|
|
119
119
|
"listings.detail.expires_at": "В продаже до {date}",
|
|
120
120
|
"listings.detail.stock": "В наличии",
|
|
121
|
+
"listings.detail.views": "Просмотры",
|
|
121
122
|
"listings.detail.edit": "Редактировать объявление",
|
|
122
123
|
"listings.detail.take_down": "Снять с публикации",
|
|
123
124
|
|
|
@@ -225,6 +226,14 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
225
226
|
"listings.favorites.sign_in_hint":
|
|
226
227
|
"Избранное хранится в аккаунте, поэтому переходит с вами на другие устройства.",
|
|
227
228
|
|
|
229
|
+
// The two publish checks the 0.17 contract brought with it: a place is
|
|
230
|
+
// required, and a zero price in such a category is an empty field rather
|
|
231
|
+
// than "free".
|
|
232
|
+
"error.400.listing_location_required":
|
|
233
|
+
"Перед публикацией укажите, где находится товар",
|
|
234
|
+
"error.400.listing_zero_price_not_allowed":
|
|
235
|
+
"В этой категории цена 0 недопустима. Оставьте поле пустым — это и есть «цена не указана».",
|
|
236
|
+
|
|
228
237
|
"listings.blocked.sign_in": "Войдите, чтобы сделать это",
|
|
229
238
|
"listings.blocked.guest":
|
|
230
239
|
"Этот аккаунт пока так не может — сначала завершите настройку",
|
package/src/index.ts
CHANGED
|
@@ -67,7 +67,9 @@ export type { ListingsApi } from "./api/listingsApi.js";
|
|
|
67
67
|
export {
|
|
68
68
|
DEFAULT_LISTING_CURRENCY,
|
|
69
69
|
LISTING_STATUSES,
|
|
70
|
+
LISTINGS_ENGAGEMENT_BATCH_LIMIT,
|
|
70
71
|
MODERATION_STATUSES,
|
|
72
|
+
engagementIds,
|
|
71
73
|
} from "./api/types.js";
|
|
72
74
|
export type {
|
|
73
75
|
DeleteResponse,
|
|
@@ -80,6 +82,9 @@ export type {
|
|
|
80
82
|
ListingDetail as ListingDetailData,
|
|
81
83
|
ListingDraft,
|
|
82
84
|
ListingDraftPatch,
|
|
85
|
+
ListingEngagement,
|
|
86
|
+
ListingEngagementBatch,
|
|
87
|
+
ListingEngagementFields,
|
|
83
88
|
ListingFeatureDao,
|
|
84
89
|
ListingFeatureDaoUnion,
|
|
85
90
|
ListingFeatureType,
|
|
@@ -155,6 +160,14 @@ export {
|
|
|
155
160
|
} from "./model/features.js";
|
|
156
161
|
export type { FeatureCopySource } from "./model/features.js";
|
|
157
162
|
|
|
163
|
+
// ── model: the engagement axis (already-seen / view count / the overlay) ─────
|
|
164
|
+
export {
|
|
165
|
+
engagementFor,
|
|
166
|
+
isListingViewed,
|
|
167
|
+
listingViewCount,
|
|
168
|
+
withEngagement,
|
|
169
|
+
} from "./model/engagement.js";
|
|
170
|
+
|
|
158
171
|
// ── model: validation, the mirror and the publish-400 split ──────────────────
|
|
159
172
|
export {
|
|
160
173
|
CATEGORY_FIELD,
|
|
@@ -192,6 +205,7 @@ export type { ListingPageKey } from "./model/queryKeys.js";
|
|
|
192
205
|
export {
|
|
193
206
|
useListing,
|
|
194
207
|
useListingCards,
|
|
208
|
+
useListingEngagement,
|
|
195
209
|
useListingStatus,
|
|
196
210
|
useMyCounters,
|
|
197
211
|
useMyFavorites,
|
|
@@ -241,6 +255,19 @@ export type {
|
|
|
241
255
|
MyListingsBag,
|
|
242
256
|
UseMyListingsOptions,
|
|
243
257
|
} from "./headless/MyListings.js";
|
|
258
|
+
export {
|
|
259
|
+
ListingEngagementContext,
|
|
260
|
+
ListingEngagementScope,
|
|
261
|
+
useEngagedListing,
|
|
262
|
+
useEngagementOverlay,
|
|
263
|
+
useListingEngagementOverlay,
|
|
264
|
+
useListingEngagementScope,
|
|
265
|
+
} from "./headless/Engagement.js";
|
|
266
|
+
export type {
|
|
267
|
+
ListingEngagementBag,
|
|
268
|
+
ListingEngagementScopeProps,
|
|
269
|
+
UseListingEngagementOverlayOptions,
|
|
270
|
+
} from "./headless/Engagement.js";
|
|
244
271
|
export { Favorites, useFavorites, useFavoriteToggle } from "./headless/Favorites.js";
|
|
245
272
|
export type {
|
|
246
273
|
FavoriteToggleBag,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Have I already seen this one?" — the engagement axis of a listing row, and
|
|
3
|
+
* the OVERLAY that is the only way a storefront ever gets an answer.
|
|
4
|
+
*
|
|
5
|
+
* A classified's results page is mostly re-reading: the same twenty offers
|
|
6
|
+
* scrolled past for the third evening in a row. Every mature one marks the
|
|
7
|
+
* rows you already opened, and a shopper who cannot see which those are is
|
|
8
|
+
* paying attention to the same card repeatedly. `viewed` is that mark and
|
|
9
|
+
* `view_count` is the seller-facing counterpart on the listing page.
|
|
10
|
+
*
|
|
11
|
+
* ── Why the row alone is not enough, and the overlay is the load-bearing half
|
|
12
|
+
*
|
|
13
|
+
* stapel-listings puts `viewed` / `view_count` on its own card and detail
|
|
14
|
+
* serializers, and on the listing PAGE that is the end of the story. But the
|
|
15
|
+
* two surfaces a buyer actually scrolls — the home feed and the SERP — are
|
|
16
|
+
* not served by this module at all: their cards come out of the SEARCH index,
|
|
17
|
+
* whose stored document can carry neither a flag that differs per reader nor
|
|
18
|
+
* a counter that moves faster than a re-index. So on exactly the screens the
|
|
19
|
+
* feature exists for, `viewed` and `is_favorited` never arrive on the row,
|
|
20
|
+
* every card renders undimmed with an outline heart, and nothing anywhere
|
|
21
|
+
* reports a problem.
|
|
22
|
+
*
|
|
23
|
+
* `GET /listings/engagement/?ids=…` is the answer the backend built for it:
|
|
24
|
+
* ONE call for a whole page, `{id: {view_count, viewed, is_favorited}}`,
|
|
25
|
+
* `AllowAny` so a signed-out grid is not a second code path. This module is
|
|
26
|
+
* the READING side of it — what a row means, how one entry is found in a
|
|
27
|
+
* batch, and how an entry is laid over a row a container fetched from
|
|
28
|
+
* somewhere else. The id normalizer both the request and its cache key are
|
|
29
|
+
* built from lives with the wire types (`engagementIds`), because the cap it
|
|
30
|
+
* enforces is the server's.
|
|
31
|
+
*
|
|
32
|
+
* ── The rule every function here keeps ────────────────────────────────────
|
|
33
|
+
*
|
|
34
|
+
* **Absent, `null` and `NaN` all mean "draw nothing extra".** No dimming, no
|
|
35
|
+
* number, no warning, no log line, and — for a failed overlay — no banner
|
|
36
|
+
* over a grid that is otherwise working. A pair that printed "0 views" for a
|
|
37
|
+
* field the server never sent would be inventing a fact about a seller's
|
|
38
|
+
* listing; a pair that turned a decoration's 500 into an error state would
|
|
39
|
+
* have taken a working results page away from a shopper over a flag. A grid
|
|
40
|
+
* that renders is worth more than a flag.
|
|
41
|
+
*/
|
|
42
|
+
import type {
|
|
43
|
+
ListingCard,
|
|
44
|
+
ListingEngagement,
|
|
45
|
+
ListingEngagementBatch,
|
|
46
|
+
ListingEngagementFields,
|
|
47
|
+
} from "../api/types.js";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Has this reader already opened this listing?
|
|
51
|
+
*
|
|
52
|
+
* `true` and only `true`. `null` is "we did not ask on this person's behalf"
|
|
53
|
+
* — the same third state `is_favorited` carries for an anonymous read — and a
|
|
54
|
+
* row nobody asked about is not a row somebody has seen.
|
|
55
|
+
*/
|
|
56
|
+
export function isListingViewed(
|
|
57
|
+
row: ListingEngagementFields | undefined
|
|
58
|
+
): boolean {
|
|
59
|
+
return row?.viewed === true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* How many times the listing has been opened, or `undefined` where there is
|
|
64
|
+
* no number to show.
|
|
65
|
+
*
|
|
66
|
+
* `Number.isFinite` rather than `typeof === "number"`: a JSON `NaN` cannot
|
|
67
|
+
* arrive, but a count computed by a host from something absent can, and
|
|
68
|
+
* "NaN views" on a seller's page is worse than no line at all. Zero IS a
|
|
69
|
+
* number and is rendered — "0 views" is a true and useful thing to tell a
|
|
70
|
+
* seller, and the case it must not be confused with (the field is missing) is
|
|
71
|
+
* already `undefined` here.
|
|
72
|
+
*/
|
|
73
|
+
export function listingViewCount(
|
|
74
|
+
row: ListingEngagementFields | undefined
|
|
75
|
+
): number | undefined {
|
|
76
|
+
const raw = row?.view_count;
|
|
77
|
+
return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One listing's entry out of a batch answer, or `undefined`.
|
|
82
|
+
*
|
|
83
|
+
* The wire keys are STRINGS and an id with no listing is simply absent, so
|
|
84
|
+
* this is a lookup rather than an index: `batch.items[id]` would be a type
|
|
85
|
+
* error today and a silent `undefined` tomorrow.
|
|
86
|
+
*/
|
|
87
|
+
export function engagementFor(
|
|
88
|
+
batch: ListingEngagementBatch | undefined,
|
|
89
|
+
id: number
|
|
90
|
+
): ListingEngagement | undefined {
|
|
91
|
+
return batch?.items[String(id)];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Lay an overlay entry over a row, so a card reads ONE object whatever its
|
|
96
|
+
* provenance.
|
|
97
|
+
*
|
|
98
|
+
* The overlay wins where it speaks, and it speaks about all three of its
|
|
99
|
+
* fields at once — it is the answer to a question that was actually asked on
|
|
100
|
+
* this reader's behalf, and the row (a search document, or a card serialized
|
|
101
|
+
* before the fields existed) is at best older and at worst silent. Where
|
|
102
|
+
* there is no overlay entry the row is returned UNCHANGED, by identity: a
|
|
103
|
+
* missing id, an overlay that has not loaded, and an overlay that failed are
|
|
104
|
+
* the same "we know nothing more than the row does", and none of them may
|
|
105
|
+
* fabricate a `false`.
|
|
106
|
+
*/
|
|
107
|
+
export function withEngagement(
|
|
108
|
+
row: ListingCard,
|
|
109
|
+
overlay: ListingEngagement | undefined
|
|
110
|
+
): ListingCard {
|
|
111
|
+
if (overlay === undefined) return row;
|
|
112
|
+
return {
|
|
113
|
+
...row,
|
|
114
|
+
viewed: overlay.viewed,
|
|
115
|
+
view_count: overlay.view_count,
|
|
116
|
+
is_favorited: overlay.is_favorited,
|
|
117
|
+
};
|
|
118
|
+
}
|
package/src/model/queries.ts
CHANGED
|
@@ -5,11 +5,13 @@ import type { StapelApiError } from "@stapel/core";
|
|
|
5
5
|
import type { ValidationBatchResult } from "@stapel/attributes-react";
|
|
6
6
|
import type {
|
|
7
7
|
ListingDetail,
|
|
8
|
+
ListingEngagementBatch,
|
|
8
9
|
ListingPageParams,
|
|
9
10
|
ListingStatusInfo,
|
|
10
11
|
MyCounters,
|
|
11
12
|
PaginatedListingCards,
|
|
12
13
|
} from "../api/types.js";
|
|
14
|
+
import { engagementIds } from "../api/types.js";
|
|
13
15
|
import { useListingsApi } from "./context.js";
|
|
14
16
|
import { listingsQueryKeys, pageKey } from "./queryKeys.js";
|
|
15
17
|
|
|
@@ -148,3 +150,42 @@ export function useValidateDraft(
|
|
|
148
150
|
retry: false,
|
|
149
151
|
});
|
|
150
152
|
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The per-viewer ENGAGEMENT overlay for one page of ids, in one request.
|
|
156
|
+
*
|
|
157
|
+
* `AllowAny` upstream, so it joins the ungated regime described at the top of
|
|
158
|
+
* this file: a signed-out grid asks exactly the same question and is told
|
|
159
|
+
* `null` for both per-viewer flags. Gating it on the session substrate would
|
|
160
|
+
* make a shop window wait for a login bootstrap in order to decorate itself.
|
|
161
|
+
*
|
|
162
|
+
* ── Every failure mode here is a NO-OP, on purpose ────────────────────────
|
|
163
|
+
*
|
|
164
|
+
* `retry: false`, no error surface, and `enabled` false for an empty page.
|
|
165
|
+
* This read decorates a grid that has already rendered from somewhere else:
|
|
166
|
+
* if it 500s, times out, or is never wired at all, the cards must draw
|
|
167
|
+
* exactly as they drew before it existed. Retrying a decoration three times
|
|
168
|
+
* spends a person's connection on a flag, and an error banner over a working
|
|
169
|
+
* results page trades the thing they came for against the thing they did not
|
|
170
|
+
* ask about. `useEngagementOverlay` therefore reads only `data`, and the
|
|
171
|
+
* failure is available to a host that wants it and rendered by nobody.
|
|
172
|
+
*
|
|
173
|
+
* `placeholderData: keepPreviousData` for the paging case: a grid whose cards
|
|
174
|
+
* un-dim for a moment on every page change is reporting a state change that
|
|
175
|
+
* did not happen. The previous answer is still true about the previous ids,
|
|
176
|
+
* and `withEngagement` only ever reads the entries whose ids it asked for.
|
|
177
|
+
*/
|
|
178
|
+
export function useListingEngagement(
|
|
179
|
+
ids: readonly number[],
|
|
180
|
+
options?: { readonly enabled?: boolean }
|
|
181
|
+
): UseQueryResult<ListingEngagementBatch, StapelApiError> {
|
|
182
|
+
const api = useListingsApi();
|
|
183
|
+
const wanted = engagementIds(ids);
|
|
184
|
+
return useQuery({
|
|
185
|
+
queryKey: listingsQueryKeys.engagement(wanted),
|
|
186
|
+
queryFn: ({ signal }) => api.engagement(wanted, { signal }),
|
|
187
|
+
enabled: (options?.enabled ?? true) && wanted.length > 0,
|
|
188
|
+
placeholderData: keepPreviousData,
|
|
189
|
+
retry: false,
|
|
190
|
+
});
|
|
191
|
+
}
|
package/src/model/queryKeys.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* produces a different request cannot silently reuse a page — the stale-page
|
|
12
12
|
* bug (new filter, cached rows) is unwritable rather than merely avoided.
|
|
13
13
|
*/
|
|
14
|
+
import { engagementIds } from "../api/types.js";
|
|
14
15
|
import type { ListingPageParams } from "../api/types.js";
|
|
15
16
|
|
|
16
17
|
/** The normalized page cursor a list read is keyed on. */
|
|
@@ -67,6 +68,18 @@ export const listingsQueryKeys: {
|
|
|
67
68
|
allFavorites(): readonly ["listings", "my", "favorites"];
|
|
68
69
|
/** Every published-card PAGE, same reason. */
|
|
69
70
|
allLists(): readonly ["listings", "list"];
|
|
71
|
+
/**
|
|
72
|
+
* The per-viewer overlay for one page of ids.
|
|
73
|
+
*
|
|
74
|
+
* Keyed on the NORMALIZED list (`engagementIds`: sorted, de-duplicated,
|
|
75
|
+
* capped) and joined into one string, which is the same value the request
|
|
76
|
+
* sends. The doctrine at the top of this file applied to a batch read: two
|
|
77
|
+
* renders asking for the same ids in a different order are asking the
|
|
78
|
+
* identical question — the answer is a map keyed by id and carries no order
|
|
79
|
+
* of its own — so they must share one cache entry and cost one request.
|
|
80
|
+
* Keyed on the raw array they would cost two, per re-render, per grid.
|
|
81
|
+
*/
|
|
82
|
+
engagement(ids: readonly number[]): readonly ["listings", "engagement", string];
|
|
70
83
|
} = {
|
|
71
84
|
all: [ROOT],
|
|
72
85
|
list: (page) => [ROOT, "list", page],
|
|
@@ -80,4 +93,5 @@ export const listingsQueryKeys: {
|
|
|
80
93
|
validateDraft: (id) => [ROOT, "validate-draft", id],
|
|
81
94
|
allFavorites: () => [ROOT, "my", "favorites"],
|
|
82
95
|
allLists: () => [ROOT, "list"],
|
|
96
|
+
engagement: (ids) => [ROOT, "engagement", engagementIds(ids).join(",")],
|
|
83
97
|
};
|