@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.
- package/CHANGELOG.md +216 -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/ListingComposer.d.ts +36 -2
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +15 -2
- package/dist/headless/ListingComposer.js.map +1 -1
- 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 +11 -11
- 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/ListingComposer.tsx +68 -4
- 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
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,
|
package/src/model/cardBadges.ts
CHANGED
|
@@ -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(
|
|
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
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
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
|
-
/**
|
|
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
|
|
233
|
-
|
|
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
|
}
|
package/src/model/mineSource.ts
CHANGED
|
@@ -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 {
|
|
35
|
+
import type { MyListingsCountedTab } from "./status.js";
|
|
36
36
|
import { MY_LISTINGS_TAB_STATUSES } from "./status.js";
|
|
37
37
|
|
|
38
|
-
/**
|
|
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:
|
|
46
|
+
readonly tab: MyListingsCountedTab;
|
|
41
47
|
readonly page: MyListingsParams;
|
|
42
48
|
readonly signal?: AbortSignal;
|
|
43
49
|
}) => Promise<PaginatedMyListingCards>;
|
package/src/model/status.ts
CHANGED
|
@@ -285,7 +285,8 @@ export function listingStatusView(
|
|
|
285
285
|
}
|
|
286
286
|
|
|
287
287
|
/**
|
|
288
|
-
* The
|
|
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
|
|
298
|
+
export const MY_LISTINGS_COUNTED_TABS = ["active", "drafts", "archived"] as const;
|
|
298
299
|
|
|
299
|
-
|
|
300
|
+
/** One of the three tabs `MyCountersResponse` carries a number for. */
|
|
301
|
+
export type MyListingsCountedTab = (typeof MY_LISTINGS_COUNTED_TABS)[number];
|
|
300
302
|
|
|
301
|
-
|
|
302
|
-
Record<
|
|
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
|
|
310
|
-
*
|
|
311
|
-
|
|
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
|
-
):
|
|
315
|
-
for (const tab of
|
|
316
|
-
if (
|
|
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
|
|
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.
|
|
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) =>
|
|
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
|
+
}
|