@stapel/listings-react 0.25.10 → 0.26.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 +208 -0
- package/README.md +77 -9
- package/dist/api/generated/schema.d.ts +2 -2
- 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 +76 -56
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +164 -18
- 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 +45 -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/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 +8 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +6 -1
- package/dist/default/index.js.map +1 -1
- 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 +39 -18
- package/dist/headless/MyListings.d.ts.map +1 -1
- package/dist/headless/MyListings.js +41 -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 +62 -13
- package/dist/model/status.d.ts.map +1 -1
- package/dist/model/status.js +69 -15
- 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 +2 -2
- 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 +268 -81
- 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 +77 -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/favorite.tsx +68 -1
- package/src/default/icons.tsx +60 -0
- package/src/default/index.ts +23 -0
- package/src/default/notice.ts +68 -0
- package/src/headless/MyListings.tsx +89 -38
- 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 +85 -18
|
@@ -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`
|
|
@@ -68,6 +68,9 @@ 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_draft_meta_too_large": { status: 400, params: ["max_bytes"], remediation: "fix_input", en: "draft_meta is too large ({max_bytes} bytes max)" },
|
|
70
70
|
"error.400.listing_feature_not_allowed": { status: 400, params: ["feature"], remediation: "fix_input", en: "Feature '{feature}' is not allowed for this category" },
|
|
71
|
+
"error.400.listing_features_draft_shape": { status: 400, params: ["got_type","example"], remediation: "fix_input", en: "features_draft must be an object keyed by feature slug, or the list of feature objects a listing read returns (each carrying its own 'slug') — got {got_type}. Example of the accepted object form: {example}" },
|
|
72
|
+
"error.400.listing_features_draft_unknown_slug": { status: 400, params: ["index","example"], remediation: "fix_input", en: "Every entry of a features_draft list must carry its own non-empty 'slug' string (the slug a listing read stores on that element) so it can be filed back under the right feature — entry at index {index} has none. Example: {example}" },
|
|
73
|
+
"error.400.listing_features_draft_value_shape": { status: 400, params: ["slug","got_type","example"], remediation: "fix_input", en: "features_draft['{slug}'] must itself be an object of the form {{\"type\": <feature type>, \"value\": <feature value>}} — got {got_type}. Example: {example}" },
|
|
71
74
|
"error.400.listing_invalid_status_filter": { status: 400, params: ["status"], remediation: "fix_input", en: "Unknown listing status '{status}'" },
|
|
72
75
|
"error.400.listing_location_required": { status: 400, params: [], remediation: "fix_input", en: "Choose where the item is before publishing" },
|
|
73
76
|
"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\"." },
|
|
@@ -143,6 +146,9 @@ export const LISTINGS_ERROR_CODES: readonly ListingsErrorCode[] = [
|
|
|
143
146
|
"error.400.invalid_ad_id",
|
|
144
147
|
"error.400.listing_draft_meta_too_large",
|
|
145
148
|
"error.400.listing_feature_not_allowed",
|
|
149
|
+
"error.400.listing_features_draft_shape",
|
|
150
|
+
"error.400.listing_features_draft_unknown_slug",
|
|
151
|
+
"error.400.listing_features_draft_value_shape",
|
|
146
152
|
"error.400.listing_invalid_status_filter",
|
|
147
153
|
"error.400.listing_location_required",
|
|
148
154
|
"error.400.listing_zero_price_not_allowed",
|
|
@@ -220,6 +226,9 @@ export const listingsErrorBundleEn: Record<ListingsErrorCode, string> = {
|
|
|
220
226
|
"error.400.invalid_ad_id": "Invalid advertisement ID",
|
|
221
227
|
"error.400.listing_draft_meta_too_large": "draft_meta is too large ({max_bytes} bytes max)",
|
|
222
228
|
"error.400.listing_feature_not_allowed": "Feature '{feature}' is not allowed for this category",
|
|
229
|
+
"error.400.listing_features_draft_shape": "features_draft must be an object keyed by feature slug, or the list of feature objects a listing read returns (each carrying its own 'slug') — got {got_type}. Example of the accepted object form: {example}",
|
|
230
|
+
"error.400.listing_features_draft_unknown_slug": "Every entry of a features_draft list must carry its own non-empty 'slug' string (the slug a listing read stores on that element) so it can be filed back under the right feature — entry at index {index} has none. Example: {example}",
|
|
231
|
+
"error.400.listing_features_draft_value_shape": "features_draft['{slug}'] must itself be an object of the form {{\"type\": <feature type>, \"value\": <feature value>}} — got {got_type}. Example: {example}",
|
|
223
232
|
"error.400.listing_invalid_status_filter": "Unknown listing status '{status}'",
|
|
224
233
|
"error.400.listing_location_required": "Choose where the item is before publishing",
|
|
225
234
|
"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\".",
|
|
@@ -286,6 +286,37 @@
|
|
|
286
286
|
"remediation": "fix_input",
|
|
287
287
|
"en": "Feature '{feature}' is not allowed for this category"
|
|
288
288
|
},
|
|
289
|
+
{
|
|
290
|
+
"code": "error.400.listing_features_draft_shape",
|
|
291
|
+
"status": 400,
|
|
292
|
+
"params": [
|
|
293
|
+
"got_type",
|
|
294
|
+
"example"
|
|
295
|
+
],
|
|
296
|
+
"remediation": "fix_input",
|
|
297
|
+
"en": "features_draft must be an object keyed by feature slug, or the list of feature objects a listing read returns (each carrying its own 'slug') — got {got_type}. Example of the accepted object form: {example}"
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
"code": "error.400.listing_features_draft_unknown_slug",
|
|
301
|
+
"status": 400,
|
|
302
|
+
"params": [
|
|
303
|
+
"index",
|
|
304
|
+
"example"
|
|
305
|
+
],
|
|
306
|
+
"remediation": "fix_input",
|
|
307
|
+
"en": "Every entry of a features_draft list must carry its own non-empty 'slug' string (the slug a listing read stores on that element) so it can be filed back under the right feature — entry at index {index} has none. Example: {example}"
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
"code": "error.400.listing_features_draft_value_shape",
|
|
311
|
+
"status": 400,
|
|
312
|
+
"params": [
|
|
313
|
+
"slug",
|
|
314
|
+
"got_type",
|
|
315
|
+
"example"
|
|
316
|
+
],
|
|
317
|
+
"remediation": "fix_input",
|
|
318
|
+
"en": "features_draft['{slug}'] must itself be an object of the form {{\"type\": <feature type>, \"value\": <feature value>}} — got {got_type}. Example: {example}"
|
|
319
|
+
},
|
|
289
320
|
{
|
|
290
321
|
"code": "error.400.listing_invalid_status_filter",
|
|
291
322
|
"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: 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 `ru`
|
package/src/i18n/keys.ts
CHANGED
|
@@ -79,6 +79,10 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
79
79
|
* requires one — an unnamed scrollable region is announced as nothing — and
|
|
80
80
|
* the token bridge owns no i18n engine, so the copy is this pair's. */
|
|
81
81
|
cardPhotos: "listings.card.photos",
|
|
82
|
+
/** "3 of 16" over a card's photo strip. The dots say WHERE in the strip a
|
|
83
|
+
* reader is; only a counter says how much of it there is, and the reference
|
|
84
|
+
* classifieds all carry one. */
|
|
85
|
+
cardPhotoCounter: "listings.card.photo_counter",
|
|
82
86
|
/** The label on the struck-through previous price. The strike is styling and
|
|
83
87
|
* a screen reader announces none of it, so the word is on the line. */
|
|
84
88
|
cardPriceWas: "listings.card.price_was",
|
|
@@ -87,6 +91,35 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
87
91
|
cardPriceDropped: "listings.card.price_dropped",
|
|
88
92
|
cardPriceRaised: "listings.card.price_raised",
|
|
89
93
|
|
|
94
|
+
// ── the action cluster: sharing, and what favouriting says back ──────────
|
|
95
|
+
/** The share control's caption AND its accessible name — one string, so a
|
|
96
|
+
* phone (icon only) and a desktop (icon + word) announce the same verb. */
|
|
97
|
+
shareAction: "listings.share.action",
|
|
98
|
+
/** The menu's first row: put the address on the clipboard. */
|
|
99
|
+
shareCopy: "listings.share.copy",
|
|
100
|
+
/** Stated INLINE in the menu and repeated as a toast — a confirmation that
|
|
101
|
+
* only ever appears in a toast is a confirmation a person who looked away
|
|
102
|
+
* never receives. */
|
|
103
|
+
shareCopied: "listings.share.copied",
|
|
104
|
+
/** The clipboard refused: no permission, an insecure origin, a browser
|
|
105
|
+
* without the API. Never swallowed — see `useShare`. */
|
|
106
|
+
shareCopyFailed: "listings.share.copy_failed",
|
|
107
|
+
/** The three networks. Proper nouns, and identical in every locale — they
|
|
108
|
+
* are keys all the same, because a pair that writes one literal string in
|
|
109
|
+
* a skin has written the next twenty somewhere else. */
|
|
110
|
+
shareTelegram: "listings.share.telegram",
|
|
111
|
+
shareWhatsapp: "listings.share.whatsapp",
|
|
112
|
+
shareVk: "listings.share.vk",
|
|
113
|
+
/** What the heart says back. The icon fill is the state; these two are the
|
|
114
|
+
* OUTCOME, spoken once, and they are what a person who pressed a small
|
|
115
|
+
* glyph at the corner of a photograph has to hear. */
|
|
116
|
+
favoriteAdded: "listings.favorite.added",
|
|
117
|
+
favoriteRemoved: "listings.favorite.removed",
|
|
118
|
+
/** How many people saved this one, when the surface was handed a number.
|
|
119
|
+
* The wire carries no such aggregate today (see `<ListingActions>`), so it
|
|
120
|
+
* is a host's fact and never a zero this pair invented. */
|
|
121
|
+
favoriteCount: "listings.favorite.count",
|
|
122
|
+
|
|
90
123
|
// ── detail ───────────────────────────────────────────────────────────────
|
|
91
124
|
detailLoading: "listings.detail.loading",
|
|
92
125
|
detailLoadFailed: "listings.detail.load_failed",
|
|
@@ -192,6 +225,10 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
192
225
|
mineTabActive: "listings.mine.tab.active",
|
|
193
226
|
mineTabDrafts: "listings.mine.tab.drafts",
|
|
194
227
|
mineTabArchived: "listings.mine.tab.archived",
|
|
228
|
+
/** The fourth tab: the rows `my/counters` counts in none of the three
|
|
229
|
+
* (D407). Drawn only where there is something in it — see
|
|
230
|
+
* `model/status.ts`. */
|
|
231
|
+
mineTabRemoved: "listings.mine.tab.removed",
|
|
195
232
|
mineLoading: "listings.mine.loading",
|
|
196
233
|
mineLoadFailed: "listings.mine.load_failed",
|
|
197
234
|
mineEmpty: "listings.mine.empty",
|
|
@@ -202,6 +239,10 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
202
239
|
mineEmptyActive: "listings.mine.empty.active",
|
|
203
240
|
mineEmptyDrafts: "listings.mine.empty.drafts",
|
|
204
241
|
mineEmptyArchived: "listings.mine.empty.archived",
|
|
242
|
+
/** Reachable by address (`?tab=removed`) rather than by clicking, since the
|
|
243
|
+
* tab is not drawn when it is empty — so the sentence is the good news it
|
|
244
|
+
* is, not a shrug. */
|
|
245
|
+
mineEmptyRemoved: "listings.mine.empty.removed",
|
|
205
246
|
/** The takedowns — the rows no tab folds in (`my/counters` counts them in
|
|
206
247
|
* none), shown outside the tabs so they cannot be missed. */
|
|
207
248
|
mineBlockedTitle: "listings.mine.blocked.title",
|
|
@@ -234,7 +275,13 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
234
275
|
/** Deleting is irreversible, so it asks — through the shared SkinConfirm,
|
|
235
276
|
* which is a bottom sheet on a phone. */
|
|
236
277
|
mineDeleteConfirmTitle: "listings.mine.delete_confirm_title",
|
|
278
|
+
/** …offering the archive as the alternative, which it only is while
|
|
279
|
+
* `archived` is a move the listing still has. */
|
|
237
280
|
mineDeleteConfirmBody: "listings.mine.delete_confirm_body",
|
|
281
|
+
/** The same warning for a row that has already spent that alternative — an
|
|
282
|
+
* archived, sold or taken-down listing. The archive tab's dialog used to
|
|
283
|
+
* promise the archive to a person standing in it. */
|
|
284
|
+
mineDeleteConfirmBodyFinal: "listings.mine.delete_confirm_body.final",
|
|
238
285
|
|
|
239
286
|
// ── favourites ───────────────────────────────────────────────────────────
|
|
240
287
|
favoritesTitle: "listings.favorites.title",
|
|
@@ -320,10 +367,22 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
320
367
|
"listings.card.untitled": "Untitled listing",
|
|
321
368
|
"listings.card.sign_in": "Sign in",
|
|
322
369
|
"listings.card.photos": "Photos of this listing",
|
|
370
|
+
"listings.card.photo_counter": "{index} of {total}",
|
|
323
371
|
"listings.card.price_was": "Was",
|
|
324
372
|
"listings.card.price_dropped": "The price went down",
|
|
325
373
|
"listings.card.price_raised": "The price went up",
|
|
326
374
|
|
|
375
|
+
"listings.share.action": "Share",
|
|
376
|
+
"listings.share.copy": "Copy link",
|
|
377
|
+
"listings.share.copied": "Link copied",
|
|
378
|
+
"listings.share.copy_failed": "We could not copy the link — copy it from the address bar",
|
|
379
|
+
"listings.share.telegram": "Telegram",
|
|
380
|
+
"listings.share.whatsapp": "WhatsApp",
|
|
381
|
+
"listings.share.vk": "VK",
|
|
382
|
+
"listings.favorite.added": "Added to favourites",
|
|
383
|
+
"listings.favorite.removed": "Removed from favourites",
|
|
384
|
+
"listings.favorite.count": "{count} saved this",
|
|
385
|
+
|
|
327
386
|
"listings.detail.loading": "Loading the listing…",
|
|
328
387
|
"listings.detail.load_failed": "We could not load this listing",
|
|
329
388
|
"listings.detail.retry": "Try again",
|
|
@@ -421,6 +480,7 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
421
480
|
"listings.mine.tab.active": "Active",
|
|
422
481
|
"listings.mine.tab.drafts": "Drafts",
|
|
423
482
|
"listings.mine.tab.archived": "Archived",
|
|
483
|
+
"listings.mine.tab.removed": "Taken down",
|
|
424
484
|
"listings.mine.loading": "Loading your listings…",
|
|
425
485
|
"listings.mine.load_failed": "We could not load your listings",
|
|
426
486
|
"listings.mine.empty": "Nothing here yet",
|
|
@@ -429,6 +489,7 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
429
489
|
"listings.mine.empty.active": "Nothing of yours is live or awaiting review",
|
|
430
490
|
"listings.mine.empty.drafts": "No drafts — anything you start appears here",
|
|
431
491
|
"listings.mine.empty.archived": "Nothing archived, paused, expired or sold yet",
|
|
492
|
+
"listings.mine.empty.removed": "Nothing of yours has been taken down",
|
|
432
493
|
"listings.mine.blocked.title":
|
|
433
494
|
"{count} of your listings were taken down by moderation",
|
|
434
495
|
"listings.mine.blocked.title.one":
|
|
@@ -452,6 +513,8 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
452
513
|
"listings.mine.delete_confirm_title": "Delete this listing?",
|
|
453
514
|
"listings.mine.delete_confirm_body":
|
|
454
515
|
"It disappears from your dashboard and cannot be brought back. Archiving keeps it.",
|
|
516
|
+
"listings.mine.delete_confirm_body.final":
|
|
517
|
+
"It disappears from your dashboard and cannot be brought back.",
|
|
455
518
|
|
|
456
519
|
"listings.favorites.title": "Favourites",
|
|
457
520
|
"listings.favorites.loading": "Loading your favourites…",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -97,10 +97,23 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
97
97
|
"listings.card.untitled": "Объявление без названия",
|
|
98
98
|
"listings.card.sign_in": "Войти",
|
|
99
99
|
"listings.card.photos": "Фотографии объявления",
|
|
100
|
+
"listings.card.photo_counter": "{index} из {total}",
|
|
100
101
|
"listings.card.price_was": "Было",
|
|
101
102
|
"listings.card.price_dropped": "Цена снизилась",
|
|
102
103
|
"listings.card.price_raised": "Цена выросла",
|
|
103
104
|
|
|
105
|
+
"listings.share.action": "Поделиться",
|
|
106
|
+
"listings.share.copy": "Скопировать ссылку",
|
|
107
|
+
"listings.share.copied": "Ссылка скопирована",
|
|
108
|
+
"listings.share.copy_failed":
|
|
109
|
+
"Не удалось скопировать ссылку — скопируйте её из адресной строки",
|
|
110
|
+
"listings.share.telegram": "Telegram",
|
|
111
|
+
"listings.share.whatsapp": "WhatsApp",
|
|
112
|
+
"listings.share.vk": "ВКонтакте",
|
|
113
|
+
"listings.favorite.added": "Добавлено в избранное",
|
|
114
|
+
"listings.favorite.removed": "Убрано из избранного",
|
|
115
|
+
"listings.favorite.count": "В избранном у {count}",
|
|
116
|
+
|
|
104
117
|
"listings.detail.loading": "Загружаем объявление…",
|
|
105
118
|
"listings.detail.load_failed": "Не удалось загрузить объявление",
|
|
106
119
|
"listings.detail.retry": "Повторить",
|
|
@@ -196,6 +209,7 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
196
209
|
"listings.mine.tab.active": "Активные",
|
|
197
210
|
"listings.mine.tab.drafts": "Черновики",
|
|
198
211
|
"listings.mine.tab.archived": "Архив",
|
|
212
|
+
"listings.mine.tab.removed": "Снятые",
|
|
199
213
|
"listings.mine.loading": "Загружаем ваши объявления…",
|
|
200
214
|
"listings.mine.load_failed": "Не удалось загрузить ваши объявления",
|
|
201
215
|
"listings.mine.empty": "Здесь пока пусто",
|
|
@@ -204,6 +218,7 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
204
218
|
"listings.mine.empty.active": "Ни одно ваше объявление не опубликовано и не ждёт проверки",
|
|
205
219
|
"listings.mine.empty.drafts": "Черновиков нет — всё начатое появится здесь",
|
|
206
220
|
"listings.mine.empty.archived": "Пока ничего не в архиве, не снято, не истекло и не продано",
|
|
221
|
+
"listings.mine.empty.removed": "Модерация ничего у вас не снимала",
|
|
207
222
|
"listings.mine.blocked.title":
|
|
208
223
|
"Модерация сняла ваших объявлений: {count}",
|
|
209
224
|
"listings.mine.blocked.title.one": "Модерация сняла одно ваше объявление",
|
|
@@ -226,6 +241,8 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
226
241
|
"listings.mine.delete_confirm_title": "Удалить объявление?",
|
|
227
242
|
"listings.mine.delete_confirm_body":
|
|
228
243
|
"Оно исчезнет из кабинета, и вернуть его будет нельзя. Архив сохраняет его.",
|
|
244
|
+
"listings.mine.delete_confirm_body.final":
|
|
245
|
+
"Оно исчезнет из кабинета, и вернуть его будет нельзя.",
|
|
229
246
|
|
|
230
247
|
"listings.favorites.title": "Избранное",
|
|
231
248
|
"listings.favorites.loading": "Загружаем избранное…",
|
|
@@ -249,6 +266,17 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
249
266
|
"error.400.listing_draft_meta_too_large":
|
|
250
267
|
"Черновик слишком большой (не больше {max_bytes} байт). Уберите часть данных и сохраните ещё раз.",
|
|
251
268
|
|
|
269
|
+
// stapel-listings 0.22.3: the features_draft WRITE now accepts the shape a
|
|
270
|
+
// listing READ returns. These three are the refusals for a body that is
|
|
271
|
+
// neither — read by whoever is wiring the integration, so each names the
|
|
272
|
+
// shape it wanted instead of advising "try again".
|
|
273
|
+
"error.400.listing_features_draft_shape":
|
|
274
|
+
"features_draft должен быть объектом со слагами признаков в ключах — или тем списком объектов признаков, который возвращает чтение объявления (у каждого свой «slug»). Пришло: {got_type}. Пример принимаемой формы объекта: {example}",
|
|
275
|
+
"error.400.listing_features_draft_unknown_slug":
|
|
276
|
+
"У каждого элемента списка features_draft должен быть свой непустой «slug» — тот, который чтение объявления хранит на этом элементе, иначе значение некуда положить. У элемента с индексом {index} его нет. Пример: {example}",
|
|
277
|
+
"error.400.listing_features_draft_value_shape":
|
|
278
|
+
"features_draft['{slug}'] сам должен быть объектом вида {{\"type\": <тип признака>, \"value\": <значение признака>}}. Пришло: {got_type}. Пример: {example}",
|
|
279
|
+
|
|
252
280
|
"listings.blocked.sign_in": "Войдите, чтобы сделать это",
|
|
253
281
|
"listings.blocked.guest":
|
|
254
282
|
"Этот аккаунт пока так не может — сначала завершите настройку",
|