@stapel/listings-react 0.4.0 → 0.8.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 +180 -0
  2. package/dist/api/generated/schema.d.ts +94 -4
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/default/FavoritesPane.d.ts +40 -3
  5. package/dist/default/FavoritesPane.d.ts.map +1 -1
  6. package/dist/default/FavoritesPane.js +30 -18
  7. package/dist/default/FavoritesPane.js.map +1 -1
  8. package/dist/default/ListingCard.d.ts +81 -4
  9. package/dist/default/ListingCard.d.ts.map +1 -1
  10. package/dist/default/ListingCard.js +97 -27
  11. package/dist/default/ListingCard.js.map +1 -1
  12. package/dist/default/ListingComposerPage.d.ts +163 -23
  13. package/dist/default/ListingComposerPage.d.ts.map +1 -1
  14. package/dist/default/ListingComposerPage.js +85 -43
  15. package/dist/default/ListingComposerPage.js.map +1 -1
  16. package/dist/default/ListingDetailPane.d.ts +41 -4
  17. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  18. package/dist/default/ListingDetailPane.js +38 -25
  19. package/dist/default/ListingDetailPane.js.map +1 -1
  20. package/dist/default/ListingPhoto.d.ts +13 -1
  21. package/dist/default/ListingPhoto.d.ts.map +1 -1
  22. package/dist/default/ListingPhoto.js +47 -9
  23. package/dist/default/ListingPhoto.js.map +1 -1
  24. package/dist/default/ListingPrice.d.ts +13 -0
  25. package/dist/default/ListingPrice.d.ts.map +1 -0
  26. package/dist/default/ListingPrice.js +60 -0
  27. package/dist/default/ListingPrice.js.map +1 -0
  28. package/dist/default/MyListingsPane.d.ts +7 -28
  29. package/dist/default/MyListingsPane.d.ts.map +1 -1
  30. package/dist/default/MyListingsPane.js +136 -67
  31. package/dist/default/MyListingsPane.js.map +1 -1
  32. package/dist/default/SignInLink.js +8 -7
  33. package/dist/default/SignInLink.js.map +1 -1
  34. package/dist/default/StatusTags.d.ts +16 -5
  35. package/dist/default/StatusTags.d.ts.map +1 -1
  36. package/dist/default/StatusTags.js +19 -13
  37. package/dist/default/StatusTags.js.map +1 -1
  38. package/dist/default/index.d.ts +15 -9
  39. package/dist/default/index.d.ts.map +1 -1
  40. package/dist/default/index.js +13 -6
  41. package/dist/default/index.js.map +1 -1
  42. package/dist/headless/Favorites.d.ts +6 -0
  43. package/dist/headless/Favorites.d.ts.map +1 -1
  44. package/dist/headless/Favorites.js +23 -7
  45. package/dist/headless/Favorites.js.map +1 -1
  46. package/dist/headless/ListingActions.d.ts +12 -0
  47. package/dist/headless/ListingActions.d.ts.map +1 -1
  48. package/dist/headless/ListingActions.js +3 -0
  49. package/dist/headless/ListingActions.js.map +1 -1
  50. package/dist/headless/ListingComposer.d.ts +23 -0
  51. package/dist/headless/ListingComposer.d.ts.map +1 -1
  52. package/dist/headless/ListingComposer.js +33 -7
  53. package/dist/headless/ListingComposer.js.map +1 -1
  54. package/dist/headless/ListingDetail.d.ts.map +1 -1
  55. package/dist/headless/ListingDetail.js +7 -5
  56. package/dist/headless/ListingDetail.js.map +1 -1
  57. package/dist/headless/useMandateGate.d.ts +33 -2
  58. package/dist/headless/useMandateGate.d.ts.map +1 -1
  59. package/dist/headless/useMandateGate.js +42 -4
  60. package/dist/headless/useMandateGate.js.map +1 -1
  61. package/dist/i18n/es.d.ts +6 -1
  62. package/dist/i18n/es.d.ts.map +1 -1
  63. package/dist/i18n/es.js +23 -5
  64. package/dist/i18n/es.js.map +1 -1
  65. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  66. package/dist/i18n/generated/errors.es.gen.js +1 -1
  67. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  68. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  69. package/dist/i18n/generated/errors.gen.js +3 -0
  70. package/dist/i18n/generated/errors.gen.js.map +1 -1
  71. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  72. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  73. package/dist/i18n/keys.d.ts +30 -3
  74. package/dist/i18n/keys.d.ts.map +1 -1
  75. package/dist/i18n/keys.js +47 -7
  76. package/dist/i18n/keys.js.map +1 -1
  77. package/dist/i18n/ru.d.ts +16 -1
  78. package/dist/i18n/ru.d.ts.map +1 -1
  79. package/dist/i18n/ru.js +35 -5
  80. package/dist/i18n/ru.js.map +1 -1
  81. package/dist/index.d.ts +1 -1
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +1 -1
  84. package/dist/index.js.map +1 -1
  85. package/dist/model/draft.d.ts +9 -6
  86. package/dist/model/draft.d.ts.map +1 -1
  87. package/dist/model/draft.js +3 -1
  88. package/dist/model/draft.js.map +1 -1
  89. package/dist/model/features.d.ts +21 -7
  90. package/dist/model/features.d.ts.map +1 -1
  91. package/dist/model/features.js +26 -0
  92. package/dist/model/features.js.map +1 -1
  93. package/llms.txt +9 -8
  94. package/manifest.json +82 -31
  95. package/nav-manifest.json +1 -1
  96. package/package.json +19 -14
  97. package/src/analytics/generated/events.json +1 -1
  98. package/src/api/generated/schema.ts +94 -4
  99. package/src/default/FavoritesPane.tsx +156 -82
  100. package/src/default/ListingCard.tsx +283 -116
  101. package/src/default/ListingComposerPage.tsx +403 -187
  102. package/src/default/ListingDetailPane.tsx +200 -98
  103. package/src/default/ListingPhoto.tsx +89 -16
  104. package/src/default/ListingPrice.tsx +77 -0
  105. package/src/default/MyListingsPane.tsx +286 -228
  106. package/src/default/SignInLink.tsx +4 -4
  107. package/src/default/StatusTags.tsx +36 -18
  108. package/src/default/index.ts +18 -7
  109. package/src/headless/Favorites.tsx +36 -7
  110. package/src/headless/ListingActions.tsx +19 -0
  111. package/src/headless/ListingComposer.tsx +56 -5
  112. package/src/headless/ListingDetail.tsx +12 -5
  113. package/src/headless/useMandateGate.ts +49 -5
  114. package/src/i18n/es.ts +29 -5
  115. package/src/i18n/generated/errors.es.gen.ts +1 -1
  116. package/src/i18n/generated/errors.gen.ts +3 -0
  117. package/src/i18n/generated/errors.json +7 -0
  118. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  119. package/src/i18n/keys.ts +54 -7
  120. package/src/i18n/ru.ts +41 -5
  121. package/src/index.ts +5 -1
  122. package/src/model/draft.ts +12 -7
  123. package/src/model/features.ts +48 -7
  124. package/dist/default/ErrorAlert.d.ts +0 -9
  125. package/dist/default/ErrorAlert.d.ts.map +0 -1
  126. package/dist/default/ErrorAlert.js +0 -27
  127. package/dist/default/ErrorAlert.js.map +0 -1
  128. package/dist/default/theme.d.ts +0 -25
  129. package/dist/default/theme.d.ts.map +0 -1
  130. package/dist/default/theme.js +0 -40
  131. package/dist/default/theme.js.map +0 -1
  132. package/src/default/ErrorAlert.tsx +0 -46
  133. package/src/default/theme.tsx +0 -67
