@stapel/listings-react 0.18.2 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/api/generated/schema.d.ts +101 -13
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/listingsApi.d.ts +26 -1
  5. package/dist/api/listingsApi.d.ts.map +1 -1
  6. package/dist/api/listingsApi.js +9 -0
  7. package/dist/api/listingsApi.js.map +1 -1
  8. package/dist/api/types.d.ts +91 -6
  9. package/dist/api/types.d.ts.map +1 -1
  10. package/dist/api/types.js +32 -0
  11. package/dist/api/types.js.map +1 -1
  12. package/dist/default/GateReasonPopover.d.ts.map +1 -1
  13. package/dist/default/GateReasonPopover.js +92 -2
  14. package/dist/default/GateReasonPopover.js.map +1 -1
  15. package/dist/default/ListingCard.d.ts +32 -1
  16. package/dist/default/ListingCard.d.ts.map +1 -1
  17. package/dist/default/ListingCard.js +65 -14
  18. package/dist/default/ListingCard.js.map +1 -1
  19. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  20. package/dist/default/ListingDetailPane.js +18 -9
  21. package/dist/default/ListingDetailPane.js.map +1 -1
  22. package/dist/default/ListingFeedCard.d.ts +32 -7
  23. package/dist/default/ListingFeedCard.d.ts.map +1 -1
  24. package/dist/default/ListingFeedCard.js +11 -3
  25. package/dist/default/ListingFeedCard.js.map +1 -1
  26. package/dist/default/ListingPrice.d.ts +13 -2
  27. package/dist/default/ListingPrice.d.ts.map +1 -1
  28. package/dist/default/ListingPrice.js.map +1 -1
  29. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  30. package/dist/default/ListingSerpCard.js +10 -2
  31. package/dist/default/ListingSerpCard.js.map +1 -1
  32. package/dist/default/favorite.d.ts +11 -0
  33. package/dist/default/favorite.d.ts.map +1 -1
  34. package/dist/default/favorite.js +24 -9
  35. package/dist/default/favorite.js.map +1 -1
  36. package/dist/default/icons.d.ts +17 -3
  37. package/dist/default/icons.d.ts.map +1 -1
  38. package/dist/default/icons.js +2 -1
  39. package/dist/default/icons.js.map +1 -1
  40. package/dist/default/index.d.ts +1 -1
  41. package/dist/default/index.d.ts.map +1 -1
  42. package/dist/default/index.js +4 -1
  43. package/dist/default/index.js.map +1 -1
  44. package/dist/headless/Engagement.d.ts +62 -0
  45. package/dist/headless/Engagement.d.ts.map +1 -0
  46. package/dist/headless/Engagement.js +116 -0
  47. package/dist/headless/Engagement.js.map +1 -0
  48. package/dist/headless/Favorites.d.ts +50 -0
  49. package/dist/headless/Favorites.d.ts.map +1 -1
  50. package/dist/headless/Favorites.js +63 -3
  51. package/dist/headless/Favorites.js.map +1 -1
  52. package/dist/headless/ListingDetail.d.ts +21 -1
  53. package/dist/headless/ListingDetail.d.ts.map +1 -1
  54. package/dist/headless/ListingDetail.js +39 -3
  55. package/dist/headless/ListingDetail.js.map +1 -1
  56. package/dist/i18n/es.d.ts.map +1 -1
  57. package/dist/i18n/es.js +4 -0
  58. package/dist/i18n/es.js.map +1 -1
  59. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  60. package/dist/i18n/generated/errors.es.gen.js +1 -1
  61. package/dist/i18n/generated/errors.gen.d.ts +12 -0
  62. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  63. package/dist/i18n/generated/errors.gen.js +6 -0
  64. package/dist/i18n/generated/errors.gen.js.map +1 -1
  65. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  66. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  67. package/dist/i18n/keys.d.ts +7 -0
  68. package/dist/i18n/keys.d.ts.map +1 -1
  69. package/dist/i18n/keys.js +8 -0
  70. package/dist/i18n/keys.js.map +1 -1
  71. package/dist/i18n/ru.d.ts.map +1 -1
  72. package/dist/i18n/ru.js +6 -0
  73. package/dist/i18n/ru.js.map +1 -1
  74. package/dist/index.d.ts +6 -3
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +5 -2
  77. package/dist/index.js.map +1 -1
  78. package/dist/model/engagement.d.ts +85 -0
  79. package/dist/model/engagement.d.ts.map +1 -0
  80. package/dist/model/engagement.js +59 -0
  81. package/dist/model/engagement.js.map +1 -0
  82. package/dist/model/queries.d.ts +28 -1
  83. package/dist/model/queries.d.ts.map +1 -1
  84. package/dist/model/queries.js +36 -0
  85. package/dist/model/queries.js.map +1 -1
  86. package/dist/model/queryKeys.d.ts +12 -13
  87. package/dist/model/queryKeys.d.ts.map +1 -1
  88. package/dist/model/queryKeys.js +15 -0
  89. package/dist/model/queryKeys.js.map +1 -1
  90. package/llms.txt +6 -3
  91. package/manifest.json +71 -3
  92. package/nav-manifest.json +1 -1
  93. package/package.json +8 -8
  94. package/src/analytics/generated/events.json +1 -1
  95. package/src/api/generated/schema.ts +101 -13
  96. package/src/api/listingsApi.ts +39 -0
  97. package/src/api/types.ts +115 -6
  98. package/src/default/GateReasonPopover.tsx +114 -7
  99. package/src/default/ListingCard.tsx +84 -10
  100. package/src/default/ListingDetailPane.tsx +77 -20
  101. package/src/default/ListingFeedCard.tsx +45 -8
  102. package/src/default/ListingPrice.tsx +13 -2
  103. package/src/default/ListingSerpCard.tsx +11 -1
  104. package/src/default/favorite.tsx +49 -21
  105. package/src/default/icons.tsx +21 -3
  106. package/src/default/index.ts +4 -0
  107. package/src/headless/Engagement.tsx +177 -0
  108. package/src/headless/Favorites.tsx +93 -3
  109. package/src/headless/ListingDetail.tsx +64 -4
  110. package/src/i18n/es.ts +7 -0
  111. package/src/i18n/generated/errors.es.gen.ts +1 -1
  112. package/src/i18n/generated/errors.gen.ts +6 -0
  113. package/src/i18n/generated/errors.json +14 -0
  114. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  115. package/src/i18n/keys.ts +8 -0
  116. package/src/i18n/ru.ts +9 -0
  117. package/src/index.ts +27 -0
  118. package/src/model/engagement.ts +118 -0
  119. package/src/model/queries.ts +41 -0
  120. package/src/model/queryKeys.ts +14 -0
@@ -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
+ }
@@ -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
+ }
@@ -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
  };