@stapel/listings-react 0.11.0 → 0.13.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 +212 -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 +59 -0
  10. package/dist/default/ListingComposerPage.d.ts.map +1 -1
  11. package/dist/default/ListingComposerPage.js +103 -12
  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 +221 -32
  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
@@ -50,6 +50,45 @@
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
+ * ── The SECTION ORDER is this component's, and it is not a prop ────────────
54
+ *
55
+ * One order, at every width:
56
+ *
57
+ * category → title → description → price → currency → where → photos →
58
+ * the category's characteristics → the listing's own options
59
+ *
60
+ * The category comes first because everything after it depends on the choice.
61
+ * Then the five questions a seller came to answer — what it is, what it says,
62
+ * what it costs, where it is, what it looks like — and only then the questions
63
+ * the CATEGORY asks.
64
+ *
65
+ * This used to be two orders chosen by the form's width, and the narrow one
66
+ * put the characteristics directly under the category. The reason was real:
67
+ * measured on a live classified deployment, Mobile phones put the first
68
+ * attribute control at y=1596, two viewports below the fold, while the footer
69
+ * said "10 required details not filled in" with none of them on screen. The
70
+ * fix was not: the same leaf carries 32 imported fields, so on the next
71
+ * measurement of the same form the seller was asked for the parcel's weight,
72
+ * its length and "what the goods are measured in" BEFORE the title — title
73
+ * y=5575, price y=5871, photos y=6245, of a 7308px form. A composer that asks
74
+ * about postage before it asks what is being sold has no funnel left.
75
+ *
76
+ * So the discoverability problem is solved where it actually lives, and by two
77
+ * things that do not move the questions around:
78
+ *
79
+ * - the count of unfilled details is a CONTROL that goes to the first of them
80
+ * (`listings-composer-goto-missing`, below), so "10 required details" is
81
+ * never a dead end wherever the region sits;
82
+ * - the region itself is short, because `groupCollapse="auto"` opens the
83
+ * groups that ask something required and leaves the plumbing (delivery
84
+ * dimensions, wholesale terms) closed under its own heading.
85
+ *
86
+ * `detailsPlacement` is therefore gone as a decision — `data-placement` stays
87
+ * on the region as a constant, because an e2e that measured the regression
88
+ * needs something to read that is not a pixel count. A host that wants a
89
+ * different order does not want this page; it wants `<FeatureFields>` and its
90
+ * own form, which is exactly why that component is exported separately.
91
+ *
53
92
  * ── Every blocked control says which of six reasons it is ──────────────────
54
93
  *
55
94
  * The publish button is the most-gated control in the fleet, and that is the
@@ -63,6 +102,7 @@
63
102
  import type { ComponentType, ReactElement, ReactNode } from "react";
64
103
  import {
65
104
  Alert,
105
+ Button,
66
106
  Checkbox,
67
107
  Divider,
68
108
  Flex,
@@ -90,6 +130,7 @@ import type { FeatureDef } from "@stapel/attributes-react";
90
130
  import {
91
131
  BUILTIN_VALUE_EDITOR_TYPES,
92
132
  FeatureFields,
133
+ featureControlId,
93
134
  } from "@stapel/attributes-react/default";
94
135
  import { useListingComposer } from "../headless/ListingComposer.js";
95
136
  import type { ListingLocation } from "../model/draft.js";
@@ -114,6 +155,73 @@ import type { ThemeModeProp } from "./types.js";
114
155
  */
115
156
  export const COMPOSER_MEASURE = "44rem";
116
157
 
158
+ /**
159
+ * What `data-placement` says on the characteristics region.
160
+ *
161
+ * A constant, and deliberately so: the order is the composer's and does not
162
+ * depend on the width any more (see the header). The attribute stays because
163
+ * an e2e suite measured the regression through it and needs a reading that is
164
+ * not a pixel count.
165
+ */
166
+ export const COMPOSER_DETAILS_PLACEMENT = "after-core-fields";
167
+
168
+ /**
169
+ * The DOM id of the control that answers one of the composer's own fields —
170
+ * the names `mirrorListingFields` refuses by (`title`, `description`, …).
171
+ *
172
+ * A person told "10 required details are still empty" needs to be taken to
173
+ * one, and taking them there needs an ADDRESS. Features already have one
174
+ * (`featureControlId`); the composer's own fields had none, and a test id is
175
+ * not an address — it is a test's handle, and reaching for it in product code
176
+ * makes every test id load-bearing.
177
+ */
178
+ export function composerFieldId(field: string): string {
179
+ return `listings-composer-field-${field}`;
180
+ }
181
+
182
+ /** What counts as a control a person can be put in front of. A slot's control
183
+ * belongs to the container, so the field is asked for its first focusable
184
+ * descendant rather than assumed to be an `<input>`. */
185
+ const FOCUSABLE =
186
+ "input,select,textarea,button,[href],[tabindex]:not([tabindex='-1'])";
187
+
188
+ /**
189
+ * Put the person in front of one field: open whatever it is folded inside,
190
+ * bring it into view, and focus what they are meant to answer.
191
+ *
192
+ * The disclosure step is the one that is easy to forget. A characteristic
193
+ * lives in a `<details>` section that may be closed (`groupCollapse="auto"`),
194
+ * and scrolling to a control inside a closed disclosure scrolls to nothing —
195
+ * the "take me to the first empty field" button would report success and move
196
+ * the page nowhere. Any depth of nesting is opened, because the answer to
197
+ * "where is it" must not depend on how the section was drawn.
198
+ *
199
+ * The rest is guarded rather than assumed. `scrollIntoView` does not exist in
200
+ * every environment this renders in (jsdom, older embedded engines), and a
201
+ * field whose control came from a slot may have nothing focusable in it at
202
+ * all — in which case scrolling to it is still the whole of the help that can
203
+ * honestly be given.
204
+ */
205
+ function revealField(id: string): void {
206
+ if (typeof document === "undefined") return;
207
+ const anchor = document.getElementById(id);
208
+ if (anchor === null) return;
209
+ for (
210
+ let folded = anchor.closest("details");
211
+ folded !== null;
212
+ folded = folded.parentElement?.closest("details") ?? null
213
+ ) {
214
+ folded.open = true;
215
+ }
216
+ if (typeof anchor.scrollIntoView === "function") {
217
+ anchor.scrollIntoView({ block: "center", behavior: "smooth" });
218
+ }
219
+ const control = anchor.matches(FOCUSABLE)
220
+ ? anchor
221
+ : anchor.querySelector<HTMLElement>(FOCUSABLE);
222
+ control?.focus();
223
+ }
224
+
117
225
  /**
118
226
  * What `renderCategoryPicker` is handed: the current category and the ONLY
119
227
  * function that changes it. Same shape as any other bag in this monorepo — the
@@ -318,16 +426,23 @@ function SlotField(props: {
318
426
  readonly control: ReactNode | undefined;
319
427
  readonly status: { help: ReactNode; validateStatus: "error" } | Record<string, never>;
320
428
  readonly testId: string;
429
+ /** The address `revealField` aims at — see {@link composerFieldId}. */
430
+ readonly anchorId: string;
321
431
  }): ReactElement | null {
322
432
  const filled = props.control !== undefined;
323
433
  if (!filled && !props.named) return null;
434
+ // A refusal REPLACES the hint rather than stacking under it. "Choose a
435
+ // category — the rest of the form depends on it" and "a category is
436
+ // required" are one fact printed twice, one line apart, and the second is
437
+ // the one the person just earned.
438
+ const refused = "validateStatus" in props.status;
324
439
  return (
325
440
  <Form.Item
326
441
  label={props.label}
327
- {...(props.extra !== undefined ? { extra: props.extra } : {})}
442
+ {...(props.extra !== undefined && !refused ? { extra: props.extra } : {})}
328
443
  {...props.status}
329
444
  >
330
- <div data-testid={props.testId}>
445
+ <div id={props.anchorId} data-testid={props.testId}>
331
446
  {props.control ?? <SlotPlaceholder name={props.slot} visibility="visible" />}
332
447
  </div>
333
448
  </Form.Item>
@@ -401,6 +516,72 @@ export function ListingComposerPage(
401
516
  );
402
517
 
403
518
  const LocationPicker = props.locationPicker;
519
+
520
+ /**
521
+ * The characteristics of the chosen category — built once and rendered in
522
+ * exactly one of two places, so neither arm can drift from the other.
523
+ *
524
+ * The four states are four different sentences, and the one that used to be
525
+ * missing is the FIRST: with no category chosen there is no request in
526
+ * flight and none will be made, so "loading the category's characteristics"
527
+ * was simply untrue — a spinner-shaped sentence over a form that was waiting
528
+ * for the person, not for the network.
529
+ */
530
+ const details = (
531
+ <div
532
+ data-testid="listings-composer-details"
533
+ data-placement={COMPOSER_DETAILS_PLACEMENT}
534
+ >
535
+ <Divider />
536
+ <Typography.Title level={5}>
537
+ {t(LISTINGS_I18N_KEYS.composeDetails)}
538
+ </Typography.Title>
539
+ {props.featuresError !== undefined ? (
540
+ <ErrorAlert
541
+ testId="listings-composer-features-failed"
542
+ message={t(LISTINGS_I18N_KEYS.composeDetailsFailed)}
543
+ />
544
+ ) : bag.values.categoryId.length === 0 ? (
545
+ <Typography.Text
546
+ type="secondary"
547
+ data-testid="listings-composer-features-no-category"
548
+ >
549
+ {t(LISTINGS_I18N_KEYS.composeDetailsNoCategory)}
550
+ </Typography.Text>
551
+ ) : props.featuresLoading === true ? (
552
+ <Typography.Text type="secondary" data-testid="listings-composer-features-loading">
553
+ {t(LISTINGS_I18N_KEYS.composeDetailsLoading)}
554
+ </Typography.Text>
555
+ ) : props.features.length === 0 ? (
556
+ <Typography.Text type="secondary" data-testid="listings-composer-features-empty">
557
+ {t(LISTINGS_I18N_KEYS.composeDetailsEmpty)}
558
+ </Typography.Text>
559
+ ) : (
560
+ <FeatureFields
561
+ features={props.features}
562
+ values={bag.values.features}
563
+ errors={bag.fieldErrors}
564
+ disabled={bag.publishing}
565
+ onChange={bag.setFeature}
566
+ // An imported leaf is mostly plumbing: 32 fields across seven
567
+ // headings, four of them parcel dimensions. Open what is required or
568
+ // already answered, leave the rest one tap away under its own
569
+ // heading — see `FeatureGroupCollapse`.
570
+ groupCollapse="auto"
571
+ />
572
+ )}
573
+ </div>
574
+ );
575
+
576
+ // Where the "take me to it" control aims. A feature answers at its own
577
+ // control id; everything else at the composer's.
578
+ const missing = bag.firstUnsatisfied;
579
+ const missingAnchor =
580
+ missing === undefined
581
+ ? undefined
582
+ : props.features.some((feature) => feature.slug === missing)
583
+ ? featureControlId(missing)
584
+ : composerFieldId(missing);
404
585
  // Whether an unfilled slot draws its named placeholder — and therefore
405
586
  // whether the field it belongs to exists at all.
406
587
  const namedSlots = props.slotVisibility === "visible" || isDevBuild();
@@ -459,6 +640,7 @@ export function ListingComposerPage(
459
640
  named={namedSlots}
460
641
  status={errorOf(CATEGORY_FIELD)}
461
642
  testId="listings-composer-category"
643
+ anchorId={composerFieldId(CATEGORY_FIELD)}
462
644
  control={props.renderCategoryPicker?.({
463
645
  value: bag.values.categoryId,
464
646
  setCategory: bag.setCategory,
@@ -481,6 +663,7 @@ export function ListingComposerPage(
481
663
  {...errorOf(TITLE_FIELD)}
482
664
  >
483
665
  <Input
666
+ id={composerFieldId(TITLE_FIELD)}
484
667
  value={bag.values.title}
485
668
  aria-label={t(LISTINGS_I18N_KEYS.composeTitleLabel)}
486
669
  data-testid="listings-composer-title"
@@ -496,6 +679,7 @@ export function ListingComposerPage(
496
679
  {...errorOf(DESCRIPTION_FIELD)}
497
680
  >
498
681
  <Input.TextArea
682
+ id={composerFieldId(DESCRIPTION_FIELD)}
499
683
  rows={5}
500
684
  value={bag.values.description}
501
685
  aria-label={t(LISTINGS_I18N_KEYS.composeDescriptionLabel)}
@@ -518,6 +702,7 @@ export function ListingComposerPage(
518
702
  {...errorOf(PRICE_FIELD)}
519
703
  >
520
704
  <Input
705
+ id={composerFieldId(PRICE_FIELD)}
521
706
  inputMode="decimal"
522
707
  value={bag.values.price}
523
708
  {...(props.renderCurrencyPicker === undefined
@@ -538,6 +723,7 @@ export function ListingComposerPage(
538
723
  named={namedSlots}
539
724
  status={{}}
540
725
  testId="listings-composer-currency"
726
+ anchorId={composerFieldId("currency")}
541
727
  control={props.renderCurrencyPicker?.({
542
728
  value: bag.values.currency,
543
729
  setCurrency: (code) => {
@@ -562,6 +748,7 @@ export function ListingComposerPage(
562
748
  named={namedSlots}
563
749
  status={errorOf(LOCATION_FIELD)}
564
750
  testId="listings-composer-location"
751
+ anchorId={composerFieldId(LOCATION_FIELD)}
565
752
  control={
566
753
  props.renderLocationPicker !== undefined ? (
567
754
  <div data-testid="listings-composer-location-slot">
@@ -608,9 +795,11 @@ export function ListingComposerPage(
608
795
  <Typography.Title level={5}>
609
796
  {t(LISTINGS_I18N_KEYS.composePhotos)}
610
797
  </Typography.Title>
611
- {props.gallerySlot ?? (
612
- <SlotPlaceholder name="gallerySlot" visibility="visible" />
613
- )}
798
+ <div id={composerFieldId(IMAGES_FIELD)}>
799
+ {props.gallerySlot ?? (
800
+ <SlotPlaceholder name="gallerySlot" visibility="visible" />
801
+ )}
802
+ </div>
614
803
  {bag.fieldErrors[IMAGES_FIELD] ? (
615
804
  <Typography.Text type="danger" data-testid="listings-composer-images-error">
616
805
  {describe(bag.fieldErrors[IMAGES_FIELD]).message}
@@ -619,33 +808,9 @@ export function ListingComposerPage(
619
808
  </>
620
809
  ) : null}
621
810
 
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
- )}
811
+ {/* The category's own questions, after the five the seller came to
812
+ answer. See the header on why this is one order and not two. */}
813
+ {details}
649
814
 
650
815
  <Divider />
651
816
 
@@ -749,6 +914,30 @@ export function ListingComposerPage(
749
914
  {bag.publishing ? t(LISTINGS_I18N_KEYS.composePublishing) : publishLabel}
750
915
  </GatedButton>
751
916
 
917
+ {/* A count with nowhere to go is a dead end: "10 required details
918
+ are still empty" is printed by the gate above with not one of
919
+ them on screen, because the attribute region starts below the
920
+ fold. This is the way to the first of them — a real button, with
921
+ its own accessible name, and not a click handler stuck on the
922
+ sentence (which announces as text and cannot be tabbed to). It
923
+ appears only while the gate is closed, so it never stands under
924
+ a button that is ready to press. */}
925
+ {!bag.publishGate.available && missingAnchor !== undefined ? (
926
+ <Button
927
+ type="link"
928
+ size="small"
929
+ style={{ paddingInline: 0 }}
930
+ data-testid="listings-composer-goto-missing"
931
+ data-analytics="none"
932
+ data-analytics-reason="navigation within the page — the host app wraps business actions with its own tracked()"
933
+ onClick={() => {
934
+ revealField(missingAnchor);
935
+ }}
936
+ >
937
+ {t(LISTINGS_I18N_KEYS.composeShowFirstMissing)}
938
+ </Button>
939
+ ) : null}
940
+
752
941
  <Flex gap={spacing[3]} wrap align="flex-start">
753
942
  <GatedButton
754
943
  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_DETAILS_PLACEMENT,
61
+ COMPOSER_MEASURE,
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,
@@ -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