@stapel/listings-react 0.3.1 → 0.7.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 +122 -0
- package/MODULE.md +12 -6
- package/README.md +33 -17
- package/dist/api/generated/schema.d.ts +129 -3
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/listingsApi.d.ts +35 -22
- package/dist/api/listingsApi.d.ts.map +1 -1
- package/dist/api/listingsApi.js +17 -0
- package/dist/api/listingsApi.js.map +1 -1
- package/dist/api/types.d.ts +43 -2
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FavoritesPane.d.ts +40 -3
- package/dist/default/FavoritesPane.d.ts.map +1 -1
- package/dist/default/FavoritesPane.js +30 -18
- package/dist/default/FavoritesPane.js.map +1 -1
- package/dist/default/ListingCard.d.ts +25 -4
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +19 -12
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingComposerPage.d.ts +163 -23
- package/dist/default/ListingComposerPage.d.ts.map +1 -1
- package/dist/default/ListingComposerPage.js +85 -43
- package/dist/default/ListingComposerPage.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +41 -4
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +38 -25
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts +13 -1
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +47 -9
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingPrice.d.ts +13 -0
- package/dist/default/ListingPrice.d.ts.map +1 -0
- package/dist/default/ListingPrice.js +60 -0
- package/dist/default/ListingPrice.js.map +1 -0
- package/dist/default/MyListingsPane.d.ts +9 -17
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +148 -63
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/default/SignInLink.js +8 -7
- package/dist/default/SignInLink.js.map +1 -1
- package/dist/default/StatusTags.d.ts +16 -5
- package/dist/default/StatusTags.d.ts.map +1 -1
- package/dist/default/StatusTags.js +19 -13
- package/dist/default/StatusTags.js.map +1 -1
- package/dist/default/index.d.ts +15 -9
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +13 -6
- package/dist/default/index.js.map +1 -1
- package/dist/headless/Favorites.d.ts +6 -0
- package/dist/headless/Favorites.d.ts.map +1 -1
- package/dist/headless/Favorites.js +4 -0
- package/dist/headless/Favorites.js.map +1 -1
- package/dist/headless/ListingActions.d.ts +12 -0
- package/dist/headless/ListingActions.d.ts.map +1 -1
- package/dist/headless/ListingActions.js +3 -0
- package/dist/headless/ListingActions.js.map +1 -1
- package/dist/headless/ListingComposer.d.ts +23 -0
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +33 -7
- package/dist/headless/ListingComposer.js.map +1 -1
- package/dist/headless/MyListings.d.ts +27 -12
- package/dist/headless/MyListings.d.ts.map +1 -1
- package/dist/headless/MyListings.js +29 -7
- package/dist/headless/MyListings.js.map +1 -1
- package/dist/i18n/es.d.ts +6 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +28 -6
- 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 +6 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +3 -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 +28 -4
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +49 -8
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts +16 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +40 -6
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -6
- package/dist/index.js.map +1 -1
- package/dist/model/draft.d.ts +9 -6
- package/dist/model/draft.d.ts.map +1 -1
- package/dist/model/draft.js +3 -1
- package/dist/model/draft.js.map +1 -1
- package/dist/model/features.d.ts +21 -7
- package/dist/model/features.d.ts.map +1 -1
- package/dist/model/features.js +26 -0
- package/dist/model/features.js.map +1 -1
- package/dist/model/mine.d.ts +44 -0
- package/dist/model/mine.d.ts.map +1 -0
- package/dist/model/mine.js +52 -0
- package/dist/model/mine.js.map +1 -0
- package/dist/model/mineSource.d.ts +39 -34
- package/dist/model/mineSource.d.ts.map +1 -1
- package/dist/model/mineSource.js +10 -41
- package/dist/model/mineSource.js.map +1 -1
- package/dist/model/mutations.d.ts.map +1 -1
- package/dist/model/mutations.js +11 -4
- package/dist/model/mutations.js.map +1 -1
- package/dist/model/queryKeys.d.ts +7 -2
- package/dist/model/queryKeys.d.ts.map +1 -1
- package/dist/model/queryKeys.js +2 -0
- package/dist/model/queryKeys.js.map +1 -1
- package/dist/model/status.d.ts +12 -31
- package/dist/model/status.d.ts.map +1 -1
- package/dist/model/status.js +44 -0
- package/dist/model/status.js.map +1 -1
- package/llms.txt +11 -8
- package/manifest.json +88 -14
- package/nav-manifest.json +1 -1
- package/package.json +17 -12
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +129 -3
- package/src/api/listingsApi.ts +60 -21
- package/src/api/types.ts +46 -2
- package/src/default/FavoritesPane.tsx +156 -82
- package/src/default/ListingCard.tsx +88 -46
- package/src/default/ListingComposerPage.tsx +403 -187
- package/src/default/ListingDetailPane.tsx +200 -98
- package/src/default/ListingPhoto.tsx +89 -16
- package/src/default/ListingPrice.tsx +77 -0
- package/src/default/MyListingsPane.tsx +344 -222
- package/src/default/SignInLink.tsx +4 -4
- package/src/default/StatusTags.tsx +36 -18
- package/src/default/index.ts +18 -7
- package/src/headless/Favorites.tsx +10 -0
- package/src/headless/ListingActions.tsx +19 -0
- package/src/headless/ListingComposer.tsx +56 -5
- package/src/headless/MyListings.tsx +62 -22
- package/src/i18n/es.ts +36 -7
- package/src/i18n/generated/errors.es.gen.ts +1 -1
- package/src/i18n/generated/errors.gen.ts +3 -0
- package/src/i18n/generated/errors.json +9 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/keys.ts +58 -9
- package/src/i18n/ru.ts +48 -7
- package/src/index.ts +16 -5
- package/src/model/draft.ts +12 -7
- package/src/model/features.ts +48 -7
- package/src/model/mine.ts +70 -0
- package/src/model/mineSource.ts +46 -41
- package/src/model/mutations.ts +11 -4
- package/src/model/queryKeys.ts +9 -2
- package/src/model/status.ts +15 -0
- package/dist/default/ErrorAlert.d.ts +0 -9
- package/dist/default/ErrorAlert.d.ts.map +0 -1
- package/dist/default/ErrorAlert.js +0 -27
- package/dist/default/ErrorAlert.js.map +0 -1
- package/dist/default/theme.d.ts +0 -25
- package/dist/default/theme.d.ts.map +0 -1
- package/dist/default/theme.js +0 -40
- package/dist/default/theme.js.map +0 -1
- package/src/default/ErrorAlert.tsx +0 -46
- package/src/default/theme.tsx +0 -67
|
@@ -66,6 +66,7 @@ export const LISTINGS_ERRORS = {
|
|
|
66
66
|
"error.400.image_required": { status: 400, params: [], remediation: "fix_input", en: "At least one image is required to publish" },
|
|
67
67
|
"error.400.invalid_ad_id": { status: 400, params: [], remediation: "fix_input", en: "Invalid advertisement ID" },
|
|
68
68
|
"error.400.listing_feature_not_allowed": { status: 400, params: ["feature"], remediation: "fix_input", en: "Feature '{feature}' is not allowed for this category" },
|
|
69
|
+
"error.400.listing_invalid_status_filter": { status: 400, params: ["status"], remediation: "fix_input", en: "Unknown listing status '{status}'" },
|
|
69
70
|
"error.400.publish_validation_failed": { status: 400, params: [], remediation: "fix_input", en: "Listing validation failed" },
|
|
70
71
|
"error.400.validation_error": { status: 400, params: [], remediation: "fix_input", en: "Validation error" },
|
|
71
72
|
"error.400.verification_failed": { status: 400, params: [], remediation: "verify", en: "Verification failed" },
|
|
@@ -135,6 +136,7 @@ export const LISTINGS_ERROR_CODES: readonly ListingsErrorCode[] = [
|
|
|
135
136
|
"error.400.image_required",
|
|
136
137
|
"error.400.invalid_ad_id",
|
|
137
138
|
"error.400.listing_feature_not_allowed",
|
|
139
|
+
"error.400.listing_invalid_status_filter",
|
|
138
140
|
"error.400.publish_validation_failed",
|
|
139
141
|
"error.400.validation_error",
|
|
140
142
|
"error.400.verification_failed",
|
|
@@ -206,6 +208,7 @@ export const listingsErrorBundleEn: Record<ListingsErrorCode, string> = {
|
|
|
206
208
|
"error.400.image_required": "At least one image is required to publish",
|
|
207
209
|
"error.400.invalid_ad_id": "Invalid advertisement ID",
|
|
208
210
|
"error.400.listing_feature_not_allowed": "Feature '{feature}' is not allowed for this category",
|
|
211
|
+
"error.400.listing_invalid_status_filter": "Unknown listing status '{status}'",
|
|
209
212
|
"error.400.publish_validation_failed": "Listing validation failed",
|
|
210
213
|
"error.400.validation_error": "Validation error",
|
|
211
214
|
"error.400.verification_failed": "Verification failed",
|
|
@@ -268,6 +268,15 @@
|
|
|
268
268
|
"remediation": "fix_input",
|
|
269
269
|
"en": "Feature '{feature}' is not allowed for this category"
|
|
270
270
|
},
|
|
271
|
+
{
|
|
272
|
+
"code": "error.400.listing_invalid_status_filter",
|
|
273
|
+
"status": 400,
|
|
274
|
+
"params": [
|
|
275
|
+
"status"
|
|
276
|
+
],
|
|
277
|
+
"remediation": "fix_input",
|
|
278
|
+
"en": "Unknown listing status '{status}'"
|
|
279
|
+
},
|
|
271
280
|
{
|
|
272
281
|
"code": "error.400.publish_validation_failed",
|
|
273
282
|
"status": 400,
|
|
@@ -8,7 +8,7 @@ import type { ListingsErrorCode } from "./errors.gen.js";
|
|
|
8
8
|
/**
|
|
9
9
|
* `ru` texts for the backend error codes this catalog carries.
|
|
10
10
|
*
|
|
11
|
-
* PARTIAL, and deliberately typed to say so:
|
|
11
|
+
* PARTIAL, and deliberately typed to say so: 22 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
|
@@ -88,6 +88,9 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
88
88
|
* quantity in the value cell, so this key carries no `{count}` — it used to,
|
|
89
89
|
* and the live page printed the placeholder. */
|
|
90
90
|
detailStock: "listings.detail.stock",
|
|
91
|
+
/** The owner's two primaries on their own listing page. */
|
|
92
|
+
detailEdit: "listings.detail.edit",
|
|
93
|
+
detailTakeDown: "listings.detail.take_down",
|
|
91
94
|
|
|
92
95
|
// ── composer ─────────────────────────────────────────────────────────────
|
|
93
96
|
composeNewTitle: "listings.compose.new_title",
|
|
@@ -103,8 +106,7 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
103
106
|
composePriceInvalid: "listings.compose.price_invalid",
|
|
104
107
|
composeCurrencyLabel: "listings.compose.currency_label",
|
|
105
108
|
composeLocationLabel: "listings.compose.location_label",
|
|
106
|
-
|
|
107
|
-
composeLonLabel: "listings.compose.lon_label",
|
|
109
|
+
composeLocationHelp: "listings.compose.location_help",
|
|
108
110
|
composeGeoIncomplete: "listings.compose.geo_incomplete",
|
|
109
111
|
composePhotos: "listings.compose.photos",
|
|
110
112
|
composeTooManyImages: "listings.compose.too_many_images",
|
|
@@ -132,6 +134,10 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
132
134
|
composeBlockedNoDraft: "listings.compose.blocked.no_draft",
|
|
133
135
|
composeBlockedBusy: "listings.compose.blocked.busy",
|
|
134
136
|
composeBlockedMirror: "listings.compose.blocked.mirror",
|
|
137
|
+
/** The same refusal BEFORE anything is marked: "fix the highlighted fields"
|
|
138
|
+
* pointed at nothing highlighted, because the mirror only becomes visible
|
|
139
|
+
* once the person has tried to publish. */
|
|
140
|
+
composeBlockedIncomplete: "listings.compose.blocked.incomplete",
|
|
135
141
|
composeBlockedDetailsUnavailable: "listings.compose.blocked.details_unavailable",
|
|
136
142
|
|
|
137
143
|
// ── the owner's dashboard ────────────────────────────────────────────────
|
|
@@ -144,19 +150,32 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
144
150
|
mineEmpty: "listings.mine.empty",
|
|
145
151
|
mineRetry: "listings.mine.retry",
|
|
146
152
|
mineCountersFailed: "listings.mine.counters_failed",
|
|
147
|
-
/**
|
|
148
|
-
|
|
153
|
+
/** Per-tab empties: "no drafts" and "nothing sold yet" are different
|
|
154
|
+
* sentences, and one generic "nothing here" says neither. */
|
|
155
|
+
mineEmptyActive: "listings.mine.empty.active",
|
|
156
|
+
mineEmptyDrafts: "listings.mine.empty.drafts",
|
|
157
|
+
mineEmptyArchived: "listings.mine.empty.archived",
|
|
158
|
+
/** The takedowns — the rows no tab folds in (`my/counters` counts them in
|
|
159
|
+
* none), shown outside the tabs so they cannot be missed. */
|
|
160
|
+
mineBlockedTitle: "listings.mine.blocked.title",
|
|
161
|
+
mineBlockedLoadFailed: "listings.mine.blocked.load_failed",
|
|
149
162
|
mineLiveUnderReview: "listings.mine.live_under_review",
|
|
150
163
|
mineEdit: "listings.mine.edit",
|
|
151
164
|
mineArchive: "listings.mine.archive",
|
|
152
165
|
mineComplete: "listings.mine.complete",
|
|
153
166
|
mineDelete: "listings.mine.delete",
|
|
167
|
+
/** Deleting is irreversible, so it asks — through the shared SkinConfirm,
|
|
168
|
+
* which is a bottom sheet on a phone. */
|
|
169
|
+
mineDeleteConfirmTitle: "listings.mine.delete_confirm_title",
|
|
170
|
+
mineDeleteConfirmBody: "listings.mine.delete_confirm_body",
|
|
154
171
|
|
|
155
172
|
// ── favourites ───────────────────────────────────────────────────────────
|
|
156
173
|
favoritesTitle: "listings.favorites.title",
|
|
157
174
|
favoritesLoading: "listings.favorites.loading",
|
|
158
175
|
favoritesLoadFailed: "listings.favorites.load_failed",
|
|
159
176
|
favoritesEmpty: "listings.favorites.empty",
|
|
177
|
+
favoritesEmptyHint: "listings.favorites.empty_hint",
|
|
178
|
+
favoritesSignInHint: "listings.favorites.sign_in_hint",
|
|
160
179
|
|
|
161
180
|
// ── gates shared by every action a visitor cannot take ───────────────────
|
|
162
181
|
blockedSignIn: "listings.blocked.sign_in",
|
|
@@ -167,10 +186,15 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
167
186
|
blockedTransition: "listings.blocked.transition",
|
|
168
187
|
blockedDeleteActive: "listings.blocked.delete_active",
|
|
169
188
|
blockedInFlight: "listings.blocked.in_flight",
|
|
189
|
+
/** Editing is a SCREEN, not an endpoint: whether this app has one is the
|
|
190
|
+
* container's fact, and a host that wires no `onEdit` gets a switched-off
|
|
191
|
+
* button that says so instead of one that silently does nothing. */
|
|
192
|
+
blockedNoEditor: "listings.blocked.no_editor",
|
|
170
193
|
|
|
171
194
|
// ── keyset pagination ────────────────────────────────────────────────────
|
|
172
195
|
pagePrev: "listings.page.prev",
|
|
173
196
|
pageNext: "listings.page.next",
|
|
197
|
+
pageIndicator: "listings.page.indicator",
|
|
174
198
|
|
|
175
199
|
// ── nav labels ───────────────────────────────────────────────────────────
|
|
176
200
|
navDetail: "listings.nav.detail",
|
|
@@ -245,6 +269,8 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
245
269
|
"listings.detail.published_at": "Published {date}",
|
|
246
270
|
"listings.detail.expires_at": "Listed until {date}",
|
|
247
271
|
"listings.detail.stock": "In stock",
|
|
272
|
+
"listings.detail.edit": "Edit listing",
|
|
273
|
+
"listings.detail.take_down": "Take it down",
|
|
248
274
|
|
|
249
275
|
"listings.compose.new_title": "New listing",
|
|
250
276
|
"listings.compose.edit_title": "Edit listing",
|
|
@@ -263,8 +289,8 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
263
289
|
"Enter a price as a number, with at most two decimals",
|
|
264
290
|
"listings.compose.currency_label": "Currency",
|
|
265
291
|
"listings.compose.location_label": "Where it is",
|
|
266
|
-
"listings.compose.
|
|
267
|
-
|
|
292
|
+
"listings.compose.location_help":
|
|
293
|
+
"Buyers filter by distance, so a listing with no place is a listing they will not find",
|
|
268
294
|
"listings.compose.geo_incomplete":
|
|
269
295
|
"A latitude needs a longitude beside it — half a coordinate points nowhere",
|
|
270
296
|
"listings.compose.photos": "Photos",
|
|
@@ -293,14 +319,18 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
293
319
|
|
|
294
320
|
"listings.compose.blocked.no_category":
|
|
295
321
|
"Choose a category — the rest of the form depends on it",
|
|
322
|
+
// No `{types}`: the editor type is this build's vocabulary (`size_grid`),
|
|
323
|
+
// and a seller can do nothing with it. The fact they CAN act on is that
|
|
324
|
+
// this listing has to be finished somewhere else.
|
|
296
325
|
"listings.compose.blocked.unsupported_type":
|
|
297
|
-
"This category asks for a kind of detail this app cannot show yet
|
|
326
|
+
"This category asks for a kind of detail this app cannot show yet, so the listing cannot be completed here",
|
|
298
327
|
"listings.compose.blocked.photos_pending":
|
|
299
328
|
"Wait for the photos to finish uploading",
|
|
300
329
|
"listings.compose.blocked.no_draft": "The draft has not been created yet",
|
|
301
330
|
"listings.compose.blocked.busy": "One moment — the last change is still saving",
|
|
302
331
|
"listings.compose.blocked.mirror":
|
|
303
332
|
"Fix the highlighted fields first",
|
|
333
|
+
"listings.compose.blocked.incomplete": "{count} required details are still empty",
|
|
304
334
|
"listings.compose.blocked.details_unavailable":
|
|
305
335
|
"We could not load what this category asks for, so we cannot check the form",
|
|
306
336
|
|
|
@@ -313,19 +343,35 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
313
343
|
"listings.mine.empty": "Nothing here yet",
|
|
314
344
|
"listings.mine.retry": "Try again",
|
|
315
345
|
"listings.mine.counters_failed": "We could not count your listings",
|
|
316
|
-
"listings.mine.
|
|
317
|
-
|
|
346
|
+
"listings.mine.empty.active": "Nothing of yours is live or awaiting review",
|
|
347
|
+
"listings.mine.empty.drafts": "No drafts — anything you start appears here",
|
|
348
|
+
"listings.mine.empty.archived": "Nothing archived, paused, expired or sold yet",
|
|
349
|
+
"listings.mine.blocked.title":
|
|
350
|
+
"{count} of your listings were taken down by moderation",
|
|
351
|
+
"listings.mine.blocked.title.one":
|
|
352
|
+
"One of your listings was taken down by moderation",
|
|
353
|
+
"listings.mine.blocked.title.other":
|
|
354
|
+
"{count} of your listings were taken down by moderation",
|
|
355
|
+
"listings.mine.blocked.load_failed":
|
|
356
|
+
"We could not check whether any of your listings were taken down",
|
|
318
357
|
"listings.mine.live_under_review":
|
|
319
358
|
"Published, changes under review",
|
|
320
359
|
"listings.mine.edit": "Edit",
|
|
321
360
|
"listings.mine.archive": "Archive",
|
|
322
361
|
"listings.mine.complete": "Mark sold",
|
|
323
362
|
"listings.mine.delete": "Delete",
|
|
363
|
+
"listings.mine.delete_confirm_title": "Delete this listing?",
|
|
364
|
+
"listings.mine.delete_confirm_body":
|
|
365
|
+
"It disappears from your dashboard and cannot be brought back. Archiving keeps it.",
|
|
324
366
|
|
|
325
367
|
"listings.favorites.title": "Favourites",
|
|
326
368
|
"listings.favorites.loading": "Loading your favourites…",
|
|
327
369
|
"listings.favorites.load_failed": "We could not load your favourites",
|
|
328
370
|
"listings.favorites.empty": "You have not saved anything yet",
|
|
371
|
+
"listings.favorites.empty_hint":
|
|
372
|
+
"Tap the heart on any listing and it will be waiting here.",
|
|
373
|
+
"listings.favorites.sign_in_hint":
|
|
374
|
+
"Favourites are kept with your account, so they follow you between devices.",
|
|
329
375
|
|
|
330
376
|
"listings.blocked.sign_in": "Sign in to do this",
|
|
331
377
|
"listings.blocked.guest":
|
|
@@ -337,9 +383,12 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
337
383
|
"listings.blocked.delete_active":
|
|
338
384
|
"Archive it first — a listing that is on sale cannot be deleted",
|
|
339
385
|
"listings.blocked.in_flight": "One moment — that is already under way",
|
|
386
|
+
"listings.blocked.no_editor":
|
|
387
|
+
"This app has no screen for editing a listing yet",
|
|
340
388
|
|
|
341
389
|
"listings.page.prev": "Previous",
|
|
342
390
|
"listings.page.next": "Next",
|
|
391
|
+
"listings.page.indicator": "Page {page}",
|
|
343
392
|
|
|
344
393
|
"listings.nav.detail": "Listing",
|
|
345
394
|
"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";
|
|
@@ -37,12 +38,14 @@ export { listingsErrorBundleRu } from "./generated/errors.ru.gen.js";
|
|
|
37
38
|
export const listingsI18nBundleRu: I18nDictionary = {
|
|
38
39
|
...listingsErrorBundleRu,
|
|
39
40
|
|
|
40
|
-
// ── the
|
|
41
|
+
// ── the 10 stapel_listings-owned codes, pair-authored ────────────────────
|
|
41
42
|
"error.400.category_required": "Нужно выбрать категорию",
|
|
42
43
|
"error.400.image_required":
|
|
43
44
|
"Чтобы опубликовать объявление, добавьте хотя бы одно фото",
|
|
44
45
|
"error.400.listing_feature_not_allowed":
|
|
45
46
|
"Характеристика «{feature}» не относится к этой категории",
|
|
47
|
+
"error.400.listing_invalid_status_filter":
|
|
48
|
+
"Неизвестный статус объявления: «{status}»",
|
|
46
49
|
"error.400.publish_validation_failed":
|
|
47
50
|
"Объявление не прошло проверку и не опубликовано",
|
|
48
51
|
"error.403.listing_not_owner": "Это не ваше объявление",
|
|
@@ -108,6 +111,8 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
108
111
|
"listings.detail.published_at": "Опубликовано {date}",
|
|
109
112
|
"listings.detail.expires_at": "В продаже до {date}",
|
|
110
113
|
"listings.detail.stock": "В наличии",
|
|
114
|
+
"listings.detail.edit": "Редактировать объявление",
|
|
115
|
+
"listings.detail.take_down": "Снять с публикации",
|
|
111
116
|
|
|
112
117
|
"listings.compose.new_title": "Новое объявление",
|
|
113
118
|
"listings.compose.edit_title": "Редактирование объявления",
|
|
@@ -126,8 +131,8 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
126
131
|
"Введите цену числом, не более двух знаков после запятой",
|
|
127
132
|
"listings.compose.currency_label": "Валюта",
|
|
128
133
|
"listings.compose.location_label": "Где находится",
|
|
129
|
-
"listings.compose.
|
|
130
|
-
|
|
134
|
+
"listings.compose.location_help":
|
|
135
|
+
"Покупатели ищут по расстоянию — объявление без места они просто не найдут",
|
|
131
136
|
"listings.compose.geo_incomplete":
|
|
132
137
|
"Рядом с широтой нужна долгота — половина координаты никуда не указывает",
|
|
133
138
|
"listings.compose.photos": "Фотографии",
|
|
@@ -158,12 +163,13 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
158
163
|
"listings.compose.blocked.no_category":
|
|
159
164
|
"Выберите категорию — от неё зависит остальная форма",
|
|
160
165
|
"listings.compose.blocked.unsupported_type":
|
|
161
|
-
"Эта категория спрашивает характеристику, которую приложение пока не умеет
|
|
166
|
+
"Эта категория спрашивает характеристику, которую приложение пока не умеет показывать, поэтому закончить объявление здесь нельзя",
|
|
162
167
|
"listings.compose.blocked.photos_pending":
|
|
163
168
|
"Дождитесь окончания загрузки фотографий",
|
|
164
169
|
"listings.compose.blocked.no_draft": "Черновик ещё не создан",
|
|
165
170
|
"listings.compose.blocked.busy":
|
|
166
171
|
"Секунду — предыдущее изменение ещё сохраняется",
|
|
172
|
+
"listings.compose.blocked.incomplete": "Не заполнено обязательных деталей: {count}",
|
|
167
173
|
"listings.compose.blocked.mirror": "Сначала поправьте отмеченные поля",
|
|
168
174
|
"listings.compose.blocked.details_unavailable":
|
|
169
175
|
"Не удалось загрузить характеристики категории, поэтому проверить форму нельзя",
|
|
@@ -177,18 +183,34 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
177
183
|
"listings.mine.empty": "Здесь пока пусто",
|
|
178
184
|
"listings.mine.retry": "Повторить",
|
|
179
185
|
"listings.mine.counters_failed": "Не удалось посчитать ваши объявления",
|
|
180
|
-
"listings.mine.
|
|
181
|
-
|
|
186
|
+
"listings.mine.empty.active": "Ни одно ваше объявление не опубликовано и не ждёт проверки",
|
|
187
|
+
"listings.mine.empty.drafts": "Черновиков нет — всё начатое появится здесь",
|
|
188
|
+
"listings.mine.empty.archived": "Пока ничего не в архиве, не снято, не истекло и не продано",
|
|
189
|
+
"listings.mine.blocked.title":
|
|
190
|
+
"Модерация сняла ваших объявлений: {count}",
|
|
191
|
+
"listings.mine.blocked.title.one": "Модерация сняла одно ваше объявление",
|
|
192
|
+
"listings.mine.blocked.title.few": "Модерация сняла {count} ваших объявления",
|
|
193
|
+
"listings.mine.blocked.title.many": "Модерация сняла {count} ваших объявлений",
|
|
194
|
+
"listings.mine.blocked.title.other": "Модерация сняла ваших объявлений: {count}",
|
|
195
|
+
"listings.mine.blocked.load_failed":
|
|
196
|
+
"Не удалось проверить, снимала ли модерация ваши объявления",
|
|
182
197
|
"listings.mine.live_under_review": "Опубликовано, правки на проверке",
|
|
183
198
|
"listings.mine.edit": "Редактировать",
|
|
184
199
|
"listings.mine.archive": "В архив",
|
|
185
200
|
"listings.mine.complete": "Отметить проданным",
|
|
186
201
|
"listings.mine.delete": "Удалить",
|
|
202
|
+
"listings.mine.delete_confirm_title": "Удалить объявление?",
|
|
203
|
+
"listings.mine.delete_confirm_body":
|
|
204
|
+
"Оно исчезнет из кабинета, и вернуть его будет нельзя. Архив сохраняет его.",
|
|
187
205
|
|
|
188
206
|
"listings.favorites.title": "Избранное",
|
|
189
207
|
"listings.favorites.loading": "Загружаем избранное…",
|
|
190
208
|
"listings.favorites.load_failed": "Не удалось загрузить избранное",
|
|
191
209
|
"listings.favorites.empty": "Вы ещё ничего не сохранили",
|
|
210
|
+
"listings.favorites.empty_hint":
|
|
211
|
+
"Нажмите на сердечко в любом объявлении — оно будет ждать здесь.",
|
|
212
|
+
"listings.favorites.sign_in_hint":
|
|
213
|
+
"Избранное хранится в аккаунте, поэтому переходит с вами на другие устройства.",
|
|
192
214
|
|
|
193
215
|
"listings.blocked.sign_in": "Войдите, чтобы сделать это",
|
|
194
216
|
"listings.blocked.guest":
|
|
@@ -200,9 +222,12 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
200
222
|
"listings.blocked.delete_active":
|
|
201
223
|
"Сначала уберите в архив — объявление в продаже удалить нельзя",
|
|
202
224
|
"listings.blocked.in_flight": "Секунду — это уже выполняется",
|
|
225
|
+
"listings.blocked.no_editor":
|
|
226
|
+
"В этом приложении пока нет экрана редактирования объявления",
|
|
203
227
|
|
|
204
228
|
"listings.page.prev": "Назад",
|
|
205
229
|
"listings.page.next": "Дальше",
|
|
230
|
+
"listings.page.indicator": "Страница {page}",
|
|
206
231
|
|
|
207
232
|
"listings.nav.detail": "Объявление",
|
|
208
233
|
"listings.nav.compose": "Подать объявление",
|
|
@@ -210,7 +235,23 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
210
235
|
"listings.nav.favorites": "Избранное",
|
|
211
236
|
};
|
|
212
237
|
|
|
213
|
-
/**
|
|
238
|
+
/**
|
|
239
|
+
* Register the Russian bundle into a core i18n engine — AND the twelve
|
|
240
|
+
* `stapel_attributes` sentences this pair deliberately does not author.
|
|
241
|
+
*
|
|
242
|
+
* The split of ownership is right and stays: two packages must not give one
|
|
243
|
+
* refusal two sentences. What was wrong was leaving the JOIN to a README. A
|
|
244
|
+
* host that registered only this bundle got twelve of the composer's most
|
|
245
|
+
* common refusals ("this value is below the minimum", "the description is too
|
|
246
|
+
* long") in English on a Russian page, and nothing failed anywhere to say so.
|
|
247
|
+
*
|
|
248
|
+
* `@stapel/attributes-react` is a peer of this pair and its editors are what
|
|
249
|
+
* RAISE those twelve, so a listings-ru host is an attributes-ru host by
|
|
250
|
+
* construction; chaining the registration states that instead of asking. It is
|
|
251
|
+
* idempotent — a host that also registers the bundle itself simply writes the
|
|
252
|
+
* same keys twice.
|
|
253
|
+
*/
|
|
214
254
|
export function registerListingsI18nRu(i18n: I18nEngine): void {
|
|
255
|
+
registerAttributesI18nRu(i18n);
|
|
215
256
|
i18n.registerBundle("ru", listingsI18nBundleRu);
|
|
216
257
|
}
|
package/src/index.ts
CHANGED
|
@@ -38,9 +38,10 @@
|
|
|
38
38
|
* a grey button and never a hidden one.
|
|
39
39
|
* 4. **An absence is never rendered as a zero.** A soft-deleted listing says
|
|
40
40
|
* it was removed rather than 404-ing like a typo; a stored feature this
|
|
41
|
-
* build cannot read is counted rather than dropped;
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
* build cannot read is counted rather than dropped; an empty dashboard tab
|
|
42
|
+
* says WHICH emptiness it is; and a moderation takedown — the one status
|
|
43
|
+
* `my/counters` counts in no tab at all — is shown outside the tabs rather
|
|
44
|
+
* than falling out of the screen (`headless/MyListings.tsx`).
|
|
44
45
|
*
|
|
45
46
|
* ── The seams, and why they are not imports ────────────────────────────────
|
|
46
47
|
*
|
|
@@ -88,7 +89,10 @@ export type {
|
|
|
88
89
|
ListingPageParams,
|
|
89
90
|
ListingStatusInfo,
|
|
90
91
|
MyCounters,
|
|
92
|
+
MyListingCard,
|
|
93
|
+
MyListingsParams,
|
|
91
94
|
PaginatedListingCards,
|
|
95
|
+
PaginatedMyListingCards,
|
|
92
96
|
PublishResponse,
|
|
93
97
|
Schemas,
|
|
94
98
|
} from "./api/types.js";
|
|
@@ -97,6 +101,7 @@ export type {
|
|
|
97
101
|
export {
|
|
98
102
|
MY_LISTINGS_TABS,
|
|
99
103
|
MY_LISTINGS_TAB_STATUSES,
|
|
104
|
+
MY_LISTINGS_UNTABBED_STATUSES,
|
|
100
105
|
isPubliclyVisible,
|
|
101
106
|
lifecycleCaption,
|
|
102
107
|
listingStatusView,
|
|
@@ -112,9 +117,15 @@ export type {
|
|
|
112
117
|
} from "./model/status.js";
|
|
113
118
|
export { LISTING_TRANSITIONS, canDelete, canTransition } from "./model/transitions.js";
|
|
114
119
|
|
|
115
|
-
// ── model: the owner
|
|
116
|
-
export {
|
|
120
|
+
// ── model: the owner's own rows ──────────────────────────────────────────────
|
|
121
|
+
export { defaultMyListingsSource } from "./model/mineSource.js";
|
|
117
122
|
export type { MyListingsSource } from "./model/mineSource.js";
|
|
123
|
+
export {
|
|
124
|
+
myListingImages,
|
|
125
|
+
myListingPrice,
|
|
126
|
+
myListingTitle,
|
|
127
|
+
showsDraft,
|
|
128
|
+
} from "./model/mine.js";
|
|
118
129
|
|
|
119
130
|
// ── model: the draft twin ────────────────────────────────────────────────────
|
|
120
131
|
export {
|
package/src/model/draft.ts
CHANGED
|
@@ -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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
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),
|
package/src/model/features.ts
CHANGED
|
@@ -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`. `
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* `
|
|
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,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an owner's row SAYS, when half its fields may still be a draft.
|
|
3
|
+
*
|
|
4
|
+
* A listing carries every user-editable value twice: the `*_draft` twin the
|
|
5
|
+
* owner is writing, and the published field `publish` promotes it onto. The
|
|
6
|
+
* public card reads the published half — correct for a shop window, and empty
|
|
7
|
+
* for a listing that has never been published. A drafts tab built on it is a
|
|
8
|
+
* column of blank rows.
|
|
9
|
+
*
|
|
10
|
+
* So the rule, in one place rather than at three call sites: **show the
|
|
11
|
+
* published value when there is one, and the draft otherwise.** Not "always
|
|
12
|
+
* the draft" — a live listing being edited must go on reading as what
|
|
13
|
+
* strangers currently see, or the seller cannot tell what is on the shelf
|
|
14
|
+
* from what they are about to put there.
|
|
15
|
+
*
|
|
16
|
+
* Pure, so `test/mine.test.tsx` asserts the table directly.
|
|
17
|
+
*/
|
|
18
|
+
import type { MyListingCard } from "../api/types.js";
|
|
19
|
+
|
|
20
|
+
/** Is this string field's published half absent? `""` counts: the model
|
|
21
|
+
* declares `title = CharField(blank=True, default="")`, so an unpublished
|
|
22
|
+
* listing carries the empty string, not `null`. */
|
|
23
|
+
function empty(value: string | null | undefined): boolean {
|
|
24
|
+
return value === null || value === undefined || value === "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The row's heading: the published title, the draft title, or `undefined`.
|
|
29
|
+
*
|
|
30
|
+
* `undefined` and not the id: a caller that wants "Listing #41" as a last
|
|
31
|
+
* resort can say so, and one that wants to render a placeholder can do that
|
|
32
|
+
* instead. Inventing the fallback here would take the choice away.
|
|
33
|
+
*/
|
|
34
|
+
export function myListingTitle(row: MyListingCard): string | undefined {
|
|
35
|
+
if (!empty(row.title)) return row.title as string;
|
|
36
|
+
if (!empty(row.title_draft)) return row.title_draft as string;
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The row's price, published half first. A decimal STRING on the wire
|
|
42
|
+
* (`DecimalField` → `"200.00"`), passed through untouched — formatting it is
|
|
43
|
+
* a skin's job and rounding it here would lose the trailing zero a currency
|
|
44
|
+
* needs.
|
|
45
|
+
*/
|
|
46
|
+
export function myListingPrice(row: MyListingCard): string | undefined {
|
|
47
|
+
if (!empty(row.price)) return row.price as string;
|
|
48
|
+
if (!empty(row.price_draft)) return row.price_draft as string;
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The row's images, published half first — `[]` when neither has any. */
|
|
53
|
+
export function myListingImages(row: MyListingCard): readonly string[] {
|
|
54
|
+
const published = row.images;
|
|
55
|
+
if (published !== null && published !== undefined && published.length > 0) {
|
|
56
|
+
return published;
|
|
57
|
+
}
|
|
58
|
+
return row.images_draft ?? [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Is what this row shows the DRAFT rather than what strangers see?
|
|
63
|
+
*
|
|
64
|
+
* The half of the rule a person is owed on screen: a row whose title came off
|
|
65
|
+
* the draft twin is showing something nobody else can read yet, and a skin
|
|
66
|
+
* that did not mark it would be quietly claiming otherwise.
|
|
67
|
+
*/
|
|
68
|
+
export function showsDraft(row: MyListingCard): boolean {
|
|
69
|
+
return empty(row.title) && !empty(row.title_draft);
|
|
70
|
+
}
|