@@ -74,6 +74,7 @@ export const LISTINGS_ERRORS = {
74
74
  "error.401.unauthorized": { status: 401, params: [], remediation: "reauthenticate", en: "Authentication required" },
75
75
  "error.402.payment_required": { status: 402, params: [], remediation: "retry", en: "Payment required" },
76
76
  "error.403.forbidden": { status: 403, params: [], remediation: "retry", en: "You do not have permission to perform this action" },
77
+ "error.403.listing_anonymous_not_allowed": { status: 403, params: [], remediation: "retry", en: "A guest account may not publish a listing" },
77
78
  "error.403.listing_not_owner": { status: 403, params: [], remediation: "retry", en: "Not your listing" },
78
79
  "error.403.network_blocked": { status: 403, params: [], remediation: "contact_support", en: "Requests from this network are not allowed" },
79
80
  "error.403.verification_enrollment_required": { status: 403, params: [], remediation: "verify", en: "Verification factor enrollment required" },
@@ -144,6 +145,7 @@ export const LISTINGS_ERROR_CODES: readonly ListingsErrorCode[] = [
144
145
  "error.401.unauthorized",
145
146
  "error.402.payment_required",
146
147
  "error.403.forbidden",
148
+ "error.403.listing_anonymous_not_allowed",
147
149
  "error.403.listing_not_owner",
148
150
  "error.403.network_blocked",
149
151
  "error.403.verification_enrollment_required",
@@ -216,6 +218,7 @@ export const listingsErrorBundleEn: Record<ListingsErrorCode, string> = {
216
218
  "error.401.unauthorized": "Authentication required",
217
219
  "error.402.payment_required": "Payment required",
218
220
  "error.403.forbidden": "You do not have permission to perform this action",
221
+ "error.403.listing_anonymous_not_allowed": "A guest account may not publish a listing",
219
222
  "error.403.listing_not_owner": "Not your listing",
220
223
  "error.403.network_blocked": "Requests from this network are not allowed",
221
224
  "error.403.verification_enrollment_required": "Verification factor enrollment required",
@@ -326,6 +326,13 @@
326
326
  "remediation": "retry",
327
327
  "en": "You do not have permission to perform this action"
328
328
  },
329
+ {
330
+ "code": "error.403.listing_anonymous_not_allowed",
331
+ "status": 403,
332
+ "params": [],
333
+ "remediation": "retry",
334
+ "en": "A guest account may not publish a listing"
335
+ },
329
336
  {
330
337
  "code": "error.403.listing_not_owner",
331
338
  "status": 403,
@@ -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: 22 key(s) owned by
11
+ * PARTIAL, and deliberately typed to say so: 23 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
@@ -62,7 +62,17 @@ export const LISTINGS_I18N_KEYS = {
62
62
  cardPriceAbsent: "listings.card.price_absent",
63
63
  cardFavoriteAdd: "listings.card.favorite_add",
64
64
  cardFavoriteRemove: "listings.card.favorite_remove",
65
- cardOpen: "listings.card.open",
65
+ /**
66
+ * The accessible name of a card whose listing has no title.
67
+ *
68
+ * The whole card is ONE anchor now, and an anchor's name is its `aria-label`
69
+ * — so a titleless listing would otherwise be a link announced as nothing at
70
+ * all, in a list of forty.
71
+ *
72
+ * This replaces `listings.card.open` (retired), which named a separate
73
+ * "Open" control that no longer exists: the card itself is the control.
74
+ */
75
+ cardUntitled: "listings.card.untitled",
66
76
  /** The door beside a blocked favourite: the container supplies WHERE. */
67
77
  cardSignIn: "listings.card.sign_in",
68
78
 
@@ -88,6 +98,9 @@ export const LISTINGS_I18N_KEYS = {
88
98
  * quantity in the value cell, so this key carries no `{count}` — it used to,
89
99
  * and the live page printed the placeholder. */
90
100
  detailStock: "listings.detail.stock",
101
+ /** The owner's two primaries on their own listing page. */
102
+ detailEdit: "listings.detail.edit",
103
+ detailTakeDown: "listings.detail.take_down",
91
104
 
92
105
  // ── composer ─────────────────────────────────────────────────────────────
93
106
  composeNewTitle: "listings.compose.new_title",
@@ -103,8 +116,7 @@ export const LISTINGS_I18N_KEYS = {
103
116
  composePriceInvalid: "listings.compose.price_invalid",
104
117
  composeCurrencyLabel: "listings.compose.currency_label",
105
118
  composeLocationLabel: "listings.compose.location_label",
106
- composeLatLabel: "listings.compose.lat_label",
107
- composeLonLabel: "listings.compose.lon_label",
119
+ composeLocationHelp: "listings.compose.location_help",
108
120
  composeGeoIncomplete: "listings.compose.geo_incomplete",
109
121
  composePhotos: "listings.compose.photos",
110
122
  composeTooManyImages: "listings.compose.too_many_images",
@@ -132,6 +144,10 @@ export const LISTINGS_I18N_KEYS = {
132
144
  composeBlockedNoDraft: "listings.compose.blocked.no_draft",
133
145
  composeBlockedBusy: "listings.compose.blocked.busy",
134
146
  composeBlockedMirror: "listings.compose.blocked.mirror",
147
+ /** The same refusal BEFORE anything is marked: "fix the highlighted fields"
148
+ * pointed at nothing highlighted, because the mirror only becomes visible
149
+ * once the person has tried to publish. */
150
+ composeBlockedIncomplete: "listings.compose.blocked.incomplete",
135
151
  composeBlockedDetailsUnavailable: "listings.compose.blocked.details_unavailable",
136
152
 
137
153
  // ── the owner's dashboard ────────────────────────────────────────────────
@@ -158,12 +174,18 @@ export const LISTINGS_I18N_KEYS = {
158
174
  mineArchive: "listings.mine.archive",
159
175
  mineComplete: "listings.mine.complete",
160
176
  mineDelete: "listings.mine.delete",
177
+ /** Deleting is irreversible, so it asks — through the shared SkinConfirm,
178
+ * which is a bottom sheet on a phone. */
179
+ mineDeleteConfirmTitle: "listings.mine.delete_confirm_title",
180
+ mineDeleteConfirmBody: "listings.mine.delete_confirm_body",
161
181
 
162
182
  // ── favourites ───────────────────────────────────────────────────────────
163
183
  favoritesTitle: "listings.favorites.title",
164
184
  favoritesLoading: "listings.favorites.loading",
165
185
  favoritesLoadFailed: "listings.favorites.load_failed",
166
186
  favoritesEmpty: "listings.favorites.empty",
187
+ favoritesEmptyHint: "listings.favorites.empty_hint",
188
+ favoritesSignInHint: "listings.favorites.sign_in_hint",
167
189
 
168
190
  // ── gates shared by every action a visitor cannot take ───────────────────
169
191
  blockedSignIn: "listings.blocked.sign_in",
@@ -174,10 +196,15 @@ export const LISTINGS_I18N_KEYS = {
174
196
  blockedTransition: "listings.blocked.transition",
175
197
  blockedDeleteActive: "listings.blocked.delete_active",
176
198
  blockedInFlight: "listings.blocked.in_flight",
199
+ /** Editing is a SCREEN, not an endpoint: whether this app has one is the
200
+ * container's fact, and a host that wires no `onEdit` gets a switched-off
201
+ * button that says so instead of one that silently does nothing. */
202
+ blockedNoEditor: "listings.blocked.no_editor",
177
203
 
178
204
  // ── keyset pagination ────────────────────────────────────────────────────
179
205
  pagePrev: "listings.page.prev",
180
206
  pageNext: "listings.page.next",
207
+ pageIndicator: "listings.page.indicator",
181
208
 
182
209
  // ── nav labels ───────────────────────────────────────────────────────────
183
210
  navDetail: "listings.nav.detail",
@@ -229,7 +256,7 @@ export const listingsI18nBundleEn: Record<string, string> = {
229
256
  "listings.card.price_absent": "Price on request",
230
257
  "listings.card.favorite_add": "Save to favourites",
231
258
  "listings.card.favorite_remove": "Remove from favourites",
232
- "listings.card.open": "Open",
259
+ "listings.card.untitled": "Untitled listing",
233
260
  "listings.card.sign_in": "Sign in",
234
261
 
235
262
  "listings.detail.loading": "Loading the listing…",
@@ -252,6 +279,8 @@ export const listingsI18nBundleEn: Record<string, string> = {
252
279
  "listings.detail.published_at": "Published {date}",
253
280
  "listings.detail.expires_at": "Listed until {date}",
254
281
  "listings.detail.stock": "In stock",
282
+ "listings.detail.edit": "Edit listing",
283
+ "listings.detail.take_down": "Take it down",
255
284
 
256
285
  "listings.compose.new_title": "New listing",
257
286
  "listings.compose.edit_title": "Edit listing",
@@ -270,8 +299,8 @@ export const listingsI18nBundleEn: Record<string, string> = {
270
299
  "Enter a price as a number, with at most two decimals",
271
300
  "listings.compose.currency_label": "Currency",
272
301
  "listings.compose.location_label": "Where it is",
273
- "listings.compose.lat_label": "Latitude",
274
- "listings.compose.lon_label": "Longitude",
302
+ "listings.compose.location_help":
303
+ "Buyers filter by distance, so a listing with no place is a listing they will not find",
275
304
  "listings.compose.geo_incomplete":
276
305
  "A latitude needs a longitude beside it — half a coordinate points nowhere",
277
306
  "listings.compose.photos": "Photos",
@@ -300,14 +329,18 @@ export const listingsI18nBundleEn: Record<string, string> = {
300
329
 
301
330
  "listings.compose.blocked.no_category":
302
331
  "Choose a category — the rest of the form depends on it",
332
+ // No `{types}`: the editor type is this build's vocabulary (`size_grid`),
333
+ // and a seller can do nothing with it. The fact they CAN act on is that
334
+ // this listing has to be finished somewhere else.
303
335
  "listings.compose.blocked.unsupported_type":
304
- "This category asks for a kind of detail this app cannot show yet ({types}), so it cannot fill it in for you",
336
+ "This category asks for a kind of detail this app cannot show yet, so the listing cannot be completed here",
305
337
  "listings.compose.blocked.photos_pending":
306
338
  "Wait for the photos to finish uploading",
307
339
  "listings.compose.blocked.no_draft": "The draft has not been created yet",
308
340
  "listings.compose.blocked.busy": "One moment — the last change is still saving",
309
341
  "listings.compose.blocked.mirror":
310
342
  "Fix the highlighted fields first",
343
+ "listings.compose.blocked.incomplete": "{count} required details are still empty",
311
344
  "listings.compose.blocked.details_unavailable":
312
345
  "We could not load what this category asks for, so we cannot check the form",
313
346
 
@@ -325,6 +358,10 @@ export const listingsI18nBundleEn: Record<string, string> = {
325
358
  "listings.mine.empty.archived": "Nothing archived, paused, expired or sold yet",
326
359
  "listings.mine.blocked.title":
327
360
  "{count} of your listings were taken down by moderation",
361
+ "listings.mine.blocked.title.one":
362
+ "One of your listings was taken down by moderation",
363
+ "listings.mine.blocked.title.other":
364
+ "{count} of your listings were taken down by moderation",
328
365
  "listings.mine.blocked.load_failed":
329
366
  "We could not check whether any of your listings were taken down",
330
367
  "listings.mine.live_under_review":
@@ -333,11 +370,18 @@ export const listingsI18nBundleEn: Record<string, string> = {
333
370
  "listings.mine.archive": "Archive",
334
371
  "listings.mine.complete": "Mark sold",
335
372
  "listings.mine.delete": "Delete",
373
+ "listings.mine.delete_confirm_title": "Delete this listing?",
374
+ "listings.mine.delete_confirm_body":
375
+ "It disappears from your dashboard and cannot be brought back. Archiving keeps it.",
336
376
 
337
377
  "listings.favorites.title": "Favourites",
338
378
  "listings.favorites.loading": "Loading your favourites…",
339
379
  "listings.favorites.load_failed": "We could not load your favourites",
340
380
  "listings.favorites.empty": "You have not saved anything yet",
381
+ "listings.favorites.empty_hint":
382
+ "Tap the heart on any listing and it will be waiting here.",
383
+ "listings.favorites.sign_in_hint":
384
+ "Favourites are kept with your account, so they follow you between devices.",
341
385
 
342
386
  "listings.blocked.sign_in": "Sign in to do this",
343
387
  "listings.blocked.guest":
@@ -349,9 +393,12 @@ export const listingsI18nBundleEn: Record<string, string> = {
349
393
  "listings.blocked.delete_active":
350
394
  "Archive it first — a listing that is on sale cannot be deleted",
351
395
  "listings.blocked.in_flight": "One moment — that is already under way",
396
+ "listings.blocked.no_editor":
397
+ "This app has no screen for editing a listing yet",
352
398
 
353
399
  "listings.page.prev": "Previous",
354
400
  "listings.page.next": "Next",
401
+ "listings.page.indicator": "Page {page}",
355
402
 
356
403
  "listings.nav.detail": "Listing",
357
404
  "listings.nav.compose": "Post a listing",
package/src/i18n/ru.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { I18nDictionary, I18nEngine } from "@stapel/core";
2
+ import { registerAttributesI18nRu } from "@stapel/attributes-react/i18n/ru";
2
3
  import { listingsErrorBundleRu } from "./generated/errors.ru.gen.js";
3
4
 
4
5
  export { listingsErrorBundleRu } from "./generated/errors.ru.gen.js";
@@ -47,6 +48,8 @@ export const listingsI18nBundleRu: I18nDictionary = {
47
48
  "Неизвестный статус объявления: «{status}»",
48
49
  "error.400.publish_validation_failed":
49
50
  "Объявление не прошло проверку и не опубликовано",
51
+ "error.403.listing_anonymous_not_allowed":
52
+ "Чтобы подать объявление, войдите или зарегистрируйтесь",
50
53
  "error.403.listing_not_owner": "Это не ваше объявление",
51
54
  "error.404.listing_not_found": "Объявление не найдено",
52
55
  "error.409.already_favorited": "Объявление уже в избранном",
@@ -87,7 +90,7 @@ export const listingsI18nBundleRu: I18nDictionary = {
87
90
  "listings.card.price_absent": "Цена по запросу",
88
91
  "listings.card.favorite_add": "В избранное",
89
92
  "listings.card.favorite_remove": "Убрать из избранного",
90
- "listings.card.open": "Открыть",
93
+ "listings.card.untitled": "Объявление без названия",
91
94
  "listings.card.sign_in": "Войти",
92
95
 
93
96
  "listings.detail.loading": "Загружаем объявление…",
@@ -110,6 +113,8 @@ export const listingsI18nBundleRu: I18nDictionary = {
110
113
  "listings.detail.published_at": "Опубликовано {date}",
111
114
  "listings.detail.expires_at": "В продаже до {date}",
112
115
  "listings.detail.stock": "В наличии",
116
+ "listings.detail.edit": "Редактировать объявление",
117
+ "listings.detail.take_down": "Снять с публикации",
113
118
 
114
119
  "listings.compose.new_title": "Новое объявление",
115
120
  "listings.compose.edit_title": "Редактирование объявления",
@@ -128,8 +133,8 @@ export const listingsI18nBundleRu: I18nDictionary = {
128
133
  "Введите цену числом, не более двух знаков после запятой",
129
134
  "listings.compose.currency_label": "Валюта",
130
135
  "listings.compose.location_label": "Где находится",
131
- "listings.compose.lat_label": "Широта",
132
- "listings.compose.lon_label": "Долгота",
136
+ "listings.compose.location_help":
137
+ "Покупатели ищут по расстоянию — объявление без места они просто не найдут",
133
138
  "listings.compose.geo_incomplete":
134
139
  "Рядом с широтой нужна долгота — половина координаты никуда не указывает",
135
140
  "listings.compose.photos": "Фотографии",
@@ -160,12 +165,13 @@ export const listingsI18nBundleRu: I18nDictionary = {
160
165
  "listings.compose.blocked.no_category":
161
166
  "Выберите категорию — от неё зависит остальная форма",
162
167
  "listings.compose.blocked.unsupported_type":
163
- "Эта категория спрашивает характеристику, которую приложение пока не умеет показывать ({types}), поэтому заполнить её здесь нельзя",
168
+ "Эта категория спрашивает характеристику, которую приложение пока не умеет показывать, поэтому закончить объявление здесь нельзя",
164
169
  "listings.compose.blocked.photos_pending":
165
170
  "Дождитесь окончания загрузки фотографий",
166
171
  "listings.compose.blocked.no_draft": "Черновик ещё не создан",
167
172
  "listings.compose.blocked.busy":
168
173
  "Секунду — предыдущее изменение ещё сохраняется",
174
+ "listings.compose.blocked.incomplete": "Не заполнено обязательных деталей: {count}",
169
175
  "listings.compose.blocked.mirror": "Сначала поправьте отмеченные поля",
170
176
  "listings.compose.blocked.details_unavailable":
171
177
  "Не удалось загрузить характеристики категории, поэтому проверить форму нельзя",
@@ -184,6 +190,10 @@ export const listingsI18nBundleRu: I18nDictionary = {
184
190
  "listings.mine.empty.archived": "Пока ничего не в архиве, не снято, не истекло и не продано",
185
191
  "listings.mine.blocked.title":
186
192
  "Модерация сняла ваших объявлений: {count}",
193
+ "listings.mine.blocked.title.one": "Модерация сняла одно ваше объявление",
194
+ "listings.mine.blocked.title.few": "Модерация сняла {count} ваших объявления",
195
+ "listings.mine.blocked.title.many": "Модерация сняла {count} ваших объявлений",
196
+ "listings.mine.blocked.title.other": "Модерация сняла ваших объявлений: {count}",
187
197
  "listings.mine.blocked.load_failed":
188
198
  "Не удалось проверить, снимала ли модерация ваши объявления",
189
199
  "listings.mine.live_under_review": "Опубликовано, правки на проверке",
@@ -191,11 +201,18 @@ export const listingsI18nBundleRu: I18nDictionary = {
191
201
  "listings.mine.archive": "В архив",
192
202
  "listings.mine.complete": "Отметить проданным",
193
203
  "listings.mine.delete": "Удалить",
204
+ "listings.mine.delete_confirm_title": "Удалить объявление?",
205
+ "listings.mine.delete_confirm_body":
206
+ "Оно исчезнет из кабинета, и вернуть его будет нельзя. Архив сохраняет его.",
194
207
 
195
208
  "listings.favorites.title": "Избранное",
196
209
  "listings.favorites.loading": "Загружаем избранное…",
197
210
  "listings.favorites.load_failed": "Не удалось загрузить избранное",
198
211
  "listings.favorites.empty": "Вы ещё ничего не сохранили",
212
+ "listings.favorites.empty_hint":
213
+ "Нажмите на сердечко в любом объявлении — оно будет ждать здесь.",
214
+ "listings.favorites.sign_in_hint":
215
+ "Избранное хранится в аккаунте, поэтому переходит с вами на другие устройства.",
199
216
 
200
217
  "listings.blocked.sign_in": "Войдите, чтобы сделать это",
201
218
  "listings.blocked.guest":
@@ -207,9 +224,12 @@ export const listingsI18nBundleRu: I18nDictionary = {
207
224
  "listings.blocked.delete_active":
208
225
  "Сначала уберите в архив — объявление в продаже удалить нельзя",
209
226
  "listings.blocked.in_flight": "Секунду — это уже выполняется",
227
+ "listings.blocked.no_editor":
228
+ "В этом приложении пока нет экрана редактирования объявления",
210
229
 
211
230
  "listings.page.prev": "Назад",
212
231
  "listings.page.next": "Дальше",
232
+ "listings.page.indicator": "Страница {page}",
213
233
 
214
234
  "listings.nav.detail": "Объявление",
215
235
  "listings.nav.compose": "Подать объявление",
@@ -217,7 +237,23 @@ export const listingsI18nBundleRu: I18nDictionary = {
217
237
  "listings.nav.favorites": "Избранное",
218
238
  };
219
239
 
220
- /** Register the Russian bundle into a core i18n engine. */
240
+ /**
241
+ * Register the Russian bundle into a core i18n engine — AND the twelve
242
+ * `stapel_attributes` sentences this pair deliberately does not author.
243
+ *
244
+ * The split of ownership is right and stays: two packages must not give one
245
+ * refusal two sentences. What was wrong was leaving the JOIN to a README. A
246
+ * host that registered only this bundle got twelve of the composer's most
247
+ * common refusals ("this value is below the minimum", "the description is too
248
+ * long") in English on a Russian page, and nothing failed anywhere to say so.
249
+ *
250
+ * `@stapel/attributes-react` is a peer of this pair and its editors are what
251
+ * RAISE those twelve, so a listings-ru host is an attributes-ru host by
252
+ * construction; chaining the registration states that instead of asking. It is
253
+ * idempotent — a host that also registers the bundle itself simply writes the
254
+ * same keys twice.
255
+ */
221
256
  export function registerListingsI18nRu(i18n: I18nEngine): void {
257
+ registerAttributesI18nRu(i18n);
222
258
  i18n.registerBundle("ru", listingsI18nBundleRu);
223
259
  }
package/src/index.ts CHANGED
@@ -214,7 +214,11 @@ export type {
214
214
 
215
215
  // ── headless (renderless components + their bags) ────────────────────────────
216
216
  export { ListingsProvider } from "./headless/ListingsProvider.js";
217
- export { useMandateGate } from "./headless/useMandateGate.js";
217
+ export {
218
+ useMandateGate,
219
+ useElevatableMandateGate,
220
+ LISTINGS_ELEVATION_ACTIONS,
221
+ } from "./headless/useMandateGate.js";
218
222
  export { ListingDetail, useListingDetail } from "./headless/ListingDetail.js";
219
223
  export type {
220
224
  ListingDetailBag,
@@ -29,12 +29,14 @@ import { DEFAULT_LISTING_CURRENCY } from "../api/types.js";
29
29
  * `geohash` that disagrees with the coordinates beside it is worse than no
30
30
  * geohash — the pin and the bucket point at different places.
31
31
  *
32
- * The pair does NOT compute `geohash` from `lat`/`lon`, and that is a
33
- * decision. A geohash is precision-dependent, the indexer's bucketing depends
34
- * on which precision was used, and a client that picked its own would put
35
- * listings in neighbouring cells for reasons nobody could see. It is written
36
- * by whatever resolved the place (a geo picker, `stapel-geo`) and carried
37
- * verbatim from there to the wire.
32
+ * `geohash` is SERVER-COMPUTED and read-only since stapel-listings 0.7.1.
33
+ * `Listing.save()` stamps `geohash_draft` from `lat_draft`/`lon_draft` through
34
+ * the `geo.geohash_encode` comm function, and `ListingDraftSerializer` marks
35
+ * the field `readOnly` a value sent in the request body is silently ignored.
36
+ * So this member is something the composer READS BACK (a reopened draft, a
37
+ * published listing) and never something it sends: `draftPatchFromValues`
38
+ * omits it, and a picker that happens to know a geohash may keep it for its
39
+ * own display without expecting the wire to carry it.
38
40
  */
39
41
  export interface ListingLocation {
40
42
  /** Opaque id from whatever place directory the deployment uses. */
@@ -45,6 +47,7 @@ export interface ListingLocation {
45
47
  * `55.796100` into `55.7961` and change what was submitted. */
46
48
  readonly lat: string | null;
47
49
  readonly lon: string | null;
50
+ /** Read-only: the server computes it from `lat`/`lon` (0.7.1). Never sent. */
48
51
  readonly geohash: string;
49
52
  }
50
53
 
@@ -242,7 +245,9 @@ export function draftPatchFromValues(
242
245
  images_draft: [...values.images],
243
246
  location_id_draft: values.location.locationId,
244
247
  location_label_draft: values.location.locationLabel,
245
- geohash_draft: values.location.geohash,
248
+ // No `geohash_draft`: stapel-listings 0.7.1 computes it in `Listing.save()`
249
+ // from the coordinates and marks the serializer field read-only, so a value
250
+ // sent here is discarded. Sending one would be a claim the wire ignores.
246
251
  lat_draft: values.location.lat,
247
252
  lon_draft: values.location.lon,
248
253
  features_draft: toWireFeatures(features, values.features),
@@ -22,14 +22,28 @@
22
22
  * `order`, `title`, `badge`) are simply ignored by every formatter. Copying a
23
23
  * hand-picked subset instead would be a list to keep in step with ten types.
24
24
  *
25
- * ── What a DAO does NOT carry ──────────────────────────────────────────────
25
+ * ── What a DAO does NOT carry, and the one line that repairs it ────────────
26
26
  *
27
- * `select`'s `options`. `normalize_to_dao` stores the chosen VALUES, not the
28
- * option table, so `formatFeatureValue` falls back to the raw option value
29
- * which is a translation KEY when the config is translatable (the default),
30
- * so a host whose bundle carries the catalogue's option copy still reads a
31
- * word. A host whose bundle does not sees the key, on purpose: a visible
32
- * `option.condition.used` gets fixed, an invented "Used" ships wrong.
27
+ * `select`'s `options`. `SelectType.dto_to_dao` stores the chosen VALUES and
28
+ * the ui config, never the option table the table lives on the CATEGORY, and
29
+ * not needing it is the whole point of the projection.
30
+ *
31
+ * The consequence was a defect the visual pass caught on every card and every
32
+ * spec row: `formatFeatureValue` resolves an option's copy by looking the
33
+ * value up in `config.options`, and with no table it falls through to
34
+ * `String(value)`. The stored value of a translatable catalogue IS a
35
+ * translation key, so a listing page printed `demo.condition.used` and
36
+ * `demo.brand.bosch` at people. (The server does not have this problem: its
37
+ * own `format_value` has the category's config in hand.)
38
+ *
39
+ * So the split below synthesizes the IDENTITY table — `{value: v, label: v}`
40
+ * for each stored value — which is exactly the table a translatable catalogue
41
+ * would have produced, since its labels ARE the keys. `formatFeatureValue`
42
+ * then runs the value through the host's `t` and a bundle carrying the
43
+ * catalogue copy reads "Used". A bundle that does not still shows the key, on
44
+ * purpose: a visible `option.condition.used` gets fixed, an invented "Used"
45
+ * ships wrong. A NON-translatable catalogue stores literal labels, `t` returns
46
+ * an unknown key unchanged, and the output is what it always was.
33
47
  */
34
48
  import type { FeatureDef, FeatureValueDto } from "@stapel/attributes-react";
35
49
  import type { ListingFeatureDao, ListingFeatureView } from "../api/types.js";
@@ -37,6 +51,31 @@ import type { ListingFeatureDao, ListingFeatureView } from "../api/types.js";
37
51
  /** Keys that belong to the DAO envelope rather than to the type's config. */
38
52
  const ENVELOPE = new Set(["slug", "value", "name", "order", "title", "badge"]);
39
53
 
54
+ /** The two types whose stored `value` is an option key rather than the thing
55
+ * itself. `hierarchical_select` is deliberately absent: its formatter joins
56
+ * the path with " / " and never consults an option table at all. */
57
+ const OPTION_VALUED = new Set(["select"]);
58
+
59
+ /**
60
+ * The identity option table for a stored `select` — see the module header.
61
+ *
62
+ * Returns `undefined` when there is nothing to add (another type, a config
63
+ * that already carries a table, a value that is not a list of strings), so the
64
+ * common path allocates nothing and a DAO that DOES carry options is left
65
+ * exactly as it arrived.
66
+ */
67
+ function synthesizedOptions(
68
+ dao: ListingFeatureDao,
69
+ config: Readonly<Record<string, unknown>>
70
+ ): readonly { value: string; label: string }[] | undefined {
71
+ if (typeof dao.type !== "string" || !OPTION_VALUED.has(dao.type)) return undefined;
72
+ if (Array.isArray(config["options"])) return undefined;
73
+ const raw = Array.isArray(dao.value) ? dao.value : [dao.value];
74
+ const values = raw.filter((item): item is string => typeof item === "string");
75
+ if (values.length === 0) return undefined;
76
+ return values.map((value) => ({ value, label: value }));
77
+ }
78
+
40
79
  /**
41
80
  * One DAO row → the definition/value pair the display half consumes.
42
81
  *
@@ -54,6 +93,8 @@ export function featureFromDao(
54
93
  for (const [key, value] of Object.entries(dao)) {
55
94
  if (!ENVELOPE.has(key)) config[key] = value;
56
95
  }
96
+ const options = synthesizedOptions(dao, config);
97
+ if (options !== undefined) config["options"] = options;
57
98
 
58
99
  const feature: FeatureDef = {
59
100
  slug: dao.slug,
@@ -1,9 +0,0 @@
1
- import type { CSSProperties, ReactElement, ReactNode } from "react";
2
- import type { FlowErrorDisplay } from "@stapel/core";
3
- export declare function ErrorAlert(props: {
4
- error: FlowErrorDisplay | undefined;
5
- style?: CSSProperties | undefined;
6
- testId?: string | undefined;
7
- action?: ReactNode;
8
- }): ReactElement | null;
9
- //# sourceMappingURL=ErrorAlert.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"ErrorAlert.d.ts","sourceRoot":"","sources":["../../src/default/ErrorAlert.tsx"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACpE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,wBAAgB,UAAU,CAAC,KAAK,EAAE;IAChC,KAAK,EAAE,gBAAgB,GAAG,SAAS,CAAC;IACpC,KAAK,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;IAClC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,MAAM,CAAC,EAAE,SAAS,CAAC;CACpB,GAAG,YAAY,GAAG,IAAI,CAsBtB"}
@@ -1,27 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- /**
3
- * The one error surface this pair's default skin renders, so the split copy
4
- * core produces reaches a screen the same way everywhere (mirrors the
5
- * docs/notifications/workspaces etalon).
6
- *
7
- * Core's `describeFlowError` splits a failure into the sentence a person
8
- * reads and the technical detail a support agent quotes (owner report
9
- * 2026-08-09: the status used to be spliced into the sentence, ending in a
10
- * bare `" (500)"`). The split only helps if a skin renders both halves at
11
- * their own weights: `message` at normal weight, `detail` muted and small.
12
- *
13
- * `undefined` in — including a `detail` core left `undefined` because there
14
- * was nothing worth quoting — renders nothing rather than an empty line.
15
- */
16
- import { Alert, Typography } from "antd";
17
- export function ErrorAlert(props) {
18
- const { error } = props;
19
- if (!error)
20
- return null;
21
- return (_jsx(Alert, { type: "error", showIcon: true, ...(props.style ? { style: props.style } : {}), ...(props.testId ? { "data-testid": props.testId } : {}), ...(props.action ? { action: props.action } : {}), message: error.message, ...(error.detail
22
- ? {
23
- description: (_jsx(Typography.Text, { type: "secondary", style: { fontSize: 12 }, children: error.detail })),
24
- }
25
- : {}) }));
26
- }
27
- //# sourceMappingURL=ErrorAlert.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"ErrorAlert.js","sourceRoot":"","sources":["../../src/default/ErrorAlert.tsx"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAIzC,MAAM,UAAU,UAAU,CAAC,KAK1B;IACC,MAAM,EAAE,KAAK,EAAE,GAAG,KAAK,CAAC;IACxB,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAC;IACxB,OAAO,CACL,KAAC,KAAK,IACJ,IAAI,EAAC,OAAO,EACZ,QAAQ,WACJ,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KAC3C,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,KACrD,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAClD,OAAO,EAAE,KAAK,CAAC,OAAO,KAClB,CAAC,KAAK,CAAC,MAAM;YACf,CAAC,CAAC;gBACE,WAAW,EAAE,CACX,KAAC,UAAU,CAAC,IAAI,IAAC,IAAI,EAAC,WAAW,EAAC,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,YACtD,KAAK,CAAC,MAAM,GACG,CACnB;aACF;YACH,CAAC,CAAC,EAAE,CAAC,GACP,CACH,CAAC;AACJ,CAAC"}
@@ -1,25 +0,0 @@
1
- import type { CSSProperties, ReactElement, ReactNode } from "react";
2
- import type { ThemeMode } from "@stapel/tokens-antd";
3
- export interface ListingsSkinThemeProps {
4
- /**
5
- * Light or dark. Defaults to the mode the host's document declares
6
- * (`resolveThemeMode()`). Pass explicitly to pin a side.
7
- */
8
- readonly mode?: ThemeMode;
9
- /** Extra styles merged onto the themed root element (layout only — colors
10
- * come from the mode). */
11
- readonly style?: CSSProperties;
12
- readonly children: ReactNode;
13
- }
14
- /**
15
- * `<ListingsSkinTheme/>` — the self-theming wrapper of
16
- * `@stapel/listings-react/default`. Standalone use is supported (wrap any
17
- * composition of the skin's parts once); the shipped surfaces
18
- * (`ListingCard`, `ListingDetailPane`, `ListingComposerPage`,
19
- * `MyListingsPane`, `FavoritesPane`)
20
- * each already wrap themselves, and nested antd
21
- * `ConfigProvider`s merge, so composing them under one `ListingsSkinTheme`
22
- * stays correct.
23
- */
24
- export declare function ListingsSkinTheme(props: ListingsSkinThemeProps): ReactElement;
25
- //# sourceMappingURL=theme.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../../src/default/theme.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAOpE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAErD,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,CAAC;IAC1B;8BAC0B;IAC1B,QAAQ,CAAC,KAAK,CAAC,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAC;CAC9B;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,sBAAsB,GAAG,YAAY,CAmB7E"}
@@ -1,40 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- /**
3
- * The default skin's INTERNAL theme provider — every `/default` surface wraps
4
- * itself in this, so the skin is readable no matter what the host supplies
5
- * (tracker #26: a skin set with no internal provider once inherited a theme
6
- * bridge serving light-mode values inside a dark document — text on
7
- * background at 1.00:1). The theme derives from `@stapel/tokens` via
8
- * `@stapel/tokens-antd`'s `toAntdThemeConfig(mode)`; `mode` defaults to what
9
- * the HOST's document declares (`resolveThemeMode()` — the `data-theme`
10
- * attribute `tokens.css` keys its dark block on), never to a hardcoded side.
11
- *
12
- * This is also what "override styles" means for this pair: a storefront
13
- * retheming through the §68 token JSON regenerates its `--stapel-*` custom
14
- * properties and the result page follows with zero code — a far better deal
15
- * than a prop-per-colour API, and the reason the skin has none.
16
- */
17
- import { useMemo } from "react";
18
- import { ConfigProvider } from "antd";
19
- import { resolveThemeMode, toAntdTheme, toAntdThemeConfig, } from "@stapel/tokens-antd";
20
- /**
21
- * `<ListingsSkinTheme/>` — the self-theming wrapper of
22
- * `@stapel/listings-react/default`. Standalone use is supported (wrap any
23
- * composition of the skin's parts once); the shipped surfaces
24
- * (`ListingCard`, `ListingDetailPane`, `ListingComposerPage`,
25
- * `MyListingsPane`, `FavoritesPane`)
26
- * each already wrap themselves, and nested antd
27
- * `ConfigProvider`s merge, so composing them under one `ListingsSkinTheme`
28
- * stays correct.
29
- */
30
- export function ListingsSkinTheme(props) {
31
- const mode = props.mode ?? resolveThemeMode();
32
- const theme = useMemo(() => toAntdThemeConfig(mode), [mode]);
33
- const token = useMemo(() => toAntdTheme(mode), [mode]);
34
- return (_jsx(ConfigProvider, { theme: theme, children: _jsx("div", { "data-listings-skin-root": true, "data-listings-skin-mode": mode, style: {
35
- color: token.colorText,
36
- backgroundColor: token.colorBgContainer,
37
- ...props.style,
38
- }, children: props.children }) }));
39
- }
40
- //# sourceMappingURL=theme.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"theme.js","sourceRoot":"","sources":["../../src/default/theme.tsx"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC;AAEhC,OAAO,EAAE,cAAc,EAAE,MAAM,MAAM,CAAC;AACtC,OAAO,EACL,gBAAgB,EAChB,WAAW,EACX,iBAAiB,GAClB,MAAM,qBAAqB,CAAC;AAe7B;;;;;;;;;GASG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAA6B;IAC7D,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,IAAI,gBAAgB,EAAE,CAAC;IAC9C,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,iBAAiB,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC;IAC7D,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC;IACvD,OAAO,CACL,KAAC,cAAc,IAAC,KAAK,EAAE,KAAK,YAC1B,0EAE2B,IAAI,EAC7B,KAAK,EAAE;gBACL,KAAK,EAAE,KAAK,CAAC,SAAS;gBACtB,eAAe,EAAE,KAAK,CAAC,gBAAgB;gBACvC,GAAG,KAAK,CAAC,KAAK;aACf,YAEA,KAAK,CAAC,QAAQ,GACX,GACS,CAClB,CAAC;AACJ,CAAC"}