@stapel/listings-react 0.25.10 → 0.27.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 +259 -0
- package/MODULE.md +3 -1
- package/README.md +122 -9
- package/dist/api/generated/schema.d.ts +22 -12
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/CardBadges.d.ts.map +1 -1
- package/dist/default/CardBadges.js +3 -1
- package/dist/default/CardBadges.js.map +1 -1
- package/dist/default/ListingActions.d.ts +108 -0
- package/dist/default/ListingActions.d.ts.map +1 -0
- package/dist/default/ListingActions.js +29 -0
- package/dist/default/ListingActions.js.map +1 -0
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +15 -8
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +170 -62
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +284 -28
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingFeedCard.d.ts.map +1 -1
- package/dist/default/ListingFeedCard.js +2 -1
- package/dist/default/ListingFeedCard.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +5 -2
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +18 -4
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +49 -21
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/default/ShareAction.d.ts +32 -0
- package/dist/default/ShareAction.d.ts.map +1 -0
- package/dist/default/ShareAction.js +116 -0
- package/dist/default/ShareAction.js.map +1 -0
- package/dist/default/actionRow.d.ts +58 -0
- package/dist/default/actionRow.d.ts.map +1 -0
- package/dist/default/actionRow.js +128 -0
- package/dist/default/actionRow.js.map +1 -0
- package/dist/default/cardGallery.d.ts +2 -0
- package/dist/default/cardGallery.d.ts.map +1 -1
- package/dist/default/cardGallery.js +31 -0
- package/dist/default/cardGallery.js.map +1 -1
- package/dist/default/detailGallery.d.ts +77 -0
- package/dist/default/detailGallery.d.ts.map +1 -0
- package/dist/default/detailGallery.js +91 -0
- package/dist/default/detailGallery.js.map +1 -0
- package/dist/default/favorite.d.ts +25 -0
- package/dist/default/favorite.d.ts.map +1 -1
- package/dist/default/favorite.js +29 -3
- package/dist/default/favorite.js.map +1 -1
- package/dist/default/icons.d.ts +19 -0
- package/dist/default/icons.d.ts.map +1 -1
- package/dist/default/icons.js +23 -0
- package/dist/default/icons.js.map +1 -1
- package/dist/default/index.d.ts +11 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +10 -1
- package/dist/default/index.js.map +1 -1
- package/dist/default/movableCluster.d.ts +18 -0
- package/dist/default/movableCluster.d.ts.map +1 -0
- package/dist/default/movableCluster.js +110 -0
- package/dist/default/movableCluster.js.map +1 -0
- package/dist/default/notice.d.ts +14 -0
- package/dist/default/notice.d.ts.map +1 -0
- package/dist/default/notice.js +62 -0
- package/dist/default/notice.js.map +1 -0
- package/dist/headless/MyListings.d.ts +61 -19
- package/dist/headless/MyListings.d.ts.map +1 -1
- package/dist/headless/MyListings.js +58 -19
- package/dist/headless/MyListings.js.map +1 -1
- package/dist/headless/Share.d.ts +103 -0
- package/dist/headless/Share.d.ts.map +1 -0
- package/dist/headless/Share.js +190 -0
- package/dist/headless/Share.js.map +1 -0
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +21 -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 +18 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +9 -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 +45 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +60 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +21 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/model/cardBadges.d.ts +52 -3
- package/dist/model/cardBadges.d.ts.map +1 -1
- package/dist/model/cardBadges.js +74 -12
- package/dist/model/cardBadges.js.map +1 -1
- package/dist/model/mineSource.d.ts +9 -3
- package/dist/model/mineSource.d.ts.map +1 -1
- package/dist/model/mineSource.js.map +1 -1
- package/dist/model/status.d.ts +79 -15
- package/dist/model/status.d.ts.map +1 -1
- package/dist/model/status.js +86 -17
- package/dist/model/status.js.map +1 -1
- package/llms.txt +8 -3
- package/manifest.json +96 -4
- package/nav-manifest.json +1 -1
- package/package.json +10 -10
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +22 -12
- package/src/default/CardBadges.tsx +3 -1
- package/src/default/ListingActions.tsx +196 -0
- package/src/default/ListingCard.tsx +79 -43
- package/src/default/ListingDetailPane.tsx +520 -89
- package/src/default/ListingFeedCard.tsx +2 -0
- package/src/default/ListingPhoto.tsx +28 -0
- package/src/default/ListingSerpCard.tsx +39 -12
- package/src/default/MyListingsPane.tsx +81 -44
- package/src/default/ShareAction.tsx +260 -0
- package/src/default/actionRow.ts +131 -0
- package/src/default/cardGallery.ts +31 -0
- package/src/default/detailGallery.ts +97 -0
- package/src/default/favorite.tsx +68 -1
- package/src/default/icons.tsx +60 -0
- package/src/default/index.ts +37 -1
- package/src/default/movableCluster.tsx +137 -0
- package/src/default/notice.ts +68 -0
- package/src/headless/MyListings.tsx +130 -39
- package/src/headless/Share.tsx +278 -0
- package/src/i18n/es.ts +28 -0
- package/src/i18n/generated/errors.es.gen.ts +1 -1
- package/src/i18n/generated/errors.gen.ts +9 -0
- package/src/i18n/generated/errors.json +31 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/keys.ts +63 -0
- package/src/i18n/ru.ts +28 -0
- package/src/index.ts +22 -0
- package/src/model/cardBadges.ts +124 -12
- package/src/model/mineSource.ts +9 -3
- package/src/model/status.ts +102 -20
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A one-line confirmation, spoken once — the message seam this skin uses and
|
|
3
|
+
* the reason it is not a new dependency.
|
|
4
|
+
*
|
|
5
|
+
* ── There is no fleet-wide toast primitive, and this does not invent one ──
|
|
6
|
+
*
|
|
7
|
+
* `@stapel/tokens-antd/skin` carries `ErrorAlert`, `EmptyState`,
|
|
8
|
+
* `GatedControl`, `SkinDialog` and `SkinConfirm` — every way a pair states a
|
|
9
|
+
* REFUSAL or asks a question — and nothing at all for "that worked". So the
|
|
10
|
+
* seam here is antd's own, which this package already renders through:
|
|
11
|
+
*
|
|
12
|
+
* 1. `App.useApp().message` when the host mounts antd's `<App>`, which is
|
|
13
|
+
* the arrangement antd itself asks for — the notice then inherits the
|
|
14
|
+
* host's `ConfigProvider` theme, its locale and its container;
|
|
15
|
+
* 2. antd's STATIC `message` otherwise, so a host that never mounted `<App>`
|
|
16
|
+
* still gets the confirmation instead of silence.
|
|
17
|
+
*
|
|
18
|
+
* Outside an `<App>`, `App.useApp()` returns `{message: {}, …}` — antd's own
|
|
19
|
+
* default context — which is why the arm is chosen by asking whether the
|
|
20
|
+
* function is there rather than by asking whether a provider is. A version
|
|
21
|
+
* that assumed the context would have been a confirmation that worked in
|
|
22
|
+
* every test and on no deployment.
|
|
23
|
+
*
|
|
24
|
+
* ── And a toast is never the ONLY copy of a confirmation ──────────────────
|
|
25
|
+
*
|
|
26
|
+
* A toast is transient by construction: it appears for three seconds
|
|
27
|
+
* somewhere the person may not be looking, and on a phone it can land under a
|
|
28
|
+
* thumb. So every caller here also paints the same sentence where the gesture
|
|
29
|
+
* happened — `<ShareAction>` states "Link copied" inside the open menu, and
|
|
30
|
+
* the heart's state is on the heart. This is the AMPLIFIER, never the record.
|
|
31
|
+
* A future `SkinNotice` in the substrate replaces the body of this function
|
|
32
|
+
* and nothing else; the callers already speak in resolved sentences.
|
|
33
|
+
*/
|
|
34
|
+
import { useCallback } from "react";
|
|
35
|
+
import { App, message as staticMessage } from "antd";
|
|
36
|
+
|
|
37
|
+
/** Say one short sentence. Resolved copy — this is the skin, not a bag. */
|
|
38
|
+
export type Notice = (text: string) => void;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How long a confirmation stands, in seconds.
|
|
42
|
+
*
|
|
43
|
+
* Two, not antd's default three. Every notice this package raises confirms
|
|
44
|
+
* something the person can already SEE — a filled heart, a sentence standing
|
|
45
|
+
* in the open share menu — so it is an acknowledgement, not information, and
|
|
46
|
+
* an acknowledgement that outstays the gesture is a strip of chrome sitting
|
|
47
|
+
* over the page a thumb was about to press next.
|
|
48
|
+
*/
|
|
49
|
+
export const NOTICE_SECONDS = 2;
|
|
50
|
+
|
|
51
|
+
export function useNotice(): Notice {
|
|
52
|
+
const app = App.useApp();
|
|
53
|
+
const contextual = app.message.success;
|
|
54
|
+
return useCallback(
|
|
55
|
+
(text: string): void => {
|
|
56
|
+
if (typeof contextual === "function") {
|
|
57
|
+
contextual(text, NOTICE_SECONDS);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// No `<App>` above us. antd's static entry renders its own holder into
|
|
61
|
+
// the document, which is exactly right for a host that never opted in,
|
|
62
|
+
// and is a no-op on a server where there is no document to render into.
|
|
63
|
+
if (typeof document === "undefined") return;
|
|
64
|
+
staticMessage.success(text, NOTICE_SECONDS);
|
|
65
|
+
},
|
|
66
|
+
[contextual]
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -20,8 +20,13 @@ import type { MyListingsSource } from "../model/mineSource.js";
|
|
|
20
20
|
import { useListingsApi } from "../model/context.js";
|
|
21
21
|
import { useMyCounters } from "../model/queries.js";
|
|
22
22
|
import { listingsQueryKeys, pageKey } from "../model/queryKeys.js";
|
|
23
|
-
import {
|
|
24
|
-
|
|
23
|
+
import {
|
|
24
|
+
MY_LISTINGS_COUNTED_TABS,
|
|
25
|
+
MY_LISTINGS_REMOVED_TAB,
|
|
26
|
+
MY_LISTINGS_TABS,
|
|
27
|
+
MY_LISTINGS_UNTABBED_STATUSES,
|
|
28
|
+
} from "../model/status.js";
|
|
29
|
+
import type { MyListingsCountedTab, MyListingsTab } from "../model/status.js";
|
|
25
30
|
import { browserAddress, tabFromSearch } from "../model/tabAddress.js";
|
|
26
31
|
import type { MyListingsAddress } from "../model/tabAddress.js";
|
|
27
32
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
@@ -37,45 +42,87 @@ import { useMandateGate } from "./useMandateGate.js";
|
|
|
37
42
|
* keeps the argument and the seam that came out of it.
|
|
38
43
|
*
|
|
39
44
|
* TWO reads, not one, and the second one is the point of this file's shape:
|
|
40
|
-
* the three tabs are the SERVER's status groupings, and `blocked` — a
|
|
45
|
+
* the three counted tabs are the SERVER's status groupings, and `blocked` — a
|
|
41
46
|
* moderation takedown — is in none of them, because `my/counters` counts it
|
|
42
47
|
* in none of them. A dashboard that only ever asked for a tab's statuses
|
|
43
48
|
* would hide exactly the listing whose owner most needs to know. So
|
|
44
49
|
* `blockedRows` is fetched beside them, off the same route, narrowed to
|
|
45
50
|
* whatever `MY_LISTINGS_UNTABBED_STATUSES` derives.
|
|
51
|
+
*
|
|
52
|
+
* That second read used to be the fourth tab's COUNT as well (D407):
|
|
53
|
+
* `my/counters` had three integers and no fourth, so the removed tab was
|
|
54
|
+
* counted from the rows it holds — honest, because that read is unpaged and
|
|
55
|
+
* runs whichever tab is open, and blind to exactly one thing: a page cap. A
|
|
56
|
+
* seller with more takedowns than one page of `my/listings` saw the page count
|
|
57
|
+
* and not the total, and there was no counter on the wire to check it against.
|
|
58
|
+
*
|
|
59
|
+
* **stapel-listings 0.22.4 answers it**: `MyCountersResponse.blocked`, required
|
|
60
|
+
* beside the other three, over the same owner scope and the same grouping. So
|
|
61
|
+
* the tab's number is the SERVER's now and the unpaged read is what it always
|
|
62
|
+
* was underneath — the removed tab's ROWS. The two consequences are both
|
|
63
|
+
* visible: the tab appears from the counter (a beat earlier, and without the
|
|
64
|
+
* seller's whole takedown page having to land first), and a seller with more
|
|
65
|
+
* takedowns than fit on a page is told how many there are rather than how many
|
|
66
|
+
* arrived. A deployment on a server older than 0.22.4 sends no `blocked` at
|
|
67
|
+
* all, and then the rows are the count again — degraded to the previous
|
|
68
|
+
* answer, never to a `0` (see `MyListingsBag.tabCounts`).
|
|
46
69
|
*/
|
|
47
70
|
|
|
48
71
|
export interface MyListingsBag {
|
|
49
72
|
readonly tab: MyListingsTab;
|
|
73
|
+
/**
|
|
74
|
+
* The tabs to DRAW, in order.
|
|
75
|
+
*
|
|
76
|
+
* The server's three always; the removed one only where there is something
|
|
77
|
+
* in it or the person asked for it by address. An empty "Taken down" tab is
|
|
78
|
+
* a scare, and the seller it would scare is the one it has nothing to tell.
|
|
79
|
+
*
|
|
80
|
+
* "Something in it" is `counters.blocked` (stapel-listings 0.22.4) and no
|
|
81
|
+
* longer the unpaged takedown page: the two reads settle independently, and
|
|
82
|
+
* the one that decides whether a tab strip has three tabs or four should be
|
|
83
|
+
* the one that answers in three integers rather than the one that answers in
|
|
84
|
+
* a page of listings.
|
|
85
|
+
*/
|
|
50
86
|
readonly tabs: readonly MyListingsTab[];
|
|
51
87
|
setTab(tab: MyListingsTab): void;
|
|
52
|
-
/** The
|
|
88
|
+
/** The four real counts, as the server reports them. */
|
|
53
89
|
readonly counters: LoadState<MyCounters>;
|
|
54
90
|
/**
|
|
55
91
|
* The number to DRAW on each tab — the server's counter, raised to what is
|
|
56
|
-
* actually on screen.
|
|
92
|
+
* actually on screen, plus the fourth tab's own.
|
|
57
93
|
*
|
|
58
|
-
* D407: a moderator-rejected listing was on the
|
|
59
|
-
* reading `0`. The two sets are grouped in two
|
|
60
|
-
* aggregates server-side, `MY_LISTINGS_TAB_STATUSES`
|
|
61
|
-
* a tab ASKS for — and any disagreement between them
|
|
62
|
-
* an older counter, a status added upstream, a
|
|
63
|
-
* lands as a badge contradicting the rows
|
|
94
|
+
* D407, twice over. The first half: a moderator-rejected listing was on the
|
|
95
|
+
* Drafts tab under a badge reading `0`. The two sets are grouped in two
|
|
96
|
+
* places — `my/counters` aggregates server-side, `MY_LISTINGS_TAB_STATUSES`
|
|
97
|
+
* decides which statuses a tab ASKS for — and any disagreement between them
|
|
98
|
+
* (a deployment running an older counter, a status added upstream, a
|
|
99
|
+
* grouping changed on one side) lands as a badge contradicting the rows
|
|
100
|
+
* underneath it. A count smaller than what a person can see is not a count,
|
|
101
|
+
* so the loaded rows are treated as evidence: for the OPEN tab the number is
|
|
102
|
+
* never below `rows.length`. It is a floor and not a replacement — the rows
|
|
103
|
+
* are one keyset page and the counter is the whole set, so the counter still
|
|
104
|
+
* wins whenever it is the larger of the two.
|
|
64
105
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* `
|
|
68
|
-
*
|
|
69
|
-
*
|
|
106
|
+
* The second half: a takedown was in no tab and therefore in no number, so
|
|
107
|
+
* a cabinet holding one read "Active 0 · Drafts 0 · Archived 0" over it.
|
|
108
|
+
* `removed` was counted from `blockedRows`, because there was no server
|
|
109
|
+
* counter to read and the rows were the only evidence on the wire. There is
|
|
110
|
+
* one now — `MyCountersResponse.blocked`, stapel-listings 0.22.4 — and it is
|
|
111
|
+
* what the fourth tab draws, under the same floor as the other three: the
|
|
112
|
+
* open tab's number is never below the rows on screen. `blockedRows` stays
|
|
113
|
+
* the FALLBACK for a deployment whose server predates the field, which is a
|
|
114
|
+
* count that is right up to a page and never a `0` over a visible row.
|
|
70
115
|
*/
|
|
71
116
|
readonly tabCounts: LoadState<Readonly<Record<MyListingsTab, number>>>;
|
|
72
|
-
/** The rows for the current tab.
|
|
117
|
+
/** The rows for the current tab — {@link MyListingsBag.blockedRows} while
|
|
118
|
+
* the removed tab is open, and the tab's own keyset page otherwise. */
|
|
73
119
|
readonly rows: LoadState<readonly MyListingCard[]>;
|
|
74
120
|
/**
|
|
75
|
-
* The rows
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
121
|
+
* The rows the server's own counter folds into no tab — a moderation
|
|
122
|
+
* takedown, today; the removed tab's whole contents and its count. Empty
|
|
123
|
+
* for almost every seller; when it is not, it is the most important thing
|
|
124
|
+
* on the screen. Never `failed` in a way that hides the tabs: this read is
|
|
125
|
+
* independent of the tab's own and a skin renders it beside them.
|
|
79
126
|
*/
|
|
80
127
|
readonly blockedRows: LoadState<readonly MyListingCard[]>;
|
|
81
128
|
readonly page: MyListingsParams;
|
|
@@ -136,11 +183,18 @@ export function useMyListings(
|
|
|
136
183
|
[injected, api]
|
|
137
184
|
);
|
|
138
185
|
const ready = sessionReady && gate.available;
|
|
186
|
+
// The fourth tab is served by `blockedQuery` below, not by the host's
|
|
187
|
+
// source: `MyListingsSource` is typed for the three counted tabs and a host
|
|
188
|
+
// that implemented it before D407 has no answer for a fourth.
|
|
189
|
+
const removed = tab === MY_LISTINGS_REMOVED_TAB;
|
|
190
|
+
const countedTab: MyListingsCountedTab = removed
|
|
191
|
+
? MY_LISTINGS_COUNTED_TABS[0]
|
|
192
|
+
: (tab as MyListingsCountedTab);
|
|
139
193
|
|
|
140
194
|
const rowsQuery = useQuery({
|
|
141
|
-
queryKey: listingsQueryKeys.mine(
|
|
142
|
-
queryFn: ({ signal }) => source({ tab, page, signal }),
|
|
143
|
-
enabled: ready,
|
|
195
|
+
queryKey: listingsQueryKeys.mine(countedTab, pageKey(page)),
|
|
196
|
+
queryFn: ({ signal }) => source({ tab: countedTab, page, signal }),
|
|
197
|
+
enabled: ready && !removed,
|
|
144
198
|
retry: false,
|
|
145
199
|
});
|
|
146
200
|
|
|
@@ -155,12 +209,6 @@ export function useMyListings(
|
|
|
155
209
|
retry: false,
|
|
156
210
|
});
|
|
157
211
|
|
|
158
|
-
const rows: LoadState<readonly MyListingCard[]> = useMemo(() => {
|
|
159
|
-
if (rowsQuery.status === "error") return loadFailed(rowsQuery.error);
|
|
160
|
-
if (rowsQuery.data !== undefined) return loadReady(rowsQuery.data.items);
|
|
161
|
-
return loadLoading();
|
|
162
|
-
}, [rowsQuery.status, rowsQuery.error, rowsQuery.data]);
|
|
163
|
-
|
|
164
212
|
const blockedRows: LoadState<readonly MyListingCard[]> = useMemo(() => {
|
|
165
213
|
if (MY_LISTINGS_UNTABBED_STATUSES.length === 0) return loadReady([]);
|
|
166
214
|
if (blockedQuery.status === "error") return loadFailed(blockedQuery.error);
|
|
@@ -168,9 +216,35 @@ export function useMyListings(
|
|
|
168
216
|
return loadLoading();
|
|
169
217
|
}, [blockedQuery.status, blockedQuery.error, blockedQuery.data]);
|
|
170
218
|
|
|
171
|
-
const
|
|
219
|
+
const tabRows: LoadState<readonly MyListingCard[]> = useMemo(() => {
|
|
220
|
+
if (rowsQuery.status === "error") return loadFailed(rowsQuery.error);
|
|
221
|
+
if (rowsQuery.data !== undefined) return loadReady(rowsQuery.data.items);
|
|
222
|
+
return loadLoading();
|
|
223
|
+
}, [rowsQuery.status, rowsQuery.error, rowsQuery.data]);
|
|
224
|
+
|
|
225
|
+
const rows = removed ? blockedRows : tabRows;
|
|
226
|
+
|
|
227
|
+
// The takedown count, when it is known. Not a `0` while the read is in
|
|
228
|
+
// flight: the tab strip is drawn off this number and a tab that appeared a
|
|
229
|
+
// beat after the page settled would move the three beside it.
|
|
230
|
+
//
|
|
231
|
+
// The SERVER's integer first (stapel-listings 0.22.4), the unpaged page's
|
|
232
|
+
// length only where a deployment's backend predates it. `counters.data` is
|
|
233
|
+
// typed with `blocked` required, so the runtime check is the honest one and
|
|
234
|
+
// not a type-driven one: an older server answers 200 with three keys, and a
|
|
235
|
+
// `?? 0` there would print "Taken down 0" over a row the seller can see.
|
|
236
|
+
const rowsBlockedCount =
|
|
237
|
+
blockedRows.status === "ready" ? blockedRows.data.length : undefined;
|
|
238
|
+
const serverBlockedCount =
|
|
239
|
+
typeof counters.data?.blocked === "number" ? counters.data.blocked : undefined;
|
|
240
|
+
const blockedCount = serverBlockedCount ?? rowsBlockedCount;
|
|
172
241
|
|
|
173
|
-
//
|
|
242
|
+
// Paging belongs to the tab's own keyset read. The takedowns are fetched
|
|
243
|
+
// unpaged on purpose, so the removed tab has nowhere to go and says so.
|
|
244
|
+
const envelope = removed ? undefined : rowsQuery.data;
|
|
245
|
+
|
|
246
|
+
// D407, the floor: never a number smaller than the rows on screen — and a
|
|
247
|
+
// number for the fourth tab, which the server counts nowhere. See
|
|
174
248
|
// `MyListingsBag.tabCounts`.
|
|
175
249
|
const tabCounts: LoadState<Readonly<Record<MyListingsTab, number>>> =
|
|
176
250
|
useMemo(() => {
|
|
@@ -178,19 +252,36 @@ export function useMyListings(
|
|
|
178
252
|
if (counters.data === undefined) return loadLoading();
|
|
179
253
|
const server = counters.data;
|
|
180
254
|
const visible = rows.status === "ready" ? rows.data.length : 0;
|
|
181
|
-
return loadReady(
|
|
182
|
-
Object.fromEntries(
|
|
183
|
-
|
|
255
|
+
return loadReady({
|
|
256
|
+
...(Object.fromEntries(
|
|
257
|
+
MY_LISTINGS_COUNTED_TABS.map((one) => [
|
|
184
258
|
one,
|
|
185
259
|
one === tab ? Math.max(server[one], visible) : server[one],
|
|
186
260
|
])
|
|
187
|
-
) as Readonly<Record<
|
|
188
|
-
|
|
189
|
-
|
|
261
|
+
) as Readonly<Record<MyListingsCountedTab, number>>),
|
|
262
|
+
// The fourth tab takes the same floor as the other three: the server's
|
|
263
|
+
// number, raised to what is on screen while this tab is the open one.
|
|
264
|
+
// The counter and the unpaged page are two reads of one set and either
|
|
265
|
+
// may be the staler — a badge under its own rows is not a count.
|
|
266
|
+
[MY_LISTINGS_REMOVED_TAB]:
|
|
267
|
+
tab === MY_LISTINGS_REMOVED_TAB
|
|
268
|
+
? Math.max(blockedCount ?? 0, visible)
|
|
269
|
+
: (blockedCount ?? 0),
|
|
270
|
+
});
|
|
271
|
+
}, [counters.status, counters.error, counters.data, rows, tab, blockedCount]);
|
|
272
|
+
|
|
273
|
+
// The removed tab is drawn where it has something to say — or where the
|
|
274
|
+
// address named it, so `?tab=removed` opens a real (if empty) tab rather
|
|
275
|
+
// than an activeKey pointing at nothing.
|
|
276
|
+
//
|
|
277
|
+
// "Something to say" is the COUNTER since 0.22.4, so the tab arrives with
|
|
278
|
+
// `my/counters` rather than waiting for a page of takedowns to come back.
|
|
279
|
+
const tabs: readonly MyListingsTab[] =
|
|
280
|
+
removed || (blockedCount ?? 0) > 0 ? MY_LISTINGS_TABS : MY_LISTINGS_COUNTED_TABS;
|
|
190
281
|
|
|
191
282
|
return {
|
|
192
283
|
tab,
|
|
193
|
-
tabs
|
|
284
|
+
tabs,
|
|
194
285
|
setTab: (next) => {
|
|
195
286
|
// A cursor belongs to ONE ordered candidate set. Carried across a tab
|
|
196
287
|
// change it either bounces or honestly returns page four of a
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sharing a listing — the verb a classified is judged on and this pair did
|
|
3
|
+
* not have.
|
|
4
|
+
*
|
|
5
|
+
* Measured on the live storefront (owner, 2026-09-06): **no share control of
|
|
6
|
+
* any kind**, on any surface. A person who wanted to send an offer to whoever
|
|
7
|
+
* they are buying it with had the address bar and nothing else, on a phone,
|
|
8
|
+
* where the address bar is the hardest thing on the screen to reach. Every
|
|
9
|
+
* reference classified answers this with one button.
|
|
10
|
+
*
|
|
11
|
+
* ── Two arms, and the device decides which ────────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* `navigator.share` is the whole answer where it exists: the platform's own
|
|
14
|
+
* sheet, with the person's own apps in it, in their own order. It exists on
|
|
15
|
+
* essentially every phone and on almost no desktop, which is why the second
|
|
16
|
+
* arm is not a fallback for old browsers but the DESKTOP rendering — a small
|
|
17
|
+
* menu with "copy the link" and the three networks a Russian-speaking
|
|
18
|
+
* marketplace actually gets traffic from.
|
|
19
|
+
*
|
|
20
|
+
* `native` is resolved in an EFFECT rather than during render, for the reason
|
|
21
|
+
* `cardGallery.ts`'s `useFinePointer` gives at length: a server render has no
|
|
22
|
+
* `navigator`, and a first client render that disagreed with it is a
|
|
23
|
+
* hydration mismatch on every listing page in the app. It opens `false` — the
|
|
24
|
+
* menu arm — because a menu that appears for one frame and is replaced by a
|
|
25
|
+
* button is invisible, while the reverse is a sheet that fails to open.
|
|
26
|
+
*
|
|
27
|
+
* ── The URL is the HOST'S, never `window.location` ────────────────────────
|
|
28
|
+
*
|
|
29
|
+
* A pair does not own routing (`@stapel/core`'s `ui.ts` argues it). The
|
|
30
|
+
* canonical address of a listing is a route the container built, so it
|
|
31
|
+
* arrives as {@link UseShareOptions.url} and is used verbatim. `location.href`
|
|
32
|
+
* is consulted ONLY when the host supplied nothing — which is honest for a
|
|
33
|
+
* bare mount and wrong the moment an app has a canonical URL, because the
|
|
34
|
+
* address bar on a SERP carries the query, the page, the scroll anchor and
|
|
35
|
+
* whatever tracking parameters the visitor arrived with, and none of that
|
|
36
|
+
* belongs in a link somebody sends to a friend.
|
|
37
|
+
*
|
|
38
|
+
* A RELATIVE `url` ("/l/7" — what every route seam in this fleet speaks) is
|
|
39
|
+
* resolved against the document's own base, so a host hands in the same path
|
|
40
|
+
* it hands `<ListingCard href>` and gets an absolute link out.
|
|
41
|
+
*
|
|
42
|
+
* ── Nothing here renders ──────────────────────────────────────────────────
|
|
43
|
+
*
|
|
44
|
+
* The hook returns hrefs and channel identifiers; the copy lives in the skin.
|
|
45
|
+
* A host drawing its own share menu gets the same three links and the same
|
|
46
|
+
* clipboard write without importing antd.
|
|
47
|
+
*/
|
|
48
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
49
|
+
import type { ReactElement, ReactNode } from "react";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where a share went. `"native"` is the platform sheet (which never says
|
|
53
|
+
* WHICH app the person picked — that is the sheet's privacy property, not a
|
|
54
|
+
* gap here), `"copy"` is the clipboard, and the three networks are the
|
|
55
|
+
* explicit links.
|
|
56
|
+
*/
|
|
57
|
+
export type ShareChannel = "native" | "copy" | "telegram" | "whatsapp" | "vk";
|
|
58
|
+
|
|
59
|
+
/** The three networks, in the order the menu draws them. */
|
|
60
|
+
export const SHARE_NETWORKS = ["telegram", "whatsapp", "vk"] as const;
|
|
61
|
+
|
|
62
|
+
export type ShareNetwork = (typeof SHARE_NETWORKS)[number];
|
|
63
|
+
|
|
64
|
+
/** One network's ready-made link. */
|
|
65
|
+
export interface ShareLink {
|
|
66
|
+
readonly channel: ShareNetwork;
|
|
67
|
+
/** Absolute, already encoded. Rendered `target="_blank"` with
|
|
68
|
+
* `rel="noopener noreferrer"` — see `<ShareAction>` for why both. */
|
|
69
|
+
readonly href: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ShareTarget {
|
|
73
|
+
/** The canonical, ABSOLUTE address of what is being shared. */
|
|
74
|
+
readonly url: string;
|
|
75
|
+
/** The listing's title. Empty is a real answer — an untitled listing is
|
|
76
|
+
* still shareable — and then the networks carry the link alone. */
|
|
77
|
+
readonly title?: string | undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The three networks' share endpoints, as pure functions of the target.
|
|
82
|
+
*
|
|
83
|
+
* Separate from the hook and exported so the encoding is a thing a test can
|
|
84
|
+
* read rather than a thing a rendered `<a href>` implies. Each is the
|
|
85
|
+
* network's own documented endpoint:
|
|
86
|
+
*
|
|
87
|
+
* Telegram https://t.me/share/url?url=…&text=…
|
|
88
|
+
* WhatsApp https://wa.me/?text=… (one field: title + url)
|
|
89
|
+
* VK https://vk.com/share.php?url=…&title=…
|
|
90
|
+
*
|
|
91
|
+
* `encodeURIComponent` on every field, without exception. A listing title is
|
|
92
|
+
* seller-supplied text — it contains `&`, `#`, `?` and emoji in the wild —
|
|
93
|
+
* and a title pasted raw into a query string does not merely render oddly, it
|
|
94
|
+
* silently truncates the URL the recipient receives at the first `&`.
|
|
95
|
+
*/
|
|
96
|
+
export function shareLinks(target: ShareTarget): readonly ShareLink[] {
|
|
97
|
+
const url = encodeURIComponent(target.url);
|
|
98
|
+
const title = target.title ?? "";
|
|
99
|
+
const text = encodeURIComponent(title);
|
|
100
|
+
// WhatsApp takes ONE field, so the title and the link travel together in
|
|
101
|
+
// it; the other two carry the address in its own parameter.
|
|
102
|
+
const whatsapp = encodeURIComponent(
|
|
103
|
+
title.length > 0 ? `${title} ${target.url}` : target.url
|
|
104
|
+
);
|
|
105
|
+
return [
|
|
106
|
+
{ channel: "telegram", href: `https://t.me/share/url?url=${url}&text=${text}` },
|
|
107
|
+
{ channel: "whatsapp", href: `https://wa.me/?text=${whatsapp}` },
|
|
108
|
+
{ channel: "vk", href: `https://vk.com/share.php?url=${url}&title=${text}` },
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve what the host handed in into an absolute address.
|
|
114
|
+
*
|
|
115
|
+
* Three cases and no guessing: an absolute URL is returned as it came, a path
|
|
116
|
+
* is resolved against the document's base, and nothing at all falls back to
|
|
117
|
+
* the address bar (see the file header for why that is the last resort and
|
|
118
|
+
* not the default).
|
|
119
|
+
*/
|
|
120
|
+
export function resolveShareUrl(url: string | undefined): string | undefined {
|
|
121
|
+
if (typeof window === "undefined") return url;
|
|
122
|
+
const base = window.document.baseURI;
|
|
123
|
+
if (url === undefined || url.length === 0) return window.location.href;
|
|
124
|
+
try {
|
|
125
|
+
return new URL(url, base).href;
|
|
126
|
+
} catch {
|
|
127
|
+
return url;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Is this environment able to open the platform's own share sheet? */
|
|
132
|
+
export function hasNativeShare(): boolean {
|
|
133
|
+
if (typeof navigator === "undefined") return false;
|
|
134
|
+
return typeof navigator.share === "function";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface UseShareOptions {
|
|
138
|
+
/** The canonical address, absolute or a path. Absent: the address bar,
|
|
139
|
+
* which is the honest answer only for a host with no route seam. */
|
|
140
|
+
readonly url?: string | undefined;
|
|
141
|
+
/** The listing's title — the sheet's heading and the networks' text. */
|
|
142
|
+
readonly title?: string | undefined;
|
|
143
|
+
/** A sentence under the title in the platform sheet (the price, say). */
|
|
144
|
+
readonly text?: string | undefined;
|
|
145
|
+
/** Analytics. Fired once per completed share, with the channel it went
|
|
146
|
+
* through; `"native"` never says which app, because the sheet does not
|
|
147
|
+
* tell the page. */
|
|
148
|
+
readonly onShared?: ((channel: ShareChannel) => void) | undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface ShareBag {
|
|
152
|
+
/** The absolute address every arm shares. `undefined` only on a server. */
|
|
153
|
+
readonly url: string | undefined;
|
|
154
|
+
/** Does this device have the platform's own sheet? Settles in an effect —
|
|
155
|
+
* see the file header. */
|
|
156
|
+
readonly native: boolean;
|
|
157
|
+
/** The three networks' links, already encoded. */
|
|
158
|
+
readonly links: readonly ShareLink[];
|
|
159
|
+
/**
|
|
160
|
+
* The clipboard write LANDED, and this is what the confirmation is drawn
|
|
161
|
+
* from. It clears itself after {@link SHARE_COPIED_MS} so a menu reopened a
|
|
162
|
+
* minute later is not still congratulating the person.
|
|
163
|
+
*/
|
|
164
|
+
readonly copied: boolean;
|
|
165
|
+
/** The clipboard refused (no permission, an insecure origin, a browser
|
|
166
|
+
* without the API). Stated, never swallowed: a person who pressed "copy"
|
|
167
|
+
* and pastes nothing has to be told the press did not work. */
|
|
168
|
+
readonly copyFailed: boolean;
|
|
169
|
+
/** Open the platform sheet. A no-op where there is none — callers branch on
|
|
170
|
+
* {@link ShareBag.native} rather than discovering it here. */
|
|
171
|
+
shareNatively(): void;
|
|
172
|
+
/** Write the address to the clipboard. */
|
|
173
|
+
copy(): void;
|
|
174
|
+
/** Report a network link the person actually followed. The anchor does the
|
|
175
|
+
* navigating; this is the analytics half. */
|
|
176
|
+
report(channel: ShareChannel): void;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** How long the "copied" confirmation stands. Long enough to read, short
|
|
180
|
+
* enough that a reopened menu is not still showing it. */
|
|
181
|
+
export const SHARE_COPIED_MS = 2400;
|
|
182
|
+
|
|
183
|
+
export function useShare(options: UseShareOptions = {}): ShareBag {
|
|
184
|
+
const { url: given, title, text, onShared } = options;
|
|
185
|
+
const [native, setNative] = useState(false);
|
|
186
|
+
const [copied, setCopied] = useState(false);
|
|
187
|
+
const [copyFailed, setCopyFailed] = useState(false);
|
|
188
|
+
|
|
189
|
+
// See the header: resolved in an effect so a server render and the
|
|
190
|
+
// hydration pass that must agree with it draw the same arm.
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
setNative(hasNativeShare());
|
|
193
|
+
}, []);
|
|
194
|
+
|
|
195
|
+
const url = useMemo(() => resolveShareUrl(given), [given]);
|
|
196
|
+
const links = useMemo(
|
|
197
|
+
() => (url === undefined ? [] : shareLinks({ url, title })),
|
|
198
|
+
[url, title]
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (!copied) return undefined;
|
|
203
|
+
const timer = setTimeout(() => {
|
|
204
|
+
setCopied(false);
|
|
205
|
+
}, SHARE_COPIED_MS);
|
|
206
|
+
return () => {
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
};
|
|
209
|
+
}, [copied]);
|
|
210
|
+
|
|
211
|
+
const report = useCallback(
|
|
212
|
+
(channel: ShareChannel): void => {
|
|
213
|
+
onShared?.(channel);
|
|
214
|
+
},
|
|
215
|
+
[onShared]
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const shareNatively = useCallback((): void => {
|
|
219
|
+
if (url === undefined) return;
|
|
220
|
+
const share = typeof navigator === "undefined" ? undefined : navigator.share;
|
|
221
|
+
if (typeof share !== "function") return;
|
|
222
|
+
// The rejection a share sheet ALWAYS produces is `AbortError` — the
|
|
223
|
+
// person closed it — and that is not a failure to report. Nothing is
|
|
224
|
+
// said either way; the sheet is the feedback.
|
|
225
|
+
void Promise.resolve(
|
|
226
|
+
share.call(navigator, {
|
|
227
|
+
url,
|
|
228
|
+
...(title !== undefined && title.length > 0 ? { title } : {}),
|
|
229
|
+
...(text !== undefined && text.length > 0 ? { text } : {}),
|
|
230
|
+
})
|
|
231
|
+
)
|
|
232
|
+
.then(() => {
|
|
233
|
+
report("native");
|
|
234
|
+
})
|
|
235
|
+
.catch(() => undefined);
|
|
236
|
+
}, [url, title, text, report]);
|
|
237
|
+
|
|
238
|
+
const copy = useCallback((): void => {
|
|
239
|
+
if (url === undefined) return;
|
|
240
|
+
setCopyFailed(false);
|
|
241
|
+
const clipboard =
|
|
242
|
+
typeof navigator === "undefined" ? undefined : navigator.clipboard;
|
|
243
|
+
if (clipboard === undefined || typeof clipboard.writeText !== "function") {
|
|
244
|
+
setCopyFailed(true);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
void Promise.resolve(clipboard.writeText(url))
|
|
248
|
+
.then(() => {
|
|
249
|
+
setCopied(true);
|
|
250
|
+
report("copy");
|
|
251
|
+
})
|
|
252
|
+
.catch(() => {
|
|
253
|
+
setCopyFailed(true);
|
|
254
|
+
});
|
|
255
|
+
}, [url, report]);
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
url,
|
|
259
|
+
native,
|
|
260
|
+
links,
|
|
261
|
+
copied,
|
|
262
|
+
copyFailed,
|
|
263
|
+
shareNatively,
|
|
264
|
+
copy,
|
|
265
|
+
report,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Renderless: the bag, handed to a render prop — the shape every headless
|
|
270
|
+
* component in this pair takes. */
|
|
271
|
+
export function Share(
|
|
272
|
+
props: UseShareOptions & {
|
|
273
|
+
children: (bag: ShareBag) => ReactNode;
|
|
274
|
+
}
|
|
275
|
+
): ReactElement {
|
|
276
|
+
const bag = useShare(props);
|
|
277
|
+
return <>{props.children(bag)}</>;
|
|
278
|
+
}
|
package/src/i18n/es.ts
CHANGED
|
@@ -77,10 +77,23 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
77
77
|
"listings.card.untitled": "Anuncio sin título",
|
|
78
78
|
"listings.card.sign_in": "Iniciar sesión",
|
|
79
79
|
"listings.card.photos": "Fotos de este anuncio",
|
|
80
|
+
"listings.card.photo_counter": "{index} de {total}",
|
|
80
81
|
"listings.card.price_was": "Antes",
|
|
81
82
|
"listings.card.price_dropped": "El precio ha bajado",
|
|
82
83
|
"listings.card.price_raised": "El precio ha subido",
|
|
83
84
|
|
|
85
|
+
"listings.share.action": "Compartir",
|
|
86
|
+
"listings.share.copy": "Copiar enlace",
|
|
87
|
+
"listings.share.copied": "Enlace copiado",
|
|
88
|
+
"listings.share.copy_failed":
|
|
89
|
+
"No pudimos copiar el enlace — cópialo de la barra de direcciones",
|
|
90
|
+
"listings.share.telegram": "Telegram",
|
|
91
|
+
"listings.share.whatsapp": "WhatsApp",
|
|
92
|
+
"listings.share.vk": "VK",
|
|
93
|
+
"listings.favorite.added": "Añadido a favoritos",
|
|
94
|
+
"listings.favorite.removed": "Quitado de favoritos",
|
|
95
|
+
"listings.favorite.count": "{count} lo han guardado",
|
|
96
|
+
|
|
84
97
|
"listings.detail.loading": "Cargando el anuncio…",
|
|
85
98
|
"listings.detail.load_failed": "No pudimos cargar este anuncio",
|
|
86
99
|
"listings.detail.retry": "Reintentar",
|
|
@@ -175,6 +188,7 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
175
188
|
"listings.mine.tab.active": "Activos",
|
|
176
189
|
"listings.mine.tab.drafts": "Borradores",
|
|
177
190
|
"listings.mine.tab.archived": "Archivo",
|
|
191
|
+
"listings.mine.tab.removed": "Retirados",
|
|
178
192
|
"listings.mine.loading": "Cargando tus anuncios…",
|
|
179
193
|
"listings.mine.load_failed": "No pudimos cargar tus anuncios",
|
|
180
194
|
"listings.mine.empty": "Aquí todavía no hay nada",
|
|
@@ -183,6 +197,7 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
183
197
|
"listings.mine.empty.active": "No tienes nada publicado ni en revisión",
|
|
184
198
|
"listings.mine.empty.drafts": "Sin borradores: lo que empieces aparecerá aquí",
|
|
185
199
|
"listings.mine.empty.archived": "Nada archivado, pausado, caducado ni vendido todavía",
|
|
200
|
+
"listings.mine.empty.removed": "Moderación no ha retirado nada tuyo",
|
|
186
201
|
"listings.mine.blocked.title":
|
|
187
202
|
"Moderación retiró {count} de tus anuncios",
|
|
188
203
|
"listings.mine.blocked.title.one": "Moderación retiró uno de tus anuncios",
|
|
@@ -203,6 +218,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
203
218
|
"listings.mine.delete_confirm_title": "¿Borrar este anuncio?",
|
|
204
219
|
"listings.mine.delete_confirm_body":
|
|
205
220
|
"Desaparece de tu panel y no se puede recuperar. Archivarlo lo conserva.",
|
|
221
|
+
"listings.mine.delete_confirm_body.final":
|
|
222
|
+
"Desaparece de tu panel y no se puede recuperar.",
|
|
206
223
|
|
|
207
224
|
"listings.favorites.title": "Favoritos",
|
|
208
225
|
"listings.favorites.loading": "Cargando tus favoritos…",
|
|
@@ -222,6 +239,17 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
222
239
|
"error.400.listing_draft_meta_too_large":
|
|
223
240
|
"El borrador es demasiado grande (máximo {max_bytes} bytes). Quita parte de los datos y guarda de nuevo.",
|
|
224
241
|
|
|
242
|
+
// stapel-listings 0.22.3: the features_draft WRITE now accepts the shape a
|
|
243
|
+
// listing READ returns. These three are the refusals for a body that is
|
|
244
|
+
// neither — read by whoever is wiring the integration, so each names the
|
|
245
|
+
// shape it wanted instead of advising "try again".
|
|
246
|
+
"error.400.listing_features_draft_shape":
|
|
247
|
+
"features_draft debe ser un objeto con los slugs de las características como claves, o la lista de objetos de característica que devuelve la lectura del anuncio (cada uno con su propio «slug»). Llegó: {got_type}. Ejemplo de la forma de objeto aceptada: {example}",
|
|
248
|
+
"error.400.listing_features_draft_unknown_slug":
|
|
249
|
+
"Cada elemento de una lista features_draft debe llevar su propio «slug» no vacío — el que la lectura del anuncio guarda en ese elemento — para poder archivarlo bajo la característica correcta. El elemento en el índice {index} no lo tiene. Ejemplo: {example}",
|
|
250
|
+
"error.400.listing_features_draft_value_shape":
|
|
251
|
+
"features_draft['{slug}'] debe ser a su vez un objeto de la forma {{\"type\": <tipo de característica>, \"value\": <valor de característica>}}. Llegó: {got_type}. Ejemplo: {example}",
|
|
252
|
+
|
|
225
253
|
"listings.blocked.sign_in": "Inicia sesión para hacer esto",
|
|
226
254
|
"listings.blocked.guest":
|
|
227
255
|
"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: 30 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`
|