@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
package/src/api/types.ts CHANGED
@@ -176,6 +176,16 @@ export interface ListingFeatureDao {
176
176
  * can lack it and a JSONField will pass that through. */
177
177
  readonly type?: ListingFeatureType;
178
178
  readonly value?: unknown;
179
+ /**
180
+ * The write-time LABEL SNAPSHOT: the copy each entry of `value` had when the
181
+ * listing was stored, positionally aligned with it, one entry per value.
182
+ * `ref_select`/`ref_hierarchical_select` have always carried it (their codes
183
+ * name vocabulary terms no display package can reach) and `select` carries
184
+ * it from the release that snapshots option copy — so it is OPTIONAL, and a
185
+ * row stored before that one has no key at all. `model/features.ts` reads
186
+ * it, and says what a row without it falls back to.
187
+ */
188
+ readonly labels?: readonly string[];
179
189
  /** Display name or translation key; falls back to the slug. */
180
190
  readonly name?: string | null;
181
191
  readonly order?: number | null;
@@ -82,6 +82,7 @@ import { spacing } from "@stapel/tokens";
82
82
  import { FeatureBadges } from "@stapel/attributes-react/default";
83
83
  import type { ListingCard as ListingCardData } from "../api/types.js";
84
84
  import { asFeatureDaoList, featuresDtoFromDaoList, featuresFromDaoList } from "../model/features.js";
85
+ import type { FeatureCopySource } from "../model/features.js";
85
86
  import { lifecycleCaption } from "../model/status.js";
86
87
  import { useFavoriteToggle } from "../headless/Favorites.js";
87
88
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
@@ -89,7 +90,7 @@ import { HeartIcon } from "./icons.js";
89
90
  import { SignInLink } from "./SignInLink.js";
90
91
  import { ListingPhoto } from "./ListingPhoto.js";
91
92
  import { ListingPrice } from "./ListingPrice.js";
92
- import type { ThemeModeProp } from "./types.js";
93
+ import type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
93
94
 
94
95
  /**
95
96
  * How the card opens — ONE of three, and the type says so.
@@ -195,7 +196,10 @@ const BUTTON_TARGET_STYLE: CSSProperties = {
195
196
  cursor: "pointer",
196
197
  };
197
198
 
198
- export interface ListingCardBaseProps extends ThemeModeProp, SignInCtaProp {
199
+ export interface ListingCardBaseProps
200
+ extends ThemeModeProp,
201
+ SignInCtaProp,
202
+ CategoryFeaturesProp {
199
203
  readonly listing: ListingCardData;
200
204
  /** See {@link ListingCardBlockedReason}. Default `"text"`. */
201
205
  readonly blockedReason?: ListingCardBlockedReason;
@@ -309,7 +313,14 @@ export function ListingCard(props: ListingCardProps): ReactElement {
309
313
  const { token } = antdTheme.useToken();
310
314
 
311
315
  const badgeDaos = asFeatureDaoList(listing.features_badges);
312
- const badgeFeatures = featuresFromDaoList(badgeDaos);
316
+ // The category's own option table, when the surface has it: a stored
317
+ // `select` carries no table of its own, so without this a badge prints the
318
+ // storage slug. Absent on a mixed grid, which is why it is optional.
319
+ const copy: FeatureCopySource =
320
+ props.categoryFeatures !== undefined
321
+ ? { categoryFeatures: props.categoryFeatures }
322
+ : {};
323
+ const badgeFeatures = featuresFromDaoList(badgeDaos, copy);
313
324
  const badgeValues = featuresDtoFromDaoList(badgeDaos);
314
325
  const titleDaos = asFeatureDaoList(listing.features_title);
315
326
 
@@ -370,7 +381,9 @@ export function ListingCard(props: ListingCardProps): ReactElement {
370
381
  {titleDaos.length > 0 ? (
371
382
  <Typography.Text type="secondary" ellipsis>
372
383
  <FeatureBadges
373
- features={featuresFromDaoList(titleDaos).map((view) => view.feature)}
384
+ features={featuresFromDaoList(titleDaos, copy).map(
385
+ (view) => view.feature
386
+ )}
374
387
  values={featuresDtoFromDaoList(titleDaos)}
375
388
  />
376
389
  </Typography.Text>
@@ -50,6 +50,22 @@
50
50
  * do not import each other; the container is the seam. A library that picked
51
51
  * one would pick it for every host.
52
52
  *
53
+ * ── On a narrow form the CHARACTERISTICS come before the photos ────────────
54
+ *
55
+ * The details of the chosen category are the questions only that category
56
+ * asks, and they are the reason a category is chosen at all. On a wide form
57
+ * they can sit after the photos, because the whole form is one screen. On a
58
+ * 390px one they cannot: measured on a live classified deployment, choosing
59
+ * Mobile phones grew the page to 7292px and put the first attribute control at
60
+ * y=1596 — nearly two viewports below the fold, behind a ~700px photo dropzone
61
+ * — while the footer said "10 required details not filled in" with none of
62
+ * them on screen. So on a narrow form the section moves directly under the
63
+ * category that produced it, and the photo dropzone follows it.
64
+ *
65
+ * The measurement is the FORM's own width (`useElementWidth`, the fleet's one
66
+ * observer), not the viewport's: a composer is not a viewport, and a viewport
67
+ * query would call a 360px pane on a desktop "wide".
68
+ *
53
69
  * ── Every blocked control says which of six reasons it is ──────────────────
54
70
  *
55
71
  * The publish button is the most-gated control in the fleet, and that is the
@@ -60,9 +76,11 @@
60
76
  * would be told, and `<GatedButton>` renders the reason beside the button —
61
77
  * never a grey rectangle, never a hover.
62
78
  */
79
+ import { useRef } from "react";
63
80
  import type { ComponentType, ReactElement, ReactNode } from "react";
64
81
  import {
65
82
  Alert,
83
+ Button,
66
84
  Checkbox,
67
85
  Divider,
68
86
  Flex,
@@ -76,6 +94,7 @@ import {
76
94
  GatedButton,
77
95
  PaneGate,
78
96
  SkinTheme,
97
+ useElementWidth,
79
98
  } from "@stapel/tokens-antd/skin";
80
99
  import {
81
100
  SlotPlaceholder,
@@ -85,11 +104,12 @@ import {
85
104
  useI18n,
86
105
  useT,
87
106
  } from "@stapel/core";
88
- import { spacing } from "@stapel/tokens";
107
+ import { breakpoints, spacing } from "@stapel/tokens";
89
108
  import type { FeatureDef } from "@stapel/attributes-react";
90
109
  import {
91
110
  BUILTIN_VALUE_EDITOR_TYPES,
92
111
  FeatureFields,
112
+ featureControlId,
93
113
  } from "@stapel/attributes-react/default";
94
114
  import { useListingComposer } from "../headless/ListingComposer.js";
95
115
  import type { ListingLocation } from "../model/draft.js";
@@ -114,6 +134,61 @@ import type { ThemeModeProp } from "./types.js";
114
134
  */
115
135
  export const COMPOSER_MEASURE = "44rem";
116
136
 
137
+ /**
138
+ * Below this FORM width the composer is a one-thumb column, and the order of
139
+ * its sections has to change — see {@link ListingComposerPage}'s header on why
140
+ * the characteristics move up.
141
+ *
142
+ * The `tablet` breakpoint, which is what "narrow" means everywhere else in the
143
+ * skin, measured against the form's OWN width and never the viewport's (§83).
144
+ * A composer drawn in a 360px settings pane on a 1440px desktop is narrow; a
145
+ * viewport query would call it wide and bury its questions.
146
+ */
147
+ export const COMPOSER_STACKED_BELOW: number = breakpoints.tablet;
148
+
149
+ /**
150
+ * The DOM id of the control that answers one of the composer's own fields —
151
+ * the names `mirrorListingFields` refuses by (`title`, `description`, …).
152
+ *
153
+ * A person told "10 required details are still empty" needs to be taken to
154
+ * one, and taking them there needs an ADDRESS. Features already have one
155
+ * (`featureControlId`); the composer's own fields had none, and a test id is
156
+ * not an address — it is a test's handle, and reaching for it in product code
157
+ * makes every test id load-bearing.
158
+ */
159
+ export function composerFieldId(field: string): string {
160
+ return `listings-composer-field-${field}`;
161
+ }
162
+
163
+ /** What counts as a control a person can be put in front of. A slot's control
164
+ * belongs to the container, so the field is asked for its first focusable
165
+ * descendant rather than assumed to be an `<input>`. */
166
+ const FOCUSABLE =
167
+ "input,select,textarea,button,[href],[tabindex]:not([tabindex='-1'])";
168
+
169
+ /**
170
+ * Put the person in front of one field: bring it into view, and focus what
171
+ * they are meant to answer.
172
+ *
173
+ * Both halves are guarded rather than assumed. `scrollIntoView` does not exist
174
+ * in every environment this renders in (jsdom, older embedded engines), and a
175
+ * field whose control came from a slot may have nothing focusable in it at
176
+ * all — in which case scrolling to it is still the whole of the help that can
177
+ * honestly be given.
178
+ */
179
+ function revealField(id: string): void {
180
+ if (typeof document === "undefined") return;
181
+ const anchor = document.getElementById(id);
182
+ if (anchor === null) return;
183
+ if (typeof anchor.scrollIntoView === "function") {
184
+ anchor.scrollIntoView({ block: "center", behavior: "smooth" });
185
+ }
186
+ const control = anchor.matches(FOCUSABLE)
187
+ ? anchor
188
+ : anchor.querySelector<HTMLElement>(FOCUSABLE);
189
+ control?.focus();
190
+ }
191
+
117
192
  /**
118
193
  * What `renderCategoryPicker` is handed: the current category and the ONLY
119
194
  * function that changes it. Same shape as any other bag in this monorepo — the
@@ -318,16 +393,23 @@ function SlotField(props: {
318
393
  readonly control: ReactNode | undefined;
319
394
  readonly status: { help: ReactNode; validateStatus: "error" } | Record<string, never>;
320
395
  readonly testId: string;
396
+ /** The address `revealField` aims at — see {@link composerFieldId}. */
397
+ readonly anchorId: string;
321
398
  }): ReactElement | null {
322
399
  const filled = props.control !== undefined;
323
400
  if (!filled && !props.named) return null;
401
+ // A refusal REPLACES the hint rather than stacking under it. "Choose a
402
+ // category — the rest of the form depends on it" and "a category is
403
+ // required" are one fact printed twice, one line apart, and the second is
404
+ // the one the person just earned.
405
+ const refused = "validateStatus" in props.status;
324
406
  return (
325
407
  <Form.Item
326
408
  label={props.label}
327
- {...(props.extra !== undefined ? { extra: props.extra } : {})}
409
+ {...(props.extra !== undefined && !refused ? { extra: props.extra } : {})}
328
410
  {...props.status}
329
411
  >
330
- <div data-testid={props.testId}>
412
+ <div id={props.anchorId} data-testid={props.testId}>
331
413
  {props.control ?? <SlotPlaceholder name={props.slot} visibility="visible" />}
332
414
  </div>
333
415
  </Form.Item>
@@ -401,6 +483,76 @@ export function ListingComposerPage(
401
483
  );
402
484
 
403
485
  const LocationPicker = props.locationPicker;
486
+ // The FORM's width, which is what decides the section order — see the
487
+ // header. `?? false` because an unmeasured box is not a narrow one (the rule
488
+ // `useElementWidth` states for every caller): the wide order is the one that
489
+ // reflows gracefully, so it is what the first frame draws.
490
+ const form = useRef<HTMLDivElement>(null);
491
+ const { below } = useElementWidth(form, {
492
+ thresholds: { stacked: COMPOSER_STACKED_BELOW },
493
+ });
494
+ const stacked = below.stacked ?? false;
495
+
496
+ /**
497
+ * The characteristics of the chosen category — built once and rendered in
498
+ * exactly one of two places, so neither arm can drift from the other.
499
+ *
500
+ * The four states are four different sentences, and the one that used to be
501
+ * missing is the FIRST: with no category chosen there is no request in
502
+ * flight and none will be made, so "loading the category's characteristics"
503
+ * was simply untrue — a spinner-shaped sentence over a form that was waiting
504
+ * for the person, not for the network.
505
+ */
506
+ const details = (
507
+ <div
508
+ data-testid="listings-composer-details"
509
+ data-placement={stacked ? "after-category" : "after-photos"}
510
+ >
511
+ <Divider />
512
+ <Typography.Title level={5}>
513
+ {t(LISTINGS_I18N_KEYS.composeDetails)}
514
+ </Typography.Title>
515
+ {props.featuresError !== undefined ? (
516
+ <ErrorAlert
517
+ testId="listings-composer-features-failed"
518
+ message={t(LISTINGS_I18N_KEYS.composeDetailsFailed)}
519
+ />
520
+ ) : bag.values.categoryId.length === 0 ? (
521
+ <Typography.Text
522
+ type="secondary"
523
+ data-testid="listings-composer-features-no-category"
524
+ >
525
+ {t(LISTINGS_I18N_KEYS.composeDetailsNoCategory)}
526
+ </Typography.Text>
527
+ ) : props.featuresLoading === true ? (
528
+ <Typography.Text type="secondary" data-testid="listings-composer-features-loading">
529
+ {t(LISTINGS_I18N_KEYS.composeDetailsLoading)}
530
+ </Typography.Text>
531
+ ) : props.features.length === 0 ? (
532
+ <Typography.Text type="secondary" data-testid="listings-composer-features-empty">
533
+ {t(LISTINGS_I18N_KEYS.composeDetailsEmpty)}
534
+ </Typography.Text>
535
+ ) : (
536
+ <FeatureFields
537
+ features={props.features}
538
+ values={bag.values.features}
539
+ errors={bag.fieldErrors}
540
+ disabled={bag.publishing}
541
+ onChange={bag.setFeature}
542
+ />
543
+ )}
544
+ </div>
545
+ );
546
+
547
+ // Where the "take me to it" control aims. A feature answers at its own
548
+ // control id; everything else at the composer's.
549
+ const missing = bag.firstUnsatisfied;
550
+ const missingAnchor =
551
+ missing === undefined
552
+ ? undefined
553
+ : props.features.some((feature) => feature.slug === missing)
554
+ ? featureControlId(missing)
555
+ : composerFieldId(missing);
404
556
  // Whether an unfilled slot draws its named placeholder — and therefore
405
557
  // whether the field it belongs to exists at all.
406
558
  const namedSlots = props.slotVisibility === "visible" || isDevBuild();
@@ -451,6 +603,11 @@ export function ListingComposerPage(
451
603
  />
452
604
  ) : null}
453
605
 
606
+ {/* The measured box is the FORM, so a host that renders this page in
607
+ a narrow pane gets the narrow order without the viewport agreeing.
608
+ A plain block wrapper: antd's `Form` forwards its ref to the form
609
+ INSTANCE, not to a node. */}
610
+ <div ref={form}>
454
611
  <Form layout="vertical" data-testid="listings-composer-form">
455
612
  <SlotField
456
613
  label={t(LISTINGS_I18N_KEYS.composeCategory)}
@@ -459,6 +616,7 @@ export function ListingComposerPage(
459
616
  named={namedSlots}
460
617
  status={errorOf(CATEGORY_FIELD)}
461
618
  testId="listings-composer-category"
619
+ anchorId={composerFieldId(CATEGORY_FIELD)}
462
620
  control={props.renderCategoryPicker?.({
463
621
  value: bag.values.categoryId,
464
622
  setCategory: bag.setCategory,
@@ -476,11 +634,16 @@ export function ListingComposerPage(
476
634
  />
477
635
  ) : null}
478
636
 
637
+ {/* Narrow: the category's own questions, directly under the choice
638
+ that produced them and above the photo dropzone. */}
639
+ {stacked ? details : null}
640
+
479
641
  <Form.Item
480
642
  label={t(LISTINGS_I18N_KEYS.composeTitleLabel)}
481
643
  {...errorOf(TITLE_FIELD)}
482
644
  >
483
645
  <Input
646
+ id={composerFieldId(TITLE_FIELD)}
484
647
  value={bag.values.title}
485
648
  aria-label={t(LISTINGS_I18N_KEYS.composeTitleLabel)}
486
649
  data-testid="listings-composer-title"
@@ -496,6 +659,7 @@ export function ListingComposerPage(
496
659
  {...errorOf(DESCRIPTION_FIELD)}
497
660
  >
498
661
  <Input.TextArea
662
+ id={composerFieldId(DESCRIPTION_FIELD)}
499
663
  rows={5}
500
664
  value={bag.values.description}
501
665
  aria-label={t(LISTINGS_I18N_KEYS.composeDescriptionLabel)}
@@ -518,6 +682,7 @@ export function ListingComposerPage(
518
682
  {...errorOf(PRICE_FIELD)}
519
683
  >
520
684
  <Input
685
+ id={composerFieldId(PRICE_FIELD)}
521
686
  inputMode="decimal"
522
687
  value={bag.values.price}
523
688
  {...(props.renderCurrencyPicker === undefined
@@ -538,6 +703,7 @@ export function ListingComposerPage(
538
703
  named={namedSlots}
539
704
  status={{}}
540
705
  testId="listings-composer-currency"
706
+ anchorId={composerFieldId("currency")}
541
707
  control={props.renderCurrencyPicker?.({
542
708
  value: bag.values.currency,
543
709
  setCurrency: (code) => {
@@ -562,6 +728,7 @@ export function ListingComposerPage(
562
728
  named={namedSlots}
563
729
  status={errorOf(LOCATION_FIELD)}
564
730
  testId="listings-composer-location"
731
+ anchorId={composerFieldId(LOCATION_FIELD)}
565
732
  control={
566
733
  props.renderLocationPicker !== undefined ? (
567
734
  <div data-testid="listings-composer-location-slot">
@@ -608,9 +775,11 @@ export function ListingComposerPage(
608
775
  <Typography.Title level={5}>
609
776
  {t(LISTINGS_I18N_KEYS.composePhotos)}
610
777
  </Typography.Title>
611
- {props.gallerySlot ?? (
612
- <SlotPlaceholder name="gallerySlot" visibility="visible" />
613
- )}
778
+ <div id={composerFieldId(IMAGES_FIELD)}>
779
+ {props.gallerySlot ?? (
780
+ <SlotPlaceholder name="gallerySlot" visibility="visible" />
781
+ )}
782
+ </div>
614
783
  {bag.fieldErrors[IMAGES_FIELD] ? (
615
784
  <Typography.Text type="danger" data-testid="listings-composer-images-error">
616
785
  {describe(bag.fieldErrors[IMAGES_FIELD]).message}
@@ -619,33 +788,9 @@ export function ListingComposerPage(
619
788
  </>
620
789
  ) : null}
621
790
 
622
- <Divider />
623
-
624
- <Typography.Title level={5}>
625
- {t(LISTINGS_I18N_KEYS.composeDetails)}
626
- </Typography.Title>
627
- {props.featuresError !== undefined ? (
628
- <ErrorAlert
629
- testId="listings-composer-features-failed"
630
- message={t(LISTINGS_I18N_KEYS.composeDetailsFailed)}
631
- />
632
- ) : props.featuresLoading === true ? (
633
- <Typography.Text type="secondary" data-testid="listings-composer-features-loading">
634
- {t(LISTINGS_I18N_KEYS.composeDetailsLoading)}
635
- </Typography.Text>
636
- ) : props.features.length === 0 ? (
637
- <Typography.Text type="secondary" data-testid="listings-composer-features-empty">
638
- {t(LISTINGS_I18N_KEYS.composeDetailsEmpty)}
639
- </Typography.Text>
640
- ) : (
641
- <FeatureFields
642
- features={props.features}
643
- values={bag.values.features}
644
- errors={bag.fieldErrors}
645
- disabled={bag.publishing}
646
- onChange={bag.setFeature}
647
- />
648
- )}
791
+ {/* Wide: the whole form is one screen, so the details keep their
792
+ place after the photos. */}
793
+ {stacked ? null : details}
649
794
 
650
795
  <Divider />
651
796
 
@@ -687,6 +832,7 @@ export function ListingComposerPage(
687
832
  </Checkbox>
688
833
  </Form.Item>
689
834
  </Form>
835
+ </div>
690
836
 
691
837
  {bag.refusal?.kind === "invalid_draft" ? (
692
838
  <ErrorAlert
@@ -749,6 +895,30 @@ export function ListingComposerPage(
749
895
  {bag.publishing ? t(LISTINGS_I18N_KEYS.composePublishing) : publishLabel}
750
896
  </GatedButton>
751
897
 
898
+ {/* A count with nowhere to go is a dead end: "10 required details
899
+ are still empty" is printed by the gate above with not one of
900
+ them on screen, because the attribute region starts below the
901
+ fold. This is the way to the first of them — a real button, with
902
+ its own accessible name, and not a click handler stuck on the
903
+ sentence (which announces as text and cannot be tabbed to). It
904
+ appears only while the gate is closed, so it never stands under
905
+ a button that is ready to press. */}
906
+ {!bag.publishGate.available && missingAnchor !== undefined ? (
907
+ <Button
908
+ type="link"
909
+ size="small"
910
+ style={{ paddingInline: 0 }}
911
+ data-testid="listings-composer-goto-missing"
912
+ data-analytics="none"
913
+ data-analytics-reason="navigation within the page — the host app wraps business actions with its own tracked()"
914
+ onClick={() => {
915
+ revealField(missingAnchor);
916
+ }}
917
+ >
918
+ {t(LISTINGS_I18N_KEYS.composeShowFirstMissing)}
919
+ </Button>
920
+ ) : null}
921
+
752
922
  <Flex gap={spacing[3]} wrap align="flex-start">
753
923
  <GatedButton
754
924
  gate={bag.saveGate}
@@ -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
+ }
@@ -40,6 +40,7 @@ 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
42
  import {
43
+ LISTING_FIELD_ORDER,
43
44
  envelopeFieldErrors,
44
45
  mirrorDraft,
45
46
  publishRefusal,
@@ -201,6 +202,17 @@ export interface ListingComposerBag {
201
202
  readonly fieldErrors: Readonly<Record<string, FlowError>>;
202
203
  /** The server's last publish refusal, unrouted, for a summary line. */
203
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;
204
216
  /** Value types this build cannot draw — the fact, from attributes-react. */
205
217
  readonly unsupported: readonly string[];
206
218
 
@@ -378,6 +390,21 @@ export function useListingComposer(
378
390
  return { ...shown, ...envelopeFieldErrors(saveThrown), ...routed };
379
391
  }, [showErrors, mirror, refusal, saveThrown]);
380
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]);
407
+
381
408
  const listingState: LoadState<ListingDetailData> | undefined =
382
409
  options.listingId === undefined
383
410
  ? undefined
@@ -571,6 +598,7 @@ export function useListingComposer(
571
598
  mirror,
572
599
  fieldErrors,
573
600
  refusal,
601
+ firstUnsatisfied,
574
602
  unsupported,
575
603
 
576
604
  saveGate,