@stapel/listings-react 0.10.0 → 0.12.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 (80) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/dist/api/types.d.ts +10 -0
  3. package/dist/api/types.d.ts.map +1 -1
  4. package/dist/api/types.js.map +1 -1
  5. package/dist/default/ListingCard.d.ts +2 -2
  6. package/dist/default/ListingCard.d.ts.map +1 -1
  7. package/dist/default/ListingCard.js +8 -2
  8. package/dist/default/ListingCard.js.map +1 -1
  9. package/dist/default/ListingComposerPage.d.ts +22 -62
  10. package/dist/default/ListingComposerPage.d.ts.map +1 -1
  11. package/dist/default/ListingComposerPage.js +228 -56
  12. package/dist/default/ListingComposerPage.js.map +1 -1
  13. package/dist/default/ListingDetailPane.d.ts +2 -2
  14. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  15. package/dist/default/ListingDetailPane.js +6 -1
  16. package/dist/default/ListingDetailPane.js.map +1 -1
  17. package/dist/default/ListingSerpCard.d.ts +2 -2
  18. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  19. package/dist/default/ListingSerpCard.js +7 -2
  20. package/dist/default/ListingSerpCard.js.map +1 -1
  21. package/dist/default/index.d.ts +2 -2
  22. package/dist/default/index.d.ts.map +1 -1
  23. package/dist/default/index.js +1 -1
  24. package/dist/default/index.js.map +1 -1
  25. package/dist/default/types.d.ts +21 -0
  26. package/dist/default/types.d.ts.map +1 -1
  27. package/dist/headless/ListingComposer.d.ts +11 -0
  28. package/dist/headless/ListingComposer.d.ts.map +1 -1
  29. package/dist/headless/ListingComposer.js +31 -6
  30. package/dist/headless/ListingComposer.js.map +1 -1
  31. package/dist/headless/ListingDetail.d.ts +16 -1
  32. package/dist/headless/ListingDetail.d.ts.map +1 -1
  33. package/dist/headless/ListingDetail.js +13 -4
  34. package/dist/headless/ListingDetail.js.map +1 -1
  35. package/dist/i18n/es.d.ts.map +1 -1
  36. package/dist/i18n/es.js +4 -0
  37. package/dist/i18n/es.js.map +1 -1
  38. package/dist/i18n/keys.d.ts +10 -0
  39. package/dist/i18n/keys.d.ts.map +1 -1
  40. package/dist/i18n/keys.js +16 -0
  41. package/dist/i18n/keys.js.map +1 -1
  42. package/dist/i18n/ru.d.ts.map +1 -1
  43. package/dist/i18n/ru.js +4 -0
  44. package/dist/i18n/ru.js.map +1 -1
  45. package/dist/index.d.ts +2 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +1 -1
  48. package/dist/index.js.map +1 -1
  49. package/dist/model/features.d.ts +90 -20
  50. package/dist/model/features.d.ts.map +1 -1
  51. package/dist/model/features.js +148 -17
  52. package/dist/model/features.js.map +1 -1
  53. package/dist/model/validation.d.ts +32 -0
  54. package/dist/model/validation.d.ts.map +1 -1
  55. package/dist/model/validation.js +82 -1
  56. package/dist/model/validation.js.map +1 -1
  57. package/dist/nav/manifest.d.ts.map +1 -1
  58. package/dist/nav/manifest.js +2 -0
  59. package/dist/nav/manifest.js.map +1 -1
  60. package/llms.txt +1 -1
  61. package/manifest.json +8 -1
  62. package/nav-manifest.json +3 -1
  63. package/package.json +7 -7
  64. package/src/analytics/generated/events.json +1 -1
  65. package/src/api/types.ts +10 -0
  66. package/src/default/ListingCard.tsx +17 -4
  67. package/src/default/ListingComposerPage.tsx +221 -36
  68. package/src/default/ListingDetailPane.tsx +10 -6
  69. package/src/default/ListingSerpCard.tsx +13 -4
  70. package/src/default/index.ts +7 -2
  71. package/src/default/types.ts +22 -0
  72. package/src/headless/ListingComposer.tsx +47 -6
  73. package/src/headless/ListingDetail.tsx +37 -10
  74. package/src/i18n/es.ts +5 -0
  75. package/src/i18n/keys.ts +17 -0
  76. package/src/i18n/ru.ts +5 -0
  77. package/src/index.ts +3 -0
  78. package/src/model/features.ts +253 -35
  79. package/src/model/validation.ts +84 -1
  80. package/src/nav/manifest.ts +2 -0
