@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.
- package/CHANGELOG.md +59 -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 +92 -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 +18 -9
- 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 +114 -7
- package/src/default/ListingCard.tsx +84 -10
- package/src/default/ListingDetailPane.tsx +77 -20
- 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
|
@@ -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
|
};
|