@stapel/listings-react 0.25.9 → 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 (138) hide show
  1. package/CHANGELOG.md +216 -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/ListingComposer.d.ts +36 -2
  60. package/dist/headless/ListingComposer.d.ts.map +1 -1
  61. package/dist/headless/ListingComposer.js +15 -2
  62. package/dist/headless/ListingComposer.js.map +1 -1
  63. package/dist/headless/MyListings.d.ts +39 -18
  64. package/dist/headless/MyListings.d.ts.map +1 -1
  65. package/dist/headless/MyListings.js +41 -19
  66. package/dist/headless/MyListings.js.map +1 -1
  67. package/dist/headless/Share.d.ts +103 -0
  68. package/dist/headless/Share.d.ts.map +1 -0
  69. package/dist/headless/Share.js +190 -0
  70. package/dist/headless/Share.js.map +1 -0
  71. package/dist/i18n/es.d.ts.map +1 -1
  72. package/dist/i18n/es.js +21 -0
  73. package/dist/i18n/es.js.map +1 -1
  74. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  75. package/dist/i18n/generated/errors.es.gen.js +1 -1
  76. package/dist/i18n/generated/errors.gen.d.ts +18 -0
  77. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  78. package/dist/i18n/generated/errors.gen.js +9 -0
  79. package/dist/i18n/generated/errors.gen.js.map +1 -1
  80. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  81. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  82. package/dist/i18n/keys.d.ts +45 -0
  83. package/dist/i18n/keys.d.ts.map +1 -1
  84. package/dist/i18n/keys.js +60 -0
  85. package/dist/i18n/keys.js.map +1 -1
  86. package/dist/i18n/ru.d.ts.map +1 -1
  87. package/dist/i18n/ru.js +21 -0
  88. package/dist/i18n/ru.js.map +1 -1
  89. package/dist/index.d.ts +5 -3
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +2 -1
  92. package/dist/index.js.map +1 -1
  93. package/dist/model/cardBadges.d.ts +52 -3
  94. package/dist/model/cardBadges.d.ts.map +1 -1
  95. package/dist/model/cardBadges.js +74 -12
  96. package/dist/model/cardBadges.js.map +1 -1
  97. package/dist/model/mineSource.d.ts +9 -3
  98. package/dist/model/mineSource.d.ts.map +1 -1
  99. package/dist/model/mineSource.js.map +1 -1
  100. package/dist/model/status.d.ts +62 -13
  101. package/dist/model/status.d.ts.map +1 -1
  102. package/dist/model/status.js +69 -15
  103. package/dist/model/status.js.map +1 -1
  104. package/llms.txt +8 -3
  105. package/manifest.json +96 -4
  106. package/nav-manifest.json +1 -1
  107. package/package.json +11 -11
  108. package/src/analytics/generated/events.json +1 -1
  109. package/src/api/generated/schema.ts +2 -2
  110. package/src/default/CardBadges.tsx +3 -1
  111. package/src/default/ListingActions.tsx +196 -0
  112. package/src/default/ListingCard.tsx +79 -43
  113. package/src/default/ListingDetailPane.tsx +268 -81
  114. package/src/default/ListingFeedCard.tsx +2 -0
  115. package/src/default/ListingPhoto.tsx +28 -0
  116. package/src/default/ListingSerpCard.tsx +39 -12
  117. package/src/default/MyListingsPane.tsx +77 -44
  118. package/src/default/ShareAction.tsx +260 -0
  119. package/src/default/actionRow.ts +131 -0
  120. package/src/default/cardGallery.ts +31 -0
  121. package/src/default/favorite.tsx +68 -1
  122. package/src/default/icons.tsx +60 -0
  123. package/src/default/index.ts +23 -0
  124. package/src/default/notice.ts +68 -0
  125. package/src/headless/ListingComposer.tsx +68 -4
  126. package/src/headless/MyListings.tsx +89 -38
  127. package/src/headless/Share.tsx +278 -0
  128. package/src/i18n/es.ts +28 -0
  129. package/src/i18n/generated/errors.es.gen.ts +1 -1
  130. package/src/i18n/generated/errors.gen.ts +9 -0
  131. package/src/i18n/generated/errors.json +31 -0
  132. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  133. package/src/i18n/keys.ts +63 -0
  134. package/src/i18n/ru.ts +28 -0
  135. package/src/index.ts +22 -0
  136. package/src/model/cardBadges.ts +124 -12
  137. package/src/model/mineSource.ts +9 -3
  138. package/src/model/status.ts +85 -18
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
  "Этот аккаунт пока так не может — сначала завершите настройку",
