@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,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ENGAGEMENT OVERLAY as a scope: one request for a page of cards, read by
|
|
3
|
+
* every card in it without a single extra prop.
|
|
4
|
+
*
|
|
5
|
+
* ── The problem this shape exists to solve ────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* `viewed`, `view_count` and `is_favorited` are on stapel-listings' own card
|
|
8
|
+
* and detail serializers, so a listing PAGE and this module's own card list
|
|
9
|
+
* carry them and need nothing here. The two surfaces a buyer actually
|
|
10
|
+
* scrolls do not: a storefront's home feed and its SERP are drawn by
|
|
11
|
+
* `@stapel/search-react` from the search index, whose stored document can
|
|
12
|
+
* hold neither a flag that differs per reader nor a counter that moves faster
|
|
13
|
+
* than a re-index. Those rows reach `<ListingCard>` with no engagement fields
|
|
14
|
+
* at all — so without this, every card on the two screens the feature was
|
|
15
|
+
* built for renders undimmed with an outline heart no matter what the person
|
|
16
|
+
* has done, and nothing anywhere reports a fault.
|
|
17
|
+
*
|
|
18
|
+
* ── Why a SCOPE and not a prop ────────────────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* Because the cost is per PAGE and the reader is per CARD, and a design that
|
|
21
|
+
* puts those in the same place gets one of them wrong. A `engagement` prop on
|
|
22
|
+
* the card would make each card responsible for its own answer, and the
|
|
23
|
+
* shortest way to satisfy that is one request per card — forty requests for a
|
|
24
|
+
* decoration, which is the exact N+1 the batch endpoint exists to prevent.
|
|
25
|
+
* The container knows the page; the card knows its id. So the container opens
|
|
26
|
+
* a scope with the ids it just rendered, ONE query runs, and each card looks
|
|
27
|
+
* itself up. It is the arrangement `GateReasonScopeContext` / `<PaneGate>`
|
|
28
|
+
* already use in this fleet for the same shape of problem.
|
|
29
|
+
*
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <ListingEngagementScope ids={items.map((item) => item.id)}>
|
|
32
|
+
* <FeedGrid>
|
|
33
|
+
* {items.map((item) => <ListingFeedCard key={item.id} listing={item.card} … />)}
|
|
34
|
+
* </FeedGrid>
|
|
35
|
+
* </ListingEngagementScope>
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* ── No scope is a first-class answer ──────────────────────────────────────
|
|
39
|
+
*
|
|
40
|
+
* A card outside a scope fires NOTHING and draws from its row exactly as it
|
|
41
|
+
* did before this module existed — which is right for this module's own card
|
|
42
|
+
* list and for the listing page, whose rows already carry the fields. Absent,
|
|
43
|
+
* empty, in-flight and FAILED are all the same silent no-op: no dimming, no
|
|
44
|
+
* count, no banner over a grid that is otherwise working. A grid that renders
|
|
45
|
+
* is worth more than a flag.
|
|
46
|
+
*/
|
|
47
|
+
import { createContext, useContext, useMemo } from "react";
|
|
48
|
+
import type { Context, ReactElement, ReactNode } from "react";
|
|
49
|
+
import { loadFailed, loadLoading, loadReady } from "@stapel/core";
|
|
50
|
+
import type { LoadState } from "@stapel/core";
|
|
51
|
+
import type {
|
|
52
|
+
ListingCard,
|
|
53
|
+
ListingEngagement,
|
|
54
|
+
ListingEngagementBatch,
|
|
55
|
+
} from "../api/types.js";
|
|
56
|
+
import { engagementIds } from "../api/types.js";
|
|
57
|
+
import { useListingEngagement } from "../model/queries.js";
|
|
58
|
+
import { engagementFor, withEngagement } from "../model/engagement.js";
|
|
59
|
+
|
|
60
|
+
/** What the scope publishes: the batch, however it is doing. */
|
|
61
|
+
export interface ListingEngagementBag {
|
|
62
|
+
/** This listing's entry, or `undefined` for an id the answer did not carry
|
|
63
|
+
* — which includes "the read has not landed" and "the read failed". */
|
|
64
|
+
overlayFor(id: number): ListingEngagement | undefined;
|
|
65
|
+
/** The read's own state, for a host that wants to know. NOTHING in this
|
|
66
|
+
* pair renders the failed arm: see the file header. */
|
|
67
|
+
readonly state: LoadState<ListingEngagementBatch>;
|
|
68
|
+
/** The normalized ids actually asked for — after the sort, the de-dupe and
|
|
69
|
+
* the server's own cap. A caller can compare it with what it handed in to
|
|
70
|
+
* see that a hundred-and-forty-card page was truncated. */
|
|
71
|
+
readonly ids: readonly number[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const NO_OVERLAY: ListingEngagementBag = {
|
|
75
|
+
overlayFor: () => undefined,
|
|
76
|
+
state: loadLoading(),
|
|
77
|
+
ids: [],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The scope. `null` outside a provider — and `null` is not an error, it is a
|
|
82
|
+
* container that never opted in.
|
|
83
|
+
*/
|
|
84
|
+
export const ListingEngagementContext: Context<ListingEngagementBag | null> =
|
|
85
|
+
createContext<ListingEngagementBag | null>(null);
|
|
86
|
+
|
|
87
|
+
export interface UseListingEngagementOverlayOptions {
|
|
88
|
+
/** Hold the request (a grid that has not resolved its rows yet). */
|
|
89
|
+
readonly enabled?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Run the batch read for a page of ids and get the bag back.
|
|
94
|
+
*
|
|
95
|
+
* The headless half: a host rendering its own visuals over its own grid calls
|
|
96
|
+
* this directly. A host using this pair's cards wraps them in
|
|
97
|
+
* {@link ListingEngagementScope} instead, which calls this once and puts the
|
|
98
|
+
* bag where the cards can find it.
|
|
99
|
+
*/
|
|
100
|
+
export function useListingEngagementOverlay(
|
|
101
|
+
ids: readonly number[],
|
|
102
|
+
options: UseListingEngagementOverlayOptions = {}
|
|
103
|
+
): ListingEngagementBag {
|
|
104
|
+
const query = useListingEngagement(
|
|
105
|
+
ids,
|
|
106
|
+
options.enabled !== undefined ? { enabled: options.enabled } : {}
|
|
107
|
+
);
|
|
108
|
+
const wanted = useMemo(() => engagementIds(ids), [ids]);
|
|
109
|
+
const batch = query.data;
|
|
110
|
+
return useMemo(
|
|
111
|
+
() => ({
|
|
112
|
+
overlayFor: (id: number) => engagementFor(batch, id),
|
|
113
|
+
state:
|
|
114
|
+
query.status === "error"
|
|
115
|
+
? loadFailed(query.error)
|
|
116
|
+
: batch !== undefined
|
|
117
|
+
? loadReady(batch)
|
|
118
|
+
: loadLoading(),
|
|
119
|
+
ids: wanted,
|
|
120
|
+
}),
|
|
121
|
+
[batch, query.status, query.error, wanted]
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ListingEngagementScopeProps
|
|
126
|
+
extends UseListingEngagementOverlayOptions {
|
|
127
|
+
/** The listing ids on this page — normalized before the request, so the
|
|
128
|
+
* caller may hand them in any order and need not de-duplicate. */
|
|
129
|
+
readonly ids: readonly number[];
|
|
130
|
+
readonly children: ReactNode;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One request for the page, one lookup for every card inside it.
|
|
135
|
+
*
|
|
136
|
+
* Renderless: it draws no element of its own, so it can be wrapped around a
|
|
137
|
+
* grid without entering its layout.
|
|
138
|
+
*/
|
|
139
|
+
export function ListingEngagementScope(
|
|
140
|
+
props: ListingEngagementScopeProps
|
|
141
|
+
): ReactElement {
|
|
142
|
+
const bag = useListingEngagementOverlay(
|
|
143
|
+
props.ids,
|
|
144
|
+
props.enabled !== undefined ? { enabled: props.enabled } : {}
|
|
145
|
+
);
|
|
146
|
+
return (
|
|
147
|
+
<ListingEngagementContext.Provider value={bag}>
|
|
148
|
+
{props.children}
|
|
149
|
+
</ListingEngagementContext.Provider>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The scope's bag, or a permanently-empty one outside a scope. Never throws:
|
|
154
|
+
* a card must render everywhere, wired or not. */
|
|
155
|
+
export function useListingEngagementScope(): ListingEngagementBag {
|
|
156
|
+
return useContext(ListingEngagementContext) ?? NO_OVERLAY;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** One listing's overlay entry, or `undefined`. */
|
|
160
|
+
export function useEngagementOverlay(
|
|
161
|
+
id: number
|
|
162
|
+
): ListingEngagement | undefined {
|
|
163
|
+
return useListingEngagementScope().overlayFor(id);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A row with the scope's answer laid over it — what every card in this pair
|
|
168
|
+
* actually reads.
|
|
169
|
+
*
|
|
170
|
+
* Outside a scope, or for an id the batch did not carry, this returns the
|
|
171
|
+
* SAME object it was given, so a card that never opted in re-renders exactly
|
|
172
|
+
* as often as it used to.
|
|
173
|
+
*/
|
|
174
|
+
export function useEngagedListing(row: ListingCard): ListingCard {
|
|
175
|
+
const overlay = useEngagementOverlay(row.id);
|
|
176
|
+
return useMemo(() => withEngagement(row, overlay), [row, overlay]);
|
|
177
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
2
|
import type { ReactElement, ReactNode } from "react";
|
|
3
3
|
import {
|
|
4
4
|
actionAvailable,
|
|
@@ -27,7 +27,25 @@ import {
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
export interface FavoriteToggleBag {
|
|
30
|
+
/**
|
|
31
|
+
* What the heart must DRAW: the predicted state while a toggle is in
|
|
32
|
+
* flight, the row's own answer otherwise. Never `null` — see
|
|
33
|
+
* {@link FavoriteToggleBag.known} for the question `null` answers.
|
|
34
|
+
*/
|
|
30
35
|
readonly favorited: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Whether the row is AUTHORITATIVE about this reader's saved state.
|
|
38
|
+
*
|
|
39
|
+
* `is_favorited` is `boolean | null` and the null is not a `false`: it is
|
|
40
|
+
* "nobody asked on this person's behalf", which is what every anonymous
|
|
41
|
+
* read of a card list sends. Both draw the outline heart — an unsaved
|
|
42
|
+
* listing and an unasked one look the same, and a third mystery look would
|
|
43
|
+
* be a state nobody could name — but a caller deciding whether it may
|
|
44
|
+
* TRUST the row (a favourites count, a "you saved this" line) must be able
|
|
45
|
+
* to tell them apart, and squeezing that into the boolean is how it stops
|
|
46
|
+
* being tellable.
|
|
47
|
+
*/
|
|
48
|
+
readonly known: boolean;
|
|
31
49
|
/** Blocked with the reason for a visitor, a guest, an unknown mandate, or
|
|
32
50
|
* while the previous toggle is still in flight. Never a hidden heart: a
|
|
33
51
|
* control that disappears for a visitor teaches them nothing, and the CTA
|
|
@@ -38,6 +56,12 @@ export interface FavoriteToggleBag {
|
|
|
38
56
|
readonly error: unknown;
|
|
39
57
|
}
|
|
40
58
|
|
|
59
|
+
/** An outstanding prediction, tagged with the listing it is about. */
|
|
60
|
+
interface FavoritePrediction {
|
|
61
|
+
readonly id: number;
|
|
62
|
+
readonly value: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
41
65
|
/**
|
|
42
66
|
* The heart on one card.
|
|
43
67
|
*
|
|
@@ -46,6 +70,38 @@ export interface FavoriteToggleBag {
|
|
|
46
70
|
* reader (`with_favorited` annotates `Value(None)`), which is a THIRD state
|
|
47
71
|
* and not `false`: "we did not ask" versus "not saved". The gate is what
|
|
48
72
|
* renders it, so the distinction never has to be squeezed into the boolean.
|
|
73
|
+
*
|
|
74
|
+
* ── The toggle is OPTIMISTIC, and this is the pair's only such write ───────
|
|
75
|
+
*
|
|
76
|
+
* Measured on a live deployment: a signed-in person taps the heart and
|
|
77
|
+
* nothing on screen moves. The write went out and the invalidation landed,
|
|
78
|
+
* but the row this hook was handed is a PROP — it belongs to a list query one
|
|
79
|
+
* or two components up, and until that query has refetched and re-rendered
|
|
80
|
+
* the grid, the icon is still drawing the state from before the tap. On a
|
|
81
|
+
* phone that is a second or more of a control that looks broken, and the
|
|
82
|
+
* usual response to a control that looks broken is to press it again.
|
|
83
|
+
*
|
|
84
|
+
* So the state the heart draws is the PREDICTION while the write is in
|
|
85
|
+
* flight, and the prediction is allowed here for the reason `mutations.ts`
|
|
86
|
+
* argues at its own hook: the truth is a single boolean the caller already
|
|
87
|
+
* holds, so guessing it is arithmetic rather than invention. Everything else
|
|
88
|
+
* in the pair moves a lifecycle whose next state is the server's to decide.
|
|
89
|
+
*
|
|
90
|
+
* Three ways the prediction ends, and all three are wired:
|
|
91
|
+
*
|
|
92
|
+
* - the write SUCCEEDS → the server's own `favorited` replaces it, so a
|
|
93
|
+
* server that disagreed with the guess wins;
|
|
94
|
+
* - the write FAILS → it rolls back to the state the tap started from,
|
|
95
|
+
* and `error` carries the failure for the surface to
|
|
96
|
+
* show. A heart that stayed filled over a save that
|
|
97
|
+
* never happened is worse than one that never moved;
|
|
98
|
+
* - the MINT fails → `elevation.run` never reaches the write, so there
|
|
99
|
+
* is nothing to roll back FROM; the prediction is
|
|
100
|
+
* dropped the moment the mint's failure appears.
|
|
101
|
+
*
|
|
102
|
+
* And it is tagged with the listing id, because a virtualised grid reuses a
|
|
103
|
+
* hook instance across rows: an untagged prediction would move one card's
|
|
104
|
+
* heart onto its neighbour on scroll.
|
|
49
105
|
*/
|
|
50
106
|
export function useFavoriteToggle(
|
|
51
107
|
id: number,
|
|
@@ -55,6 +111,24 @@ export function useFavoriteToggle(
|
|
|
55
111
|
LISTINGS_ELEVATION_ACTIONS.favorite
|
|
56
112
|
);
|
|
57
113
|
const mutation = useFavoriteListing();
|
|
114
|
+
const [predicted, setPredicted] = useState<FavoritePrediction | undefined>(
|
|
115
|
+
undefined
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const mintError = elevation.error;
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
// The mint failed, so `run` never reached the write — the prediction has
|
|
121
|
+
// nothing behind it and no `onError` coming to retire it.
|
|
122
|
+
if (mintError !== undefined && mintError !== null) setPredicted(undefined);
|
|
123
|
+
}, [mintError]);
|
|
124
|
+
|
|
125
|
+
const mine = predicted !== undefined && predicted.id === id ? predicted : undefined;
|
|
126
|
+
const shown = mine !== undefined ? mine.value : favorited === true;
|
|
127
|
+
// The row caught up: the prediction has become an opinion about the past.
|
|
128
|
+
// Retiring it here (rather than in the success callback) is what stops a
|
|
129
|
+
// stale guess from shadowing a row that changed for some other reason.
|
|
130
|
+
if (mine !== undefined && favorited === mine.value) setPredicted(undefined);
|
|
131
|
+
|
|
58
132
|
const gate = firstBlock(
|
|
59
133
|
mandate,
|
|
60
134
|
mutation.isPending || elevation.pending
|
|
@@ -62,14 +136,30 @@ export function useFavoriteToggle(
|
|
|
62
136
|
: actionAvailable()
|
|
63
137
|
);
|
|
64
138
|
return {
|
|
65
|
-
favorited:
|
|
139
|
+
favorited: shown,
|
|
140
|
+
known: favorited === true || favorited === false,
|
|
66
141
|
gate,
|
|
67
142
|
// On a host with auto-anonymous wired, the first heart an anonymous
|
|
68
143
|
// visitor presses mints their account and then saves — one press, no
|
|
69
144
|
// form, nothing said about it. Everywhere else `run` performs directly.
|
|
70
145
|
toggle: () => {
|
|
71
146
|
if (!gate.available) return;
|
|
72
|
-
|
|
147
|
+
const next = !shown;
|
|
148
|
+
const before = shown;
|
|
149
|
+
setPredicted({ id, value: next });
|
|
150
|
+
elevation.run(() => {
|
|
151
|
+
mutation.mutate(
|
|
152
|
+
{ id, favorited: next },
|
|
153
|
+
{
|
|
154
|
+
onSuccess: (data) => {
|
|
155
|
+
setPredicted({ id, value: data.favorited ?? next });
|
|
156
|
+
},
|
|
157
|
+
onError: () => {
|
|
158
|
+
setPredicted({ id, value: before });
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
});
|
|
73
163
|
},
|
|
74
164
|
inFlight: mutation.isPending || elevation.pending,
|
|
75
165
|
error: mutation.error ?? elevation.error,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
2
|
import type { ReactElement, ReactNode } from "react";
|
|
3
3
|
import {
|
|
4
4
|
actionAvailable,
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
import { useListing, useListingStatus } from "../model/queries.js";
|
|
20
20
|
import { useFavoriteListing } from "../model/mutations.js";
|
|
21
21
|
import { asFeatureDaoList, featuresFromDaoList, unreadableFeatureCount } from "../model/features.js";
|
|
22
|
+
import { isListingViewed, listingViewCount } from "../model/engagement.js";
|
|
22
23
|
import type { FeatureCopySource } from "../model/features.js";
|
|
23
24
|
import { listingStatusView } from "../model/status.js";
|
|
24
25
|
import type { ListingStatusView } from "../model/status.js";
|
|
@@ -104,12 +105,32 @@ export interface ListingDetailBag {
|
|
|
104
105
|
* (`resolveImage`), because no contract in this fleet resolves a
|
|
105
106
|
* stranger's reference — see `model/runtime.ts`. */
|
|
106
107
|
readonly images: readonly string[];
|
|
107
|
-
|
|
108
|
+
/**
|
|
109
|
+
* What the heart must DRAW — the prediction while a toggle is in flight,
|
|
110
|
+
* the row's own answer otherwise, and never `null`. See
|
|
111
|
+
* {@link ListingDetailBag.favoriteKnown} for the fact `null` carried.
|
|
112
|
+
*/
|
|
113
|
+
readonly isFavorited: boolean;
|
|
114
|
+
/**
|
|
115
|
+
* Whether the row is AUTHORITATIVE about the reader's saved state.
|
|
116
|
+
* `is_favorited: null` is an anonymous read's "nobody asked", not a
|
|
117
|
+
* `false`; both draw the outline heart, and only this tells them apart.
|
|
118
|
+
*/
|
|
119
|
+
readonly favoriteKnown: boolean;
|
|
108
120
|
/** Saving a favourite needs a mandate; the block says which of the four
|
|
109
121
|
* reasons applies. */
|
|
110
122
|
readonly favoriteGate: ActionAvailability;
|
|
111
123
|
toggleFavorite(): void;
|
|
112
124
|
readonly favoriteInFlight: boolean;
|
|
125
|
+
/** The last favourite write's failure, for the surface to state. The heart
|
|
126
|
+
* has already rolled back by the time this is set. */
|
|
127
|
+
readonly favoriteError: unknown;
|
|
128
|
+
/** Has this reader already opened this listing? See `model/engagement.ts`
|
|
129
|
+
* — absent on every response today, and `false` when it is. */
|
|
130
|
+
readonly isViewed: boolean;
|
|
131
|
+
/** How many times it has been opened, or `undefined` where the server sent
|
|
132
|
+
* no number. Never a zero standing in for an absence. */
|
|
133
|
+
readonly viewCount: number | undefined;
|
|
113
134
|
refetch(): void;
|
|
114
135
|
}
|
|
115
136
|
|
|
@@ -220,7 +241,25 @@ export function useListingDetail(
|
|
|
220
241
|
[detail.data, copy]
|
|
221
242
|
);
|
|
222
243
|
|
|
223
|
-
|
|
244
|
+
// The row's own answer, and the third state it is allowed to be in: `null`
|
|
245
|
+
// means the read was anonymous and nobody asked on this person's behalf.
|
|
246
|
+
const rowFavorited = detail.data?.is_favorited ?? undefined;
|
|
247
|
+
const [predicted, setPredicted] = useState<boolean | undefined>(undefined);
|
|
248
|
+
|
|
249
|
+
const mintError = elevation.error;
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
// A failed mint never reached the write, so no `onError` is coming to
|
|
252
|
+
// retire the prediction — see `useFavoriteToggle` for the whole argument.
|
|
253
|
+
if (mintError !== undefined && mintError !== null) setPredicted(undefined);
|
|
254
|
+
}, [mintError]);
|
|
255
|
+
|
|
256
|
+
const isFavorited = predicted ?? rowFavorited === true;
|
|
257
|
+
// The detail refetch caught up; the prediction is now an opinion about the
|
|
258
|
+
// past and must stop shadowing a row that may have changed for some other
|
|
259
|
+
// reason.
|
|
260
|
+
if (predicted !== undefined && rowFavorited === predicted) {
|
|
261
|
+
setPredicted(undefined);
|
|
262
|
+
}
|
|
224
263
|
|
|
225
264
|
const favoriteGate = firstBlock(
|
|
226
265
|
mandate,
|
|
@@ -250,14 +289,35 @@ export function useListingDetail(
|
|
|
250
289
|
),
|
|
251
290
|
images: detail.data?.images ?? NO_IMAGES,
|
|
252
291
|
isFavorited,
|
|
292
|
+
favoriteKnown: rowFavorited === true || rowFavorited === false,
|
|
253
293
|
favoriteGate,
|
|
254
294
|
toggleFavorite: () => {
|
|
255
295
|
if (!favoriteGate.available) return;
|
|
296
|
+
const next = !isFavorited;
|
|
297
|
+
const before = isFavorited;
|
|
298
|
+
// The icon flips on the gesture; the invalidation below is what makes
|
|
299
|
+
// it true, and the two callbacks are what make it honest if it is not.
|
|
300
|
+
setPredicted(next);
|
|
256
301
|
// Mints the anonymous account first where the host permits it for this
|
|
257
302
|
// action; a direct call everywhere else.
|
|
258
|
-
elevation.run(() =>
|
|
303
|
+
elevation.run(() => {
|
|
304
|
+
favorite.mutate(
|
|
305
|
+
{ id, favorited: next },
|
|
306
|
+
{
|
|
307
|
+
onSuccess: (data) => {
|
|
308
|
+
setPredicted(data.favorited ?? next);
|
|
309
|
+
},
|
|
310
|
+
onError: () => {
|
|
311
|
+
setPredicted(before);
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
);
|
|
315
|
+
});
|
|
259
316
|
},
|
|
260
317
|
favoriteInFlight: favorite.isPending || elevation.pending,
|
|
318
|
+
favoriteError: favorite.error ?? elevation.error,
|
|
319
|
+
isViewed: isListingViewed(detail.data),
|
|
320
|
+
viewCount: listingViewCount(detail.data),
|
|
261
321
|
refetch: () => {
|
|
262
322
|
void detail.refetch();
|
|
263
323
|
void probe.refetch();
|
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,
|