@stapel/listings-react 0.11.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 (79) hide show
  1. package/CHANGELOG.md +171 -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 +220 -54
  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 +18 -1
  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 +1 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/model/features.d.ts +90 -20
  49. package/dist/model/features.d.ts.map +1 -1
  50. package/dist/model/features.js +148 -17
  51. package/dist/model/features.js.map +1 -1
  52. package/dist/model/validation.d.ts +12 -0
  53. package/dist/model/validation.d.ts.map +1 -1
  54. package/dist/model/validation.js +19 -0
  55. package/dist/model/validation.js.map +1 -1
  56. package/dist/nav/manifest.d.ts.map +1 -1
  57. package/dist/nav/manifest.js +2 -0
  58. package/dist/nav/manifest.js.map +1 -1
  59. package/llms.txt +1 -1
  60. package/manifest.json +6 -1
  61. package/nav-manifest.json +3 -1
  62. package/package.json +7 -7
  63. package/src/analytics/generated/events.json +1 -1
  64. package/src/api/types.ts +10 -0
  65. package/src/default/ListingCard.tsx +17 -4
  66. package/src/default/ListingComposerPage.tsx +203 -33
  67. package/src/default/ListingDetailPane.tsx +10 -6
  68. package/src/default/ListingSerpCard.tsx +13 -4
  69. package/src/default/index.ts +7 -2
  70. package/src/default/types.ts +22 -0
  71. package/src/headless/ListingComposer.tsx +28 -0
  72. package/src/headless/ListingDetail.tsx +37 -10
  73. package/src/i18n/es.ts +5 -0
  74. package/src/i18n/keys.ts +17 -0
  75. package/src/i18n/ru.ts +5 -0
  76. package/src/index.ts +1 -0
  77. package/src/model/features.ts +253 -35
  78. package/src/model/validation.ts +20 -0
  79. package/src/nav/manifest.ts +2 -0