package/src/index.ts CHANGED
@@ -105,9 +105,12 @@ export type {
105
105
 
106
106
  // ── model: the two axes ──────────────────────────────────────────────────────
107
107
  export {
108
+ MY_LISTINGS_COUNTED_TABS,
109
+ MY_LISTINGS_REMOVED_TAB,
108
110
  MY_LISTINGS_TABS,
109
111
  MY_LISTINGS_TAB_STATUSES,
110
112
  MY_LISTINGS_UNTABBED_STATUSES,
113
+ countedTabOf,
111
114
  isPubliclyVisible,
112
115
  lifecycleCaption,
113
116
  listingStatusView,
@@ -119,6 +122,7 @@ export type {
119
122
  ListingStatusTone,
120
123
  ListingStatusView,
121
124
  ModerationNotice,
125
+ MyListingsCountedTab,
122
126
  MyListingsTab,
123
127
  } from "./model/status.js";
124
128
  export {
@@ -188,6 +192,7 @@ export type {
188
192
  CardBadgeContract,
189
193
  CardBadgePresentation,
190
194
  CardBadgeRow,
195
+ CardBadgeStyle,
191
196
  } from "./model/cardBadges.js";
192
197
 
193
198
  // ── model: the engagement axis (already-seen / view count / the overlay) ─────
@@ -310,6 +315,23 @@ export type {
310
315
  FavoritesBag,
311
316
  UseFavoritesOptions,
312
317
  } from "./headless/Favorites.js";
318
+ export {
319
+ SHARE_COPIED_MS,
320
+ SHARE_NETWORKS,
321
+ Share,
322
+ hasNativeShare,
323
+ resolveShareUrl,
324
+ shareLinks,
325
+ useShare,
326
+ } from "./headless/Share.js";
327
+ export type {
328
+ ShareBag,
329
+ ShareChannel,
330
+ ShareLink,
331
+ ShareNetwork,
332
+ ShareTarget,
333
+ UseShareOptions,
334
+ } from "./headless/Share.js";
313
335
  export { useListingActions } from "./headless/ListingActions.js";
314
336
  export type {
315
337
  ListingActionsBag,
@@ -56,6 +56,44 @@
56
56
  * contract, and it is rendered exactly as it is rendered today — through
57
57
  * `@stapel/attributes-react`'s `<FeatureBadges>`, off the stored DAO's own
58
58
  * config. Nothing about this module is required for a card to draw.
59
+ *
60
+ * ── A CHIP IS NOT A LINE (D421) ───────────────────────────────────────────
61
+ *
62
+ * The four presentations produce a piece of text; where that text is PUT
63
+ * decides how it is read, and the two places this pair puts it are not the
64
+ * same place:
65
+ *
66
+ * a badge strip each element is a `<Tag>`, and the chip's own border says
67
+ * where one fact ends and the next begins;
68
+ * a spec line the elements are joined with " · ", so the only thing
69
+ * separating two facts is punctuation, and a SPACE inside
70
+ * one of them is not punctuation.
71
+ *
72
+ * Measured on a live feed, translated: "HONOR · Model 90 · 256 GB" — three
73
+ * facts, and the middle one reads as a value that begins with the word
74
+ * "Model", because in a run of values a caption joined by a space is
75
+ * indistinguishable from a two-word answer. So {@link CardBadgeStyle}
76
+ * `"line"` writes the same pair with a COLON, which is the punctuation that
77
+ * says "what follows is the answer to this". The chip keeps its space: "Floor 3" inside a border needs
78
+ * no help, and a colon there is the form-field look the 0.22 contract
79
+ * deliberately refused.
80
+ *
81
+ * ── …AND TWO AXES MUST NOT WEAR ONE CAPTION ───────────────────────────────
82
+ *
83
+ * The same feed: "5 fl. · 9 fl. · 54 m²" — the floor a flat is on and the
84
+ * number of floors in the building, printed as one number and its unit each,
85
+ * twice, with nothing saying which is which. The server presented both as
86
+ * `value_unit` and it was right about each of them ALONE; what it cannot see
87
+ * is that they are on one line together.
88
+ *
89
+ * That collision is a property of the SET, so it is resolved once, here, in
90
+ * {@link cardBadgeTexts}: elements printed without a caption that share a unit
91
+ * (or that print identical text) get their catalogue names back — the
92
+ * `name_value` shape the contract already defines, so a disambiguated element
93
+ * is spelled exactly as a server-captioned one. The rule refuses to act where
94
+ * it would not help: a group whose names are missing or not distinct is left
95
+ * alone rather than captioned with the same word twice, because a caption
96
+ * that does not tell two things apart is noise the reader still has to read.
59
97
  */
60
98
  import type { ListingCardBadgeElement, ListingFeatureDao } from "../api/types.js";
61
99
 
@@ -180,6 +218,23 @@ function isTrue(raw: unknown): boolean {
180
218
  return raw === true || raw === 1 || raw === "true";
181
219
  }
182
220
 
221
+ /**
222
+ * WHERE the text is going to be put — see the module header.
223
+ *
224
+ * `"badge"` is a chip in a strip and is the default, so every existing call
225
+ * site keeps the bytes it had. `"line"` is one item in a " · "-separated run,
226
+ * where a caption needs punctuation to be read as one.
227
+ */
228
+ export type CardBadgeStyle = "badge" | "line";
229
+
230
+ /** A caption and its answer, joined the way this surface separates them. */
231
+ function caption(name: string, body: string, style: CardBadgeStyle): string {
232
+ // A SPACE in a chip and a COLON in a line. "Floor 3" is a caption inside a
233
+ // border; "Floor: 3" is what the same pair has to become when the border is
234
+ // gone and the neighbours are a dot away.
235
+ return style === "line" ? `${name}: ${body}` : `${name} ${body}`;
236
+ }
237
+
183
238
  /**
184
239
  * One badge element → the text a card prints, or `undefined` for an element
185
240
  * that has nothing to say (a false boolean, a blank value).
@@ -189,7 +244,11 @@ function isTrue(raw: unknown): boolean {
189
244
  * exists: three cards each re-deciding what a badge says is three places for
190
245
  * "Brick · 3 · 9" to come back.
191
246
  */
192
- export function cardBadgeText(row: CardBadgeRow, locale?: string): string | undefined {
247
+ export function cardBadgeText(
248
+ row: CardBadgeRow,
249
+ locale?: string,
250
+ style: CardBadgeStyle = "badge"
251
+ ): string | undefined {
193
252
  const presentation = badgePresentation(row);
194
253
  const name = text(row.name);
195
254
  const unit = text(row.unit);
@@ -208,12 +267,11 @@ export function cardBadgeText(row: CardBadgeRow, locale?: string): string | unde
208
267
  case "value_unit":
209
268
  return withUnit;
210
269
  case "name_value":
211
- // A SPACE, not a colon. "Floor 3" is a caption; "Floor: 3" is a form
212
- // field, and a card is neither a form nor a table the colon is the
213
- // punctuation the spec list uses because a spec list IS the table.
214
- // The unit rides along: a named row that dropped it reads "Mileage
215
- // 20 000", which is the defect this contract exists to close.
216
- return name.length > 0 ? `${name} ${withUnit}` : withUnit;
270
+ // Joined by the SURFACE's own punctuation (D421): a space inside a
271
+ // chip, a colon inside a line. The unit rides along either way a
272
+ // named row that dropped it reads "Mileage 20 000", which is the defect
273
+ // this contract exists to close.
274
+ return name.length > 0 ? caption(name, withUnit, style) : withUnit;
217
275
  default:
218
276
  // `value`, and an element the server left plain in a list that speaks
219
277
  // the contract. The value alone, never the unit — the server said so.
@@ -221,17 +279,71 @@ export function cardBadgeText(row: CardBadgeRow, locale?: string): string | unde
221
279
  }
222
280
  }
223
281
 
224
- /** Every element that has something to say, in the server's order. */
282
+ /**
283
+ * Does this element already print its own caption?
284
+ *
285
+ * `name` IS the caption, and a `name_value` with a name to print carries one.
286
+ * Everything else is a bare answer, and two bare answers are what can collide.
287
+ */
288
+ function alreadyCaptioned(row: CardBadgeRow): boolean {
289
+ const presentation = badgePresentation(row);
290
+ if (presentation === "name") return true;
291
+ return presentation === "name_value" && text(row.name).length > 0;
292
+ }
293
+
294
+ /**
295
+ * How a bare answer could be MISTAKEN for its neighbour: by measuring the
296
+ * same thing (one unit, two axes) or by reading identically.
297
+ */
298
+ function ambiguityKey(row: CardBadgeRow, printed: string): string {
299
+ const unit = text(row.unit);
300
+ return unit.length > 0 ? `unit:${unit}` : `text:${printed}`;
301
+ }
302
+
303
+ /**
304
+ * Every element that has something to say, in the server's order, with two
305
+ * axes that would read alike told apart — see the module header (D421).
306
+ */
225
307
  export function cardBadgeTexts(
226
308
  rows: readonly CardBadgeRow[],
227
- locale?: string
309
+ locale?: string,
310
+ style: CardBadgeStyle = "badge"
228
311
  ): readonly { readonly slug: string; readonly text: string }[] {
229
312
  const out: { slug: string; text: string }[] = [];
313
+ const printedRows: CardBadgeRow[] = [];
314
+ /** Ambiguity key → the positions in `out` that could be confused. */
315
+ const groups = new Map<string, number[]>();
316
+
230
317
  for (const row of rows) {
231
- const printed = cardBadgeText(row, locale);
232
- if (printed !== undefined && typeof row.slug === "string" && row.slug.length > 0) {
233
- out.push({ slug: row.slug, text: printed });
318
+ const printed = cardBadgeText(row, locale, style);
319
+ if (printed === undefined || typeof row.slug !== "string" || row.slug.length === 0) {
320
+ continue;
234
321
  }
322
+ const at = out.length;
323
+ out.push({ slug: row.slug, text: printed });
324
+ printedRows.push(row);
325
+ if (alreadyCaptioned(row)) continue;
326
+ const key = ambiguityKey(row, printed);
327
+ const seen = groups.get(key);
328
+ if (seen === undefined) groups.set(key, [at]);
329
+ else seen.push(at);
235
330
  }
331
+
332
+ for (const positions of groups.values()) {
333
+ if (positions.length < 2) continue;
334
+ const names = positions.map((at) => text(printedRows[at]?.name));
335
+ // Nothing to caption with, or one word for both axes: leave the line as
336
+ // the server wrote it rather than adding a caption that tells a reader
337
+ // nothing they did not already have.
338
+ if (names.some((name) => name.length === 0)) continue;
339
+ if (new Set(names).size !== names.length) continue;
340
+ positions.forEach((at, index) => {
341
+ const entry = out[at];
342
+ const name = names[index];
343
+ if (entry === undefined || name === undefined) return;
344
+ out[at] = { slug: entry.slug, text: caption(name, entry.text, style) };
345
+ });
346
+ }
347
+
236
348
  return out;
237
349
  }
@@ -32,12 +32,18 @@
32
32
  */
33
33
  import type { ListingsApi } from "../api/listingsApi.js";
34
34
  import type { MyListingsParams, PaginatedMyListingCards } from "../api/types.js";
35
- import type { MyListingsTab } from "./status.js";
35
+ import type { MyListingsCountedTab } from "./status.js";
36
36
  import { MY_LISTINGS_TAB_STATUSES } from "./status.js";
37
37
 
38
- /** One page of the caller's own listings, however the host gets them. */
38
+ /**
39
+ * One page of the caller's own listings, however the host gets them.
40
+ *
41
+ * `tab` is one of the three COUNTED tabs and not the fourth: the removed tab
42
+ * (D407) reads takedowns off `?status=blocked` directly and unpaged, so a host
43
+ * source is never handed a tab it was not written for.
44
+ */
39
45
  export type MyListingsSource = (args: {
40
- readonly tab: MyListingsTab;
46
+ readonly tab: MyListingsCountedTab;
41
47
  readonly page: MyListingsParams;
42
48
  readonly signal?: AbortSignal;
43
49
  }) => Promise<PaginatedMyListingCards>;
@@ -285,7 +285,8 @@ export function listingStatusView(
285
285
  }
286
286
 
287
287
  /**
288
- * The dashboard tabs, and the statuses each one folds together.
288
+ * The three tabs `my/counters` COUNTS, and the statuses each one folds
289
+ * together.
289
290
  *
290
291
  * The grouping is the SERVER's (`views.my_counters`), copied here so a tab's
291
292
  * caption and its count cannot describe different sets: `active` includes
@@ -294,40 +295,106 @@ export function listingStatusView(
294
295
  * working on. A client that grouped them its own way would show "3 active"
295
296
  * over two rows.
296
297
  */
297
- export const MY_LISTINGS_TABS = ["active", "drafts", "archived"] as const;
298
+ export const MY_LISTINGS_COUNTED_TABS = ["active", "drafts", "archived"] as const;
298
299
 
299
- export type MyListingsTab = (typeof MY_LISTINGS_TABS)[number];
300
+ /** One of the three tabs `MyCountersResponse` carries a number for. */
301
+ export type MyListingsCountedTab = (typeof MY_LISTINGS_COUNTED_TABS)[number];
300
302
 
301
- export const MY_LISTINGS_TAB_STATUSES: Readonly<
302
- Record<MyListingsTab, readonly ListingLifecycleStatus[]>
303
+ const COUNTED_TAB_STATUSES: Readonly<
304
+ Record<MyListingsCountedTab, readonly ListingLifecycleStatus[]>
303
305
  > = {
304
306
  active: ["published", "pending"],
305
307
  drafts: ["draft", "rejected"],
306
308
  archived: ["archived", "paused", "expired", "sold"],
307
309
  };
308
310
 
309
- /** Which tab a listing belongs to, or `undefined` for BLOCKED — the one
310
- * status `my/counters` counts in no tab at all. A dashboard that silently
311
- * dropped it would hide exactly the listing whose owner most needs to know. */
312
- export function tabOf(
311
+ /** Which of the three COUNTED tabs a status belongs to, or `undefined` for
312
+ * one the server's own counter groups nowhere `blocked`, today. */
313
+ export function countedTabOf(
313
314
  status: ListingLifecycleStatus
314
- ): MyListingsTab | undefined {
315
- for (const tab of MY_LISTINGS_TABS) {
316
- if (MY_LISTINGS_TAB_STATUSES[tab].includes(status)) return tab;
315
+ ): MyListingsCountedTab | undefined {
316
+ for (const tab of MY_LISTINGS_COUNTED_TABS) {
317
+ if (COUNTED_TAB_STATUSES[tab].includes(status)) return tab;
317
318
  }
318
319
  return undefined;
319
320
  }
320
321
 
321
322
  /**
322
- * The statuses no tab folds in — `blocked`, and nothing else today.
323
+ * The statuses the server's own counter groups nowhere — `blocked`, and
324
+ * nothing else today.
323
325
  *
324
326
  * DERIVED, not written down a second time: a status added upstream and left
325
327
  * out of the counter groupings lands here automatically and gets shown, which
326
328
  * is the opposite of what a hardcoded `["blocked"]` would do the day it goes
327
- * stale. The dashboard renders these rows OUTSIDE the tabs (see
328
- * `default/MyListingsPane.tsx`) rather than folding them into one, because a
329
- * tab's rows and its `my/counters` badge have to describe the same set — and
330
- * `my/counters` counts a takedown in no tab at all.
329
+ * stale.
331
330
  */
332
331
  export const MY_LISTINGS_UNTABBED_STATUSES: readonly ListingLifecycleStatus[] =
333
- LISTING_STATUSES.filter((status) => tabOf(status) === undefined);
332
+ LISTING_STATUSES.filter((status) => countedTabOf(status) === undefined);
333
+
334
+ /**
335
+ * The fourth tab: the rows `my/counters` counts in NO tab at all.
336
+ *
337
+ * ── What was on screen (D407) ────────────────────────────────────────────
338
+ *
339
+ * A listing pulled by moderation showed "Taken down by a moderator" over a
340
+ * counter row reading "Active 0 · Drafts 0 · Archived 0", beside the active
341
+ * tab's own "nothing of yours is live". The object was on the page and in no
342
+ * tab and in no number — three statements, and the two loudest of them said
343
+ * the seller had nothing.
344
+ *
345
+ * The takedowns had a home before this (a block above the tabs) and that was
346
+ * the half that was wrong: a row outside the tab strip is a row the counters
347
+ * do not describe, and a person reads the counters. So they get a TAB, with a
348
+ * count, like every other state a listing can be in.
349
+ *
350
+ * ── Why a fourth tab and not the archive ─────────────────────────────────
351
+ *
352
+ * Folding `blocked` into `archived` is the other shape this could take, and
353
+ * it costs the count: `my/counters` has three integers and no fourth, so an
354
+ * archive tab holding takedowns would read the server's `archived` number —
355
+ * `0` — until the tab was opened and its rows could raise it. The fourth tab
356
+ * is counted from its OWN read (`?status=blocked`, unpaged), so the number is
357
+ * right while the seller is looking at a different tab, which is exactly the
358
+ * moment D407 was measured at.
359
+ *
360
+ * The archive keeps its meaning too, which is not nothing: "I put this away"
361
+ * and "a moderator took this down" are not the same sentence and a tab that
362
+ * said one over rows that meant the other would be the D407 defect wearing a
363
+ * label.
364
+ */
365
+ export const MY_LISTINGS_REMOVED_TAB = "removed";
366
+
367
+ /**
368
+ * Every tab the dashboard can show: the server's three, then the removed one.
369
+ *
370
+ * The removed tab is DRAWN only where there is something in it (see
371
+ * `headless/MyListings.tsx`) — an empty "Taken down" tab is a scare — but it
372
+ * is in this list unconditionally, because `?tab=removed` must parse and a
373
+ * host must be able to name it.
374
+ */
375
+ export const MY_LISTINGS_TABS: readonly [
376
+ ...typeof MY_LISTINGS_COUNTED_TABS,
377
+ typeof MY_LISTINGS_REMOVED_TAB,
378
+ ] = [...MY_LISTINGS_COUNTED_TABS, MY_LISTINGS_REMOVED_TAB];
379
+
380
+ export type MyListingsTab = (typeof MY_LISTINGS_TABS)[number];
381
+
382
+ export const MY_LISTINGS_TAB_STATUSES: Readonly<
383
+ Record<MyListingsTab, readonly ListingLifecycleStatus[]>
384
+ > = {
385
+ ...COUNTED_TAB_STATUSES,
386
+ [MY_LISTINGS_REMOVED_TAB]: MY_LISTINGS_UNTABBED_STATUSES,
387
+ };
388
+
389
+ /** Which tab a listing belongs to. Every status has one: the three counted
390
+ * groupings, and the removed tab for whatever they leave out. `undefined` is
391
+ * unreachable today and stays in the signature so a status this build has
392
+ * never heard of cannot be filed under a tab by accident. */
393
+ export function tabOf(
394
+ status: ListingLifecycleStatus
395
+ ): MyListingsTab | undefined {
396
+ for (const tab of MY_LISTINGS_TABS) {
397
+ if (MY_LISTINGS_TAB_STATUSES[tab].includes(status)) return tab;
398
+ }
399
+ return undefined;
400
+ }