@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.
Files changed (133) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +77 -9
  3. package/dist/api/generated/schema.d.ts +2 -2
  4. package/dist/api/generated/schema.d.ts.map +1 -1
  5. package/dist/default/CardBadges.d.ts.map +1 -1
  6. package/dist/default/CardBadges.js +3 -1
  7. package/dist/default/CardBadges.js.map +1 -1
  8. package/dist/default/ListingActions.d.ts +108 -0
  9. package/dist/default/ListingActions.d.ts.map +1 -0
  10. package/dist/default/ListingActions.js +29 -0
  11. package/dist/default/ListingActions.js.map +1 -0
  12. package/dist/default/ListingCard.d.ts.map +1 -1
  13. package/dist/default/ListingCard.js +15 -8
  14. package/dist/default/ListingCard.js.map +1 -1
  15. package/dist/default/ListingDetailPane.d.ts +76 -56
  16. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  17. package/dist/default/ListingDetailPane.js +164 -18
  18. package/dist/default/ListingDetailPane.js.map +1 -1
  19. package/dist/default/ListingFeedCard.d.ts.map +1 -1
  20. package/dist/default/ListingFeedCard.js +2 -1
  21. package/dist/default/ListingFeedCard.js.map +1 -1
  22. package/dist/default/ListingPhoto.d.ts.map +1 -1
  23. package/dist/default/ListingPhoto.js +5 -2
  24. package/dist/default/ListingPhoto.js.map +1 -1
  25. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  26. package/dist/default/ListingSerpCard.js +18 -4
  27. package/dist/default/ListingSerpCard.js.map +1 -1
  28. package/dist/default/MyListingsPane.d.ts.map +1 -1
  29. package/dist/default/MyListingsPane.js +45 -21
  30. package/dist/default/MyListingsPane.js.map +1 -1
  31. package/dist/default/ShareAction.d.ts +32 -0
  32. package/dist/default/ShareAction.d.ts.map +1 -0
  33. package/dist/default/ShareAction.js +116 -0
  34. package/dist/default/ShareAction.js.map +1 -0
  35. package/dist/default/actionRow.d.ts +58 -0
  36. package/dist/default/actionRow.d.ts.map +1 -0
  37. package/dist/default/actionRow.js +128 -0
  38. package/dist/default/actionRow.js.map +1 -0
  39. package/dist/default/cardGallery.d.ts +2 -0
  40. package/dist/default/cardGallery.d.ts.map +1 -1
  41. package/dist/default/cardGallery.js +31 -0
  42. package/dist/default/cardGallery.js.map +1 -1
  43. package/dist/default/favorite.d.ts +25 -0
  44. package/dist/default/favorite.d.ts.map +1 -1
  45. package/dist/default/favorite.js +29 -3
  46. package/dist/default/favorite.js.map +1 -1
  47. package/dist/default/icons.d.ts +19 -0
  48. package/dist/default/icons.d.ts.map +1 -1
  49. package/dist/default/icons.js +23 -0
  50. package/dist/default/icons.js.map +1 -1
  51. package/dist/default/index.d.ts +8 -1
  52. package/dist/default/index.d.ts.map +1 -1
  53. package/dist/default/index.js +6 -1
  54. package/dist/default/index.js.map +1 -1
  55. package/dist/default/notice.d.ts +14 -0
  56. package/dist/default/notice.d.ts.map +1 -0
  57. package/dist/default/notice.js +62 -0
  58. package/dist/default/notice.js.map +1 -0
  59. package/dist/headless/MyListings.d.ts +39 -18
  60. package/dist/headless/MyListings.d.ts.map +1 -1
  61. package/dist/headless/MyListings.js +41 -19
  62. package/dist/headless/MyListings.js.map +1 -1
  63. package/dist/headless/Share.d.ts +103 -0
  64. package/dist/headless/Share.d.ts.map +1 -0
  65. package/dist/headless/Share.js +190 -0
  66. package/dist/headless/Share.js.map +1 -0
  67. package/dist/i18n/es.d.ts.map +1 -1
  68. package/dist/i18n/es.js +21 -0
  69. package/dist/i18n/es.js.map +1 -1
  70. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  71. package/dist/i18n/generated/errors.es.gen.js +1 -1
  72. package/dist/i18n/generated/errors.gen.d.ts +18 -0
  73. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  74. package/dist/i18n/generated/errors.gen.js +9 -0
  75. package/dist/i18n/generated/errors.gen.js.map +1 -1
  76. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  77. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  78. package/dist/i18n/keys.d.ts +45 -0
  79. package/dist/i18n/keys.d.ts.map +1 -1
  80. package/dist/i18n/keys.js +60 -0
  81. package/dist/i18n/keys.js.map +1 -1
  82. package/dist/i18n/ru.d.ts.map +1 -1
  83. package/dist/i18n/ru.js +21 -0
  84. package/dist/i18n/ru.js.map +1 -1
  85. package/dist/index.d.ts +5 -3
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +2 -1
  88. package/dist/index.js.map +1 -1
  89. package/dist/model/cardBadges.d.ts +52 -3
  90. package/dist/model/cardBadges.d.ts.map +1 -1
  91. package/dist/model/cardBadges.js +74 -12
  92. package/dist/model/cardBadges.js.map +1 -1
  93. package/dist/model/mineSource.d.ts +9 -3
  94. package/dist/model/mineSource.d.ts.map +1 -1
  95. package/dist/model/mineSource.js.map +1 -1
  96. package/dist/model/status.d.ts +62 -13
  97. package/dist/model/status.d.ts.map +1 -1
  98. package/dist/model/status.js +69 -15
  99. package/dist/model/status.js.map +1 -1
  100. package/llms.txt +8 -3
  101. package/manifest.json +96 -4
  102. package/nav-manifest.json +1 -1
  103. package/package.json +10 -10
  104. package/src/analytics/generated/events.json +1 -1
  105. package/src/api/generated/schema.ts +2 -2
  106. package/src/default/CardBadges.tsx +3 -1
  107. package/src/default/ListingActions.tsx +196 -0
  108. package/src/default/ListingCard.tsx +79 -43
  109. package/src/default/ListingDetailPane.tsx +268 -81
  110. package/src/default/ListingFeedCard.tsx +2 -0
  111. package/src/default/ListingPhoto.tsx +28 -0
  112. package/src/default/ListingSerpCard.tsx +39 -12
  113. package/src/default/MyListingsPane.tsx +77 -44
  114. package/src/default/ShareAction.tsx +260 -0
  115. package/src/default/actionRow.ts +131 -0
  116. package/src/default/cardGallery.ts +31 -0
  117. package/src/default/favorite.tsx +68 -1
  118. package/src/default/icons.tsx +60 -0
  119. package/src/default/index.ts +23 -0
  120. package/src/default/notice.ts +68 -0
  121. package/src/headless/MyListings.tsx +89 -38
  122. package/src/headless/Share.tsx +278 -0
  123. package/src/i18n/es.ts +28 -0
  124. package/src/i18n/generated/errors.es.gen.ts +1 -1
  125. package/src/i18n/generated/errors.gen.ts +9 -0
  126. package/src/i18n/generated/errors.json +31 -0
  127. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  128. package/src/i18n/keys.ts +63 -0
  129. package/src/i18n/ru.ts +28 -0
  130. package/src/index.ts +22 -0
  131. package/src/model/cardBadges.ts +124 -12
  132. package/src/model/mineSource.ts +9 -3
  133. 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: 27 key(s) owned by
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: 27 key(s) owned by
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
  "Этот аккаунт пока так не может — сначала завершите настройку",