@@ -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 {
@@ -22,63 +22,268 @@
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, and the one line that repairs it ────────────
25
+ * ── What a DAO does NOT carry, and the tables that repair it ───────────────
26
26
  *
27
27
  * `select`'s `options`. `SelectType.dto_to_dao` stores the chosen VALUES and
28
28
  * the ui config, never the option table — the table lives on the CATEGORY, and
29
29
  * not needing it is the whole point of the projection.
30
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.
31
+ * `formatFeatureValue` resolves an option's copy by looking the value up in
32
+ * `config.options`, so with no table at all it falls through to
33
+ * `String(value)` and the STORAGE SLUG reaches the screen: a live classified
34
+ * deployment printed "Condition: b-u" on its spec rows and a three-slug
35
+ * subtitle on its cards. (The server never had this problem: its own
36
+ * `format_value` has the category's config in hand.)
37
+ *
38
+ * So the split below rebuilds the table, from the best of three sources:
39
+ *
40
+ * 1. The LABEL SNAPSHOT. A `select` DAO written by a stapel-attributes that
41
+ * snapshots labels carries `labels`, a `string[]` positionally aligned
42
+ * with `value` the same device `ref_select` has always used for its
43
+ * vocabulary terms, and the reason a published listing keeps printing the
44
+ * copy it was published with however the category is edited afterwards.
45
+ * 2. The CATEGORY's own option table, when the display surface has it —
46
+ * `GET /categories/api/v1/categories/{id}/features/` answers with
47
+ * `{"slug":"condition","config":{"type":"select","options":[…]}}`, labels
48
+ * and all. It is handed in through {@link FeatureCopySource}, because a
49
+ * library may not fetch another module's endpoint and a card grid has no
50
+ * category to fetch one FOR.
51
+ * 3. The IDENTITY table — `{value: v, label: v}` — for a value neither of the
52
+ * first two names. That is exactly the table a TRANSLATABLE catalogue
53
+ * would have produced, since its labels ARE its keys, so such a value
54
+ * still reads out of the host's bundle; a NON-translatable one keeps
55
+ * showing the slug. A visible `b-u` gets fixed by rung 1 or 2, and an
56
+ * invented "Used" ships wrong, so the floor stays the value itself.
57
+ *
58
+ * ── Which rung wins, and why that order and not the other one ──────────────
59
+ *
60
+ * The SNAPSHOT outranks the category. The snapshot is what the listing was
61
+ * PUBLISHED with; the category is what the catalogue says today. A category
62
+ * whose option copy was rewritten after a listing went live must not silently
63
+ * restate that listing — the person reading it is reading an advert somebody
64
+ * else wrote, not a form. The category outranks identity, which is the whole
65
+ * point: every listing published before the snapshot release, and every row
66
+ * written by an older server, has no snapshot to outrank it and prints its
67
+ * slug today.
68
+ *
69
+ * A category def that does not describe the STORED row — a different value
70
+ * type, or no such slug — is ignored rather than forced. A category edited
71
+ * from `select` to `string` is a different question, and pairing its options
72
+ * against this row's values would print one question's copy under another
73
+ * question's answer, which does not even LOOK wrong.
74
+ *
75
+ * With no category defs supplied, every rung above 1 is absent and the
76
+ * projection behaves exactly as it did.
77
+ *
78
+ * ── The category path is the REPAIR path, not the mechanism ────────────────
79
+ *
80
+ * The primary mechanism is the server's, and it is the snapshot: stapel-
81
+ * attributes writes `labels` beside a `select`'s codes exactly as it always
82
+ * has for `ref_select`, and stapel-listings ships a reprojection command that
83
+ * back-fills rows written before it. Nothing here competes with that. Rung 2
84
+ * exists for the three states that outlive that deploy:
85
+ *
86
+ * - a row written before the snapshot existed and not yet reprojected;
87
+ * - a deployment that never runs the reprojection;
88
+ * - a category whose option copy was edited AFTER publication — where the
89
+ * snapshot is deliberately the older and correct answer, which is exactly
90
+ * why the precedence runs the way it does above.
91
+ *
92
+ * The resulting table is then read the one way `formatFeatureValue` reads any
93
+ * option table — literal when the config says `translatable_options: false`,
94
+ * through the host's `t` otherwise, where a translatable catalogue's label IS
95
+ * the key and an unknown key comes back unchanged. The flag stays the ROW's:
96
+ * it describes how the listing's own stored config wants its copy resolved,
97
+ * and an unknown key resolves to itself either way.
47
98
  */
48
99
  import type { FeatureDef, FeatureValueDto } from "@stapel/attributes-react";
100
+ import { featureType } from "@stapel/attributes-react";
49
101
  import type { ListingFeatureDao, ListingFeatureView } from "../api/types.js";
50
102
 
51
- /** Keys that belong to the DAO envelope rather than to the type's config. */
103
+ /**
104
+ * Keys that belong to the DAO envelope rather than to the type's config.
105
+ *
106
+ * `labels` is deliberately NOT one of them, even though this module now reads
107
+ * it: `ref_select` and `ref_hierarchical_select` resolve their vocabulary
108
+ * terms from `config.labels` (attributes-react's `refLabels` reads the value
109
+ * envelope first and the config second, and this split hands the formatter a
110
+ * bare `{type, value}`), so envelope-ing the key would blank every term those
111
+ * two types print. For `select` the key is simply inert — its formatter reads
112
+ * `options` and nothing else — so carrying it costs a reference.
113
+ */
52
114
  const ENVELOPE = new Set(["slug", "value", "name", "order", "title", "badge"]);
53
115
 
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. */
116
+ /** The type whose stored `value` is a flat list of option keys rather than the
117
+ * things themselves, and whose copy therefore has to be repaired pairwise.
118
+ * `ref_select` is absent because it repairs itself: its `labels` snapshot has
119
+ * always been mandatory (no display package can reach a vocabulary term). */
57
120
  const OPTION_VALUED = new Set(["select"]);
58
121
 
122
+ /**
123
+ * The type whose copy lives in a TREE on the category rather than in a flat
124
+ * table: `hierarchical_select` stores a path of option keys and
125
+ * `formatFeatureValue` walks `config.options` level by level to name each
126
+ * step, so a row without that tree prints "passenger / sedan" — the storage
127
+ * keys — exactly as a table-less `select` prints `b-u`.
128
+ *
129
+ * It is kept apart from {@link OPTION_VALUED} because the two repairs are not
130
+ * the same repair: a flat positional `labels` list cannot describe a tree, so
131
+ * there is nothing to merge and the category's tree is adopted whole or not at
132
+ * all. `formatFeatureValue` already keeps a step the tree does not contain as
133
+ * its raw value, so an adopted tree can only add copy, never blank one out.
134
+ */
135
+ const TREE_VALUED = new Set(["hierarchical_select"]);
136
+
59
137
  /** The canon's `FeatureDef.translate` vocabulary — three values, closed. */
60
138
  function isTranslateMode(value: unknown): value is "all" | "title" | "none" {
61
139
  return value === "all" || value === "title" || value === "none";
62
140
  }
63
141
 
64
142
  /**
65
- * The identity option table for a stored `select` see the module header.
143
+ * The category's feature defs, for a display surface that has them.
144
+ *
145
+ * A detail page reads one listing of one category and its container usually
146
+ * holds that category's schema already (it is the same read the composer is
147
+ * given); a card grid spanning forty categories does not, and passes nothing.
148
+ * Optional for exactly that reason: the seam adds a source of copy, it never
149
+ * becomes a requirement, and a host that wires nothing keeps the behaviour it
150
+ * has.
151
+ */
152
+ export interface FeatureCopySource {
153
+ /**
154
+ * The chosen category's features, as
155
+ * `GET /categories/api/v1/categories/{id}/features/` answers — the same
156
+ * `readonly FeatureDef[]` `<ListingComposerPage>` already takes. Defs are
157
+ * matched to stored rows by slug AND value type; anything else is ignored.
158
+ */
159
+ readonly categoryFeatures?: readonly FeatureDef[];
160
+ }
161
+
162
+ /** Category defs keyed by slug, or `undefined` when there are none to key —
163
+ * built once per list so a 40-row projection is not 40 linear scans. */
164
+ type CategoryDefs = ReadonlyMap<string, FeatureDef>;
165
+
166
+ function categoryDefs(source: FeatureCopySource): CategoryDefs | undefined {
167
+ const defs = source.categoryFeatures;
168
+ if (defs === undefined || defs.length === 0) return undefined;
169
+ const index = new Map<string, FeatureDef>();
170
+ for (const def of defs) {
171
+ // First wins: a duplicate slug in a category is a catalogue defect, and
172
+ // silently preferring the last one would make the copy depend on the
173
+ // order the endpoint happened to serialize.
174
+ if (typeof def.slug === "string" && def.slug.length > 0 && !index.has(def.slug)) {
175
+ index.set(def.slug, def);
176
+ }
177
+ }
178
+ return index.size === 0 ? undefined : index;
179
+ }
180
+
181
+ /**
182
+ * The category's definition OF THIS ROW, or `undefined`.
183
+ *
184
+ * Same slug and same value type, or it is not a definition of this row — see
185
+ * the module header on why a mismatch is ignored rather than forced.
186
+ */
187
+ function categoryDefFor(
188
+ dao: ListingFeatureDao,
189
+ defs: CategoryDefs | undefined
190
+ ): FeatureDef | undefined {
191
+ if (defs === undefined) return undefined;
192
+ const def = defs.get(dao.slug);
193
+ if (def === undefined) return undefined;
194
+ return featureType(def) === dao.type ? def : undefined;
195
+ }
196
+
197
+ /** A category def's declared option list, when it declares one. */
198
+ function declaredOptions(def: FeatureDef | undefined): readonly unknown[] | undefined {
199
+ const raw = def?.config?.["options"];
200
+ return Array.isArray(raw) ? (raw as readonly unknown[]) : undefined;
201
+ }
202
+
203
+ /** One entry of an option list → its `(value, label)` pair, when it is one. */
204
+ function optionPair(entry: unknown): { value: string; label: string } | undefined {
205
+ if (entry === null || typeof entry !== "object") return undefined;
206
+ const option = entry as { value?: unknown; label?: unknown };
207
+ if (typeof option.value !== "string" || option.value.length === 0) return undefined;
208
+ return {
209
+ value: option.value,
210
+ label: typeof option.label === "string" && option.label.length > 0 ? option.label : option.value,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * The option table a stored `select` needs and does not carry — see the
216
+ * module header. The category's table underneath, the row's own `labels`
217
+ * snapshot on top, the values themselves as the floor.
218
+ *
219
+ * The alignment rule is the engine's own (`labels if len(labels) ==
220
+ * len(codes) else codes`): a snapshot of a different length than `value` is a
221
+ * snapshot of some other value list, and pairing the two anyway would print
222
+ * one option's copy against another option's value — worse than a slug,
223
+ * because it does not LOOK wrong. So a length mismatch drops the whole
224
+ * snapshot rather than pairing the overlap, and the row falls back to the
225
+ * category (or to identity). Within a usable snapshot, a missing or empty
226
+ * entry falls back to whatever the category, then the value itself, offers for
227
+ * that one pair.
66
228
  *
67
229
  * Returns `undefined` when there is nothing to add (another type, a config
68
- * that already carries a table, a value that is not a list of strings), so the
69
- * common path allocates nothing and a DAO that DOES carry options is left
70
- * exactly as it arrived.
230
+ * that already carries a table, a value that is not a list of strings and no
231
+ * category table either), so the common path allocates nothing and a DAO that
232
+ * DOES carry options is left exactly as it arrived — a stored table is a
233
+ * write-time snapshot of the whole question, which is a stronger statement
234
+ * about this listing than either the per-value snapshot or today's category.
71
235
  */
72
- function synthesizedOptions(
236
+ function selectOptions(
73
237
  dao: ListingFeatureDao,
74
- config: Readonly<Record<string, unknown>>
238
+ config: Readonly<Record<string, unknown>>,
239
+ categoryDef: FeatureDef | undefined
75
240
  ): readonly { value: string; label: string }[] | undefined {
76
241
  if (typeof dao.type !== "string" || !OPTION_VALUED.has(dao.type)) return undefined;
77
242
  if (Array.isArray(config["options"])) return undefined;
78
- const raw = Array.isArray(dao.value) ? dao.value : [dao.value];
79
- const values = raw.filter((item): item is string => typeof item === "string");
80
- if (values.length === 0) return undefined;
81
- return values.map((value) => ({ value, label: value }));
243
+ const raw: readonly unknown[] = Array.isArray(dao.value)
244
+ ? (dao.value as readonly unknown[])
245
+ : [dao.value];
246
+ const snapshot: unknown = dao.labels;
247
+ const labels: readonly unknown[] | undefined =
248
+ Array.isArray(snapshot) && snapshot.length === raw.length
249
+ ? (snapshot as readonly unknown[])
250
+ : undefined;
251
+
252
+ // A Map so the three rungs can overwrite one another by value instead of
253
+ // the reader having to scan a list that carries the same value twice —
254
+ // `labelOf` takes the FIRST match, so a duplicate would make the losing
255
+ // rung win.
256
+ const table = new Map<string, string>();
257
+ for (const entry of declaredOptions(categoryDef) ?? []) {
258
+ const pair = optionPair(entry);
259
+ if (pair !== undefined) table.set(pair.value, pair.label);
260
+ }
261
+ raw.forEach((value, index) => {
262
+ if (typeof value !== "string") return;
263
+ const label = labels?.[index];
264
+ if (typeof label === "string" && label.length > 0) table.set(value, label);
265
+ else if (!table.has(value)) table.set(value, value);
266
+ });
267
+ if (table.size === 0) return undefined;
268
+ return [...table].map(([value, label]) => ({ value, label }));
269
+ }
270
+
271
+ /**
272
+ * The category's option TREE, adopted whole for a `hierarchical_select` that
273
+ * stored none — see {@link TREE_VALUED}.
274
+ *
275
+ * Nothing is merged and nothing is rewritten: the tree is the category's
276
+ * structure, and this projection has no per-level snapshot with which to
277
+ * disagree about it.
278
+ */
279
+ function adoptedTree(
280
+ dao: ListingFeatureDao,
281
+ config: Readonly<Record<string, unknown>>,
282
+ categoryDef: FeatureDef | undefined
283
+ ): readonly unknown[] | undefined {
284
+ if (typeof dao.type !== "string" || !TREE_VALUED.has(dao.type)) return undefined;
285
+ if (Array.isArray(config["options"])) return undefined;
286
+ return declaredOptions(categoryDef);
82
287
  }
83
288
 
84
289
  /**
@@ -90,7 +295,16 @@ function synthesizedOptions(
90
295
  * position that changes whenever the category does.
91
296
  */
92
297
  export function featureFromDao(
93
- dao: ListingFeatureDao
298
+ dao: ListingFeatureDao,
299
+ source: FeatureCopySource = {}
300
+ ): ListingFeatureView | undefined {
301
+ return featureView(dao, categoryDefs(source));
302
+ }
303
+
304
+ /** The work of {@link featureFromDao}, against an already-built index. */
305
+ function featureView(
306
+ dao: ListingFeatureDao,
307
+ defs: CategoryDefs | undefined
94
308
  ): ListingFeatureView | undefined {
95
309
  if (typeof dao.slug !== "string" || dao.slug.length === 0) return undefined;
96
310
 
@@ -98,7 +312,9 @@ export function featureFromDao(
98
312
  for (const [key, value] of Object.entries(dao)) {
99
313
  if (!ENVELOPE.has(key)) config[key] = value;
100
314
  }
101
- const options = synthesizedOptions(dao, config);
315
+ const categoryDef = categoryDefFor(dao, defs);
316
+ const options =
317
+ selectOptions(dao, config, categoryDef) ?? adoptedTree(dao, config, categoryDef);
102
318
  if (options !== undefined) config["options"] = options;
103
319
 
104
320
  const feature: FeatureDef = {
@@ -125,11 +341,13 @@ export function featureFromDao(
125
341
  * unreadableFeatureCount} so a skin can say how many rather than pretend the
126
342
  * listing had fewer attributes. */
127
343
  export function featuresFromDaoList(
128
- daos: readonly ListingFeatureDao[] | null | undefined
344
+ daos: readonly ListingFeatureDao[] | null | undefined,
345
+ source: FeatureCopySource = {}
129
346
  ): readonly ListingFeatureView[] {
347
+ const defs = categoryDefs(source);
130
348
  const out: ListingFeatureView[] = [];
131
349
  for (const dao of daos ?? []) {
132
- const view = featureFromDao(dao);
350
+ const view = featureView(dao, defs);
133
351
  if (view !== undefined) out.push(view);
134
352
  }
135
353
  return out;
@@ -96,6 +96,26 @@ export const CATEGORY_FIELD = "category_id";
96
96
  /** The coordinate pair, as one control: a latitude alone is not a place. */
97
97
  export const LOCATION_FIELD = "location";
98
98
 
99
+ /**
100
+ * The composer's OWN controls, in the order the draft declares them.
101
+ *
102
+ * The mirror is a `Record`, and a record has no order worth trusting — its
103
+ * feature refusals happen to come first because of a spread. "Which field is
104
+ * the first unsatisfied one?" is a question a person asks about a FORM, so the
105
+ * answer is written down as a list rather than read off an object: the
106
+ * category (nothing else can be answered until it is chosen), then the
107
+ * listing's own fields, then — appended by the caller — the category's
108
+ * features in the order the schema declares them.
109
+ */
110
+ export const LISTING_FIELD_ORDER: readonly string[] = [
111
+ CATEGORY_FIELD,
112
+ TITLE_FIELD,
113
+ DESCRIPTION_FIELD,
114
+ PRICE_FIELD,
115
+ LOCATION_FIELD,
116
+ IMAGES_FIELD,
117
+ ];
118
+
99
119
  /**
100
120
  * The API's own field names → the control that holds them.
101
121
  *
@@ -60,6 +60,7 @@ export const navEntries: readonly NavEntry[] = [
60
60
  {
61
61
  id: "listings.compose",
62
62
  labelKey: "listings.nav.compose",
63
+ shortLabelKey: "listings.nav.compose.short",
63
64
  icon: "PlusOutlined",
64
65
  route: { path: "/new" },
65
66
  component: { export: "ListingComposerPage", subpath: "default" },
@@ -73,6 +74,7 @@ export const navEntries: readonly NavEntry[] = [
73
74
  // Relative path: a child of the container's `/account` layout route.
74
75
  id: "listings.mine",
75
76
  labelKey: "listings.nav.mine",
77
+ shortLabelKey: "listings.nav.mine.short",
76
78
  icon: "ProfileOutlined",
77
79
  route: { path: "listings" },
78
80
  component: { export: "MyListingsPane", subpath: "default" },