@@ -59,7 +59,7 @@ import { HeartIcon } from "./icons.js";
59
59
  import { ListingPhoto } from "./ListingPhoto.js";
60
60
  import { ListingPrice } from "./ListingPrice.js";
61
61
  import { ListingStatusBlock } from "./StatusTags.js";
62
- import type { ThemeModeProp } from "./types.js";
62
+ import type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
63
63
 
64
64
  /** The reading measure of the page body. A detail page is prose plus a spec
65
65
  * table; past this it stops being one column and starts being a stripe across
@@ -73,7 +73,9 @@ export const DETAIL_MEASURE = "60rem";
73
73
  * postage stamp on the other. */
74
74
  export const DETAIL_PHOTO_MIN = "14rem";
75
75
 
76
- export interface ListingDetailPaneProps extends ThemeModeProp {
76
+ export interface ListingDetailPaneProps
77
+ extends ThemeModeProp,
78
+ CategoryFeaturesProp {
77
79
  readonly id: number;
78
80
  /** The reader's own uuid, when the host knows it. Enables the owner view —
79
81
  * the only place the moderation axis is shown, because it is the only
@@ -98,10 +100,12 @@ export interface ListingDetailPaneProps extends ThemeModeProp {
98
100
  export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
99
101
  const t = useT();
100
102
  const { locale } = useI18n();
101
- const bag = useListingDetail(
102
- props.id,
103
- props.viewerId !== undefined ? { viewerId: props.viewerId } : {}
104
- );
103
+ const bag = useListingDetail(props.id, {
104
+ ...(props.viewerId !== undefined ? { viewerId: props.viewerId } : {}),
105
+ ...(props.categoryFeatures !== undefined
106
+ ? { categoryFeatures: props.categoryFeatures }
107
+ : {}),
108
+ });
105
109
  const owner = bag.viewerIsOwner === true;
106
110
  const actions = useListingActions(props.id, bag.status?.lifecycle.status);
107
111
  const editGate = actions.editGate(props.onEdit !== undefined);
@@ -74,6 +74,7 @@ import {
74
74
  featuresDtoFromDaoList,
75
75
  featuresFromDaoList,
76
76
  } from "../model/features.js";
77
+ import type { FeatureCopySource } from "../model/features.js";
77
78
  import { lifecycleCaption } from "../model/status.js";
78
79
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
79
80
  import { PriceTrendIcon } from "./icons.js";
@@ -86,7 +87,7 @@ import {
86
87
  import type { ListingCardOpenProps } from "./ListingCard.js";
87
88
  import { LISTING_PHOTO_ASPECT, ListingPhoto } from "./ListingPhoto.js";
88
89
  import { ListingPrice } from "./ListingPrice.js";
89
- import type { ThemeModeProp } from "./types.js";
90
+ import type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
90
91
 
91
92
  /**
92
93
  * A price that moved, as the ref draws it: the old figure struck through and
@@ -103,7 +104,9 @@ export interface ListingPriceTrend {
103
104
  readonly direction: "down" | "up";
104
105
  }
105
106
 
106
- export interface ListingSerpCardBaseProps extends ThemeModeProp {
107
+ export interface ListingSerpCardBaseProps
108
+ extends ThemeModeProp,
109
+ CategoryFeaturesProp {
107
110
  readonly listing: ListingCardData;
108
111
  /**
109
112
  * The seller's own one-line summary — "Petrol 1.5 (147 hp), robot, front".
@@ -149,7 +152,13 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
149
152
  const { token } = antdTheme.useToken();
150
153
 
151
154
  const badgeDaos = asFeatureDaoList(listing.features_badges);
152
- const badgeFeatures = featuresFromDaoList(badgeDaos);
155
+ // See `CategoryFeaturesProp`: the option table a stored `select` does not
156
+ // carry, when this surface knows which category it is drawing.
157
+ const copy: FeatureCopySource =
158
+ props.categoryFeatures !== undefined
159
+ ? { categoryFeatures: props.categoryFeatures }
160
+ : {};
161
+ const badgeFeatures = featuresFromDaoList(badgeDaos, copy);
153
162
  const badgeValues = featuresDtoFromDaoList(badgeDaos);
154
163
  // The fallback spec line when the host derived none: the row's own
155
164
  // `features_title` projection, exactly the line `ListingCard` draws.
@@ -315,7 +324,7 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
315
324
  data-testid="listings-serp-specs"
316
325
  >
317
326
  <FeatureBadges
318
- features={featuresFromDaoList(titleDaos).map(
327
+ features={featuresFromDaoList(titleDaos, copy).map(
319
328
  (view) => view.feature,
320
329
  )}
321
330
  values={featuresDtoFromDaoList(titleDaos)}
@@ -55,7 +55,12 @@ export { FeedGrid, FEED_GRID_COLUMNS } from "./FeedGrid.js";
55
55
  export type { FeedGridProps } from "./FeedGrid.js";
56
56
  export { ListingDetailPane, DETAIL_MEASURE, DETAIL_PHOTO_MIN } from "./ListingDetailPane.js";
57
57
  export type { ListingDetailPaneProps } from "./ListingDetailPane.js";
58
- export { ListingComposerPage, COMPOSER_MEASURE } from "./ListingComposerPage.js";
58
+ export {
59
+ ListingComposerPage,
60
+ COMPOSER_MEASURE,
61
+ COMPOSER_STACKED_BELOW,
62
+ composerFieldId,
63
+ } from "./ListingComposerPage.js";
59
64
  export type {
60
65
  ListingComposerPageProps,
61
66
  ComposerCategorySlot,
@@ -78,4 +83,4 @@ export { ListingPhoto, LISTING_PHOTO_ASPECT } from "./ListingPhoto.js";
78
83
  export type { ListingPhotoProps } from "./ListingPhoto.js";
79
84
  export { SignInLink } from "./SignInLink.js";
80
85
  export type { SignInLinkProps } from "./SignInLink.js";
81
- export type { ThemeModeProp } from "./types.js";
86
+ export type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
@@ -4,9 +4,31 @@
4
4
  */
5
5
  export type { FlowError } from "@stapel/core";
6
6
  import type { ThemeMode } from "@stapel/tokens-antd";
7
+ import type { FeatureDef } from "@stapel/attributes-react";
7
8
 
8
9
  /** Every `/default` surface accepts a theme mode; absent means "whatever the
9
10
  * host document declares" (`resolveThemeMode()`), never a hardcoded side. */
10
11
  export interface ThemeModeProp {
11
12
  readonly mode?: ThemeMode;
12
13
  }
14
+
15
+ /**
16
+ * Every surface that DISPLAYS stored feature values takes this, and it is
17
+ * optional on all of them.
18
+ *
19
+ * A stored `select` carries its chosen values and no option table (the table
20
+ * lives on the category), so a row written before the label snapshot existed
21
+ * prints its storage slug — "b-u" where the catalogue holds the copy. A
22
+ * surface that knows which category it is drawing can hand the category's own
23
+ * defs over and the copy is repaired; a mixed grid of forty categories knows
24
+ * no such thing, passes nothing, and renders exactly what it renders today.
25
+ *
26
+ * The precedence between these defs and what the row itself stored is
27
+ * `model/features.ts`' business — see {@link FeatureCopySource}.
28
+ */
29
+ export interface CategoryFeaturesProp {
30
+ /** The category's features, as
31
+ * `GET /categories/api/v1/categories/{id}/features/` answers — the same
32
+ * array `<ListingComposerPage>` takes. */
33
+ readonly categoryFeatures?: readonly FeatureDef[];
34
+ }
@@ -39,7 +39,12 @@ import {
39
39
  import type { ListingDraftValues, ListingLocation } from "../model/draft.js";
40
40
  import { asFeatureDaoList } from "../model/features.js";
41
41
  import { featuresDtoFromDaoList } from "../model/features.js";
42
- import { mirrorDraft, publishRefusal } from "../model/validation.js";
42
+ import {
43
+ LISTING_FIELD_ORDER,
44
+ envelopeFieldErrors,
45
+ mirrorDraft,
46
+ publishRefusal,
47
+ } from "../model/validation.js";
43
48
  import type { PublishRefusal } from "../model/validation.js";
44
49
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
45
50
  import { useMandateGate } from "./useMandateGate.js";
@@ -197,6 +202,17 @@ export interface ListingComposerBag {
197
202
  readonly fieldErrors: Readonly<Record<string, FlowError>>;
198
203
  /** The server's last publish refusal, unrouted, for a summary line. */
199
204
  readonly refusal: PublishRefusal | undefined;
205
+ /**
206
+ * The first field the mirror is refusing, in the order the form asks for it
207
+ * (`LISTING_FIELD_ORDER`, then the category's features in schema order), or
208
+ * `undefined` when nothing is refused.
209
+ *
210
+ * The publish gate can say "10 required details are still empty" while not
211
+ * one of them is on screen — on a phone the attribute region starts nearly
212
+ * two viewports below the fold — and a count with nowhere to go is a dead
213
+ * end. This is what a skin's "take me there" control aims at.
214
+ */
215
+ readonly firstUnsatisfied: string | undefined;
200
216
  /** Value types this build cannot draw — the fact, from attributes-react. */
201
217
  readonly unsupported: readonly string[];
202
218
 
@@ -358,12 +374,36 @@ export function useListingComposer(
358
374
  (existing.data.title ?? "").length === 0 &&
359
375
  (existing.data.description ?? "").length === 0;
360
376
 
377
+ // A save-draft/create 400 names its field in the envelope rather than in a
378
+ // batch, so it takes the other door onto the same routing table. Without it
379
+ // the composer painted a banner and left every control clean — the person
380
+ // was told something was wrong and not what (blocker C2).
381
+ const saveThrown: unknown = saveDraft.error ?? createDraft.error;
361
382
  const fieldErrors: Readonly<Record<string, FlowError>> = useMemo(() => {
362
383
  const shown = showErrors ? mirror : {};
363
- return refusal?.kind === "invalid_draft"
364
- ? { ...shown, ...refusal.fieldErrors }
365
- : shown;
366
- }, [showErrors, mirror, refusal]);
384
+ const routed =
385
+ refusal?.kind === "invalid_draft"
386
+ ? refusal.fieldErrors
387
+ : refusal?.kind === "error"
388
+ ? envelopeFieldErrors(refusal.error)
389
+ : {};
390
+ return { ...shown, ...envelopeFieldErrors(saveThrown), ...routed };
391
+ }, [showErrors, mirror, refusal, saveThrown]);
392
+
393
+ // The order is the FORM's, not the record's — see `LISTING_FIELD_ORDER`.
394
+ // A key in neither list (a server field with no control of its own) is still
395
+ // named rather than dropped: something is refusing, and saying which is
396
+ // better than saying nothing.
397
+ const firstUnsatisfied: string | undefined = useMemo(() => {
398
+ const refused = mirror;
399
+ for (const field of LISTING_FIELD_ORDER) {
400
+ if (refused[field] !== undefined) return field;
401
+ }
402
+ for (const feature of options.features) {
403
+ if (refused[feature.slug] !== undefined) return feature.slug;
404
+ }
405
+ return Object.keys(refused)[0];
406
+ }, [mirror, options.features]);
367
407
 
368
408
  const listingState: LoadState<ListingDetailData> | undefined =
369
409
  options.listingId === undefined
@@ -558,6 +598,7 @@ export function useListingComposer(
558
598
  mirror,
559
599
  fieldErrors,
560
600
  refusal,
601
+ firstUnsatisfied,
561
602
  unsupported,
562
603
 
563
604
  saveGate,
@@ -568,7 +609,7 @@ export function useListingComposer(
568
609
  saving: busy,
569
610
  publishing: publishListing.isPending,
570
611
  saved,
571
- saveError: saveDraft.error ?? createDraft.error,
612
+ saveError: saveThrown,
572
613
  outcome,
573
614
  };
574
615
  }
@@ -11,6 +11,7 @@ import {
11
11
  } from "@stapel/core";
12
12
  import type { ActionAvailability, LoadState } from "@stapel/core";
13
13
  import type {
14
+ FeatureDef,
14
15
  ListingDetail as ListingDetailData,
15
16
  ListingFeatureView,
16
17
  ListingStatusInfo,
@@ -18,6 +19,7 @@ import type {
18
19
  import { useListing, useListingStatus } from "../model/queries.js";
19
20
  import { useFavoriteListing } from "../model/mutations.js";
20
21
  import { asFeatureDaoList, featuresFromDaoList, unreadableFeatureCount } from "../model/features.js";
22
+ import type { FeatureCopySource } from "../model/features.js";
21
23
  import { listingStatusView } from "../model/status.js";
22
24
  import type { ListingStatusView } from "../model/status.js";
23
25
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
@@ -104,6 +106,19 @@ export interface UseListingDetailOptions {
104
106
  /** The reader's own id, when the host knows it — enables `viewerIsOwner`.
105
107
  * A uuid string, matching `ListingDetail.owner` / `ListingStatus.owner_id`. */
106
108
  readonly viewerId?: string;
109
+ /**
110
+ * The listing's category features, when the container has them — the same
111
+ * `readonly FeatureDef[]` the composer takes, from
112
+ * `@stapel/categories-react`'s `useCategoryFeatures`.
113
+ *
114
+ * A stored `select` carries its chosen VALUES and no option table, so a row
115
+ * written before labels were snapshotted prints its storage slug (`b-u`)
116
+ * where the category holds the copy. Handing the category's defs in repairs
117
+ * that; handing nothing in leaves every rendered value exactly as it is
118
+ * today. Which definition wins over which is `model/features.ts`' business —
119
+ * this is only the wire it travels on.
120
+ */
121
+ readonly categoryFeatures?: readonly FeatureDef[];
107
122
  }
108
123
 
109
124
  export function useListingDetail(
@@ -166,17 +181,25 @@ export function useListingDetail(
166
181
  ? undefined
167
182
  : owner === options.viewerId;
168
183
 
184
+ // One object, rebuilt only when the defs themselves change, so the three
185
+ // projections below keep their memo across renders that touched neither.
186
+ const categoryFeatures = options.categoryFeatures;
187
+ const copy: FeatureCopySource = useMemo(
188
+ () => (categoryFeatures !== undefined ? { categoryFeatures } : {}),
189
+ [categoryFeatures]
190
+ );
191
+
169
192
  const features = useMemo(
170
- () => featuresFromDaoList(asFeatureDaoList(detail.data?.features)),
171
- [detail.data]
193
+ () => featuresFromDaoList(asFeatureDaoList(detail.data?.features), copy),
194
+ [detail.data, copy]
172
195
  );
173
196
  const titleFeatures = useMemo(
174
- () => featuresFromDaoList(asFeatureDaoList(detail.data?.features_title)),
175
- [detail.data]
197
+ () => featuresFromDaoList(asFeatureDaoList(detail.data?.features_title), copy),
198
+ [detail.data, copy]
176
199
  );
177
200
  const badgeFeatures = useMemo(
178
- () => featuresFromDaoList(asFeatureDaoList(detail.data?.features_badges)),
179
- [detail.data]
201
+ () => featuresFromDaoList(asFeatureDaoList(detail.data?.features_badges), copy),
202
+ [detail.data, copy]
180
203
  );
181
204
 
182
205
  const isFavorited = detail.data?.is_favorited ?? undefined;
@@ -228,11 +251,15 @@ export function useListingDetail(
228
251
  export function ListingDetail(props: {
229
252
  id: number;
230
253
  viewerId?: string;
254
+ /** See {@link UseListingDetailOptions.categoryFeatures}. */
255
+ categoryFeatures?: readonly FeatureDef[];
231
256
  children: (bag: ListingDetailBag) => ReactNode;
232
257
  }): ReactElement {
233
- const bag = useListingDetail(
234
- props.id,
235
- props.viewerId !== undefined ? { viewerId: props.viewerId } : {}
236
- );
258
+ const bag = useListingDetail(props.id, {
259
+ ...(props.viewerId !== undefined ? { viewerId: props.viewerId } : {}),
260
+ ...(props.categoryFeatures !== undefined
261
+ ? { categoryFeatures: props.categoryFeatures }
262
+ : {}),
263
+ });
237
264
  return <>{props.children(bag)}</>;
238
265
  }
package/src/i18n/es.ts CHANGED
@@ -130,6 +130,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
130
130
  "Un anuncio admite como mucho {max} fotos",
131
131
  "listings.compose.details": "Detalles",
132
132
  "listings.compose.details_loading": "Cargando lo que pide esta categoría…",
133
+ "listings.compose.details_no_category":
134
+ "Elige primero una categoría: aquí aparecerá lo que pide.",
133
135
  "listings.compose.details_failed":
134
136
  "No pudimos cargar lo que pide esta categoría",
135
137
  "listings.compose.details_empty": "Esta categoría no pide más detalles",
@@ -159,6 +161,7 @@ export const listingsI18nBundleEs: I18nDictionary = {
159
161
  "listings.compose.blocked.busy":
160
162
  "Un momento — el último cambio se está guardando",
161
163
  "listings.compose.blocked.incomplete": "Faltan {count} datos obligatorios",
164
+ "listings.compose.show_first_missing": "Ir al primer campo sin completar",
162
165
  "listings.compose.blocked.mirror": "Corrige primero los campos marcados",
163
166
  "listings.compose.blocked.details_unavailable":
164
167
  "No pudimos cargar lo que pide esta categoría, así que no podemos revisar el formulario",
@@ -218,7 +221,9 @@ export const listingsI18nBundleEs: I18nDictionary = {
218
221
 
219
222
  "listings.nav.detail": "Anuncio",
220
223
  "listings.nav.compose": "Poner un anuncio",
224
+ "listings.nav.compose.short": "Poner",
221
225
  "listings.nav.mine": "Mis anuncios",
226
+ "listings.nav.mine.short": "Míos",
222
227
  "listings.nav.favorites": "Favoritos",
223
228
  };
224
229
 
package/src/i18n/keys.ts CHANGED
@@ -133,6 +133,10 @@ export const LISTINGS_I18N_KEYS = {
133
133
  composeTooManyImages: "listings.compose.too_many_images",
134
134
  composeDetails: "listings.compose.details",
135
135
  composeDetailsLoading: "listings.compose.details_loading",
136
+ /** No category has been chosen yet, so nothing is in flight and nothing will
137
+ * be until one is. The section used to print the LOADING sentence in this
138
+ * state — a spinner-shaped claim about a request that was never made. */
139
+ composeDetailsNoCategory: "listings.compose.details_no_category",
136
140
  composeDetailsFailed: "listings.compose.details_failed",
137
141
  composeDetailsEmpty: "listings.compose.details_empty",
138
142
  composeCountable: "listings.compose.countable",
@@ -159,6 +163,10 @@ export const LISTINGS_I18N_KEYS = {
159
163
  * pointed at nothing highlighted, because the mirror only becomes visible
160
164
  * once the person has tried to publish. */
161
165
  composeBlockedIncomplete: "listings.compose.blocked.incomplete",
166
+ /** The way from the blocked-submit COUNT to the first field it is counting.
167
+ * The count is printed at the foot of a 7000px form whose required fields
168
+ * are two viewports up; a number nobody can act on is not help. */
169
+ composeShowFirstMissing: "listings.compose.show_first_missing",
162
170
  composeBlockedDetailsUnavailable: "listings.compose.blocked.details_unavailable",
163
171
 
164
172
  // ── the owner's dashboard ────────────────────────────────────────────────
@@ -220,7 +228,11 @@ export const LISTINGS_I18N_KEYS = {
220
228
  // ── nav labels ───────────────────────────────────────────────────────────
221
229
  navDetail: "listings.nav.detail",
222
230
  navCompose: "listings.nav.compose",
231
+ // The compact wording a phone dock uses — `NavEntry.shortLabelKey`. The
232
+ // verb, not the sentence: a dock cell is ten characters wide.
233
+ navComposeShort: "listings.nav.compose.short",
223
234
  navMine: "listings.nav.mine",
235
+ navMineShort: "listings.nav.mine.short",
224
236
  navFavorites: "listings.nav.favorites",
225
237
  } as const;
226
238
 
@@ -323,6 +335,8 @@ export const listingsI18nBundleEn: Record<string, string> = {
323
335
  "A listing can carry at most {max} photos",
324
336
  "listings.compose.details": "Details",
325
337
  "listings.compose.details_loading": "Loading what this category asks for…",
338
+ "listings.compose.details_no_category":
339
+ "Choose a category first — what it asks for appears here.",
326
340
  "listings.compose.details_failed":
327
341
  "We could not load what this category asks for",
328
342
  "listings.compose.details_empty": "This category asks for no extra details",
@@ -356,6 +370,7 @@ export const listingsI18nBundleEn: Record<string, string> = {
356
370
  "listings.compose.blocked.mirror":
357
371
  "Fix the highlighted fields first",
358
372
  "listings.compose.blocked.incomplete": "{count} required details are still empty",
373
+ "listings.compose.show_first_missing": "Take me to the first empty field",
359
374
  "listings.compose.blocked.details_unavailable":
360
375
  "We could not load what this category asks for, so we cannot check the form",
361
376
 
@@ -417,7 +432,9 @@ export const listingsI18nBundleEn: Record<string, string> = {
417
432
 
418
433
  "listings.nav.detail": "Listing",
419
434
  "listings.nav.compose": "Post a listing",
435
+ "listings.nav.compose.short": "Post",
420
436
  "listings.nav.mine": "My listings",
437
+ "listings.nav.mine.short": "Listings",
421
438
  "listings.nav.favorites": "Favourites",
422
439
  };
423
440
 
package/src/i18n/ru.ts CHANGED
@@ -146,6 +146,8 @@ export const listingsI18nBundleRu: I18nDictionary = {
146
146
  "К объявлению можно приложить не больше {max} фото",
147
147
  "listings.compose.details": "Характеристики",
148
148
  "listings.compose.details_loading": "Загружаем характеристики категории…",
149
+ "listings.compose.details_no_category":
150
+ "Сначала выберите категорию — здесь появится то, что она спрашивает.",
149
151
  "listings.compose.details_failed":
150
152
  "Не удалось загрузить характеристики категории",
151
153
  "listings.compose.details_empty":
@@ -176,6 +178,7 @@ export const listingsI18nBundleRu: I18nDictionary = {
176
178
  "listings.compose.blocked.busy":
177
179
  "Секунду — предыдущее изменение ещё сохраняется",
178
180
  "listings.compose.blocked.incomplete": "Не заполнено обязательных деталей: {count}",
181
+ "listings.compose.show_first_missing": "Перейти к первому незаполненному полю",
179
182
  "listings.compose.blocked.mirror": "Сначала поправьте отмеченные поля",
180
183
  "listings.compose.blocked.details_unavailable":
181
184
  "Не удалось загрузить характеристики категории, поэтому проверить форму нельзя",
@@ -237,7 +240,9 @@ export const listingsI18nBundleRu: I18nDictionary = {
237
240
 
238
241
  "listings.nav.detail": "Объявление",
239
242
  "listings.nav.compose": "Подать объявление",
243
+ "listings.nav.compose.short": "Подать",
240
244
  "listings.nav.mine": "Мои объявления",
245
+ "listings.nav.mine.short": "Мои",
241
246
  "listings.nav.favorites": "Избранное",
242
247
  };
243
248
 
package/src/index.ts CHANGED
@@ -152,6 +152,7 @@ export {
152
152
  featuresFromDaoList,
153
153
  unreadableFeatureCount,
154
154
  } from "./model/features.js";
155
+ export type { FeatureCopySource } from "./model/features.js";
155
156
 
156
157
  // ── model: validation, the mirror and the publish-400 split ──────────────────
157
158
  export {
@@ -159,8 +160,10 @@ export {
159
160
  DEFAULT_DRAFT_LIMITS,
160
161
  DESCRIPTION_FIELD,
161
162
  IMAGES_FIELD,
163
+ LOCATION_FIELD,
162
164
  PRICE_FIELD,
163
165
  TITLE_FIELD,
166
+ envelopeFieldErrors,
164
167
  failedResults,
165
168
  isBatchValid,
166
169
  listingFieldErrors,