@stapel/listings-react 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/dist/api/types.d.ts +10 -0
  3. package/dist/api/types.d.ts.map +1 -1
  4. package/dist/api/types.js.map +1 -1
  5. package/dist/default/ListingCard.d.ts +2 -2
  6. package/dist/default/ListingCard.d.ts.map +1 -1
  7. package/dist/default/ListingCard.js +8 -2
  8. package/dist/default/ListingCard.js.map +1 -1
  9. package/dist/default/ListingComposerPage.d.ts +22 -62
  10. package/dist/default/ListingComposerPage.d.ts.map +1 -1
  11. package/dist/default/ListingComposerPage.js +228 -56
  12. package/dist/default/ListingComposerPage.js.map +1 -1
  13. package/dist/default/ListingDetailPane.d.ts +2 -2
  14. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  15. package/dist/default/ListingDetailPane.js +6 -1
  16. package/dist/default/ListingDetailPane.js.map +1 -1
  17. package/dist/default/ListingSerpCard.d.ts +2 -2
  18. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  19. package/dist/default/ListingSerpCard.js +7 -2
  20. package/dist/default/ListingSerpCard.js.map +1 -1
  21. package/dist/default/index.d.ts +2 -2
  22. package/dist/default/index.d.ts.map +1 -1
  23. package/dist/default/index.js +1 -1
  24. package/dist/default/index.js.map +1 -1
  25. package/dist/default/types.d.ts +21 -0
  26. package/dist/default/types.d.ts.map +1 -1
  27. package/dist/headless/ListingComposer.d.ts +11 -0
  28. package/dist/headless/ListingComposer.d.ts.map +1 -1
  29. package/dist/headless/ListingComposer.js +31 -6
  30. package/dist/headless/ListingComposer.js.map +1 -1
  31. package/dist/headless/ListingDetail.d.ts +16 -1
  32. package/dist/headless/ListingDetail.d.ts.map +1 -1
  33. package/dist/headless/ListingDetail.js +13 -4
  34. package/dist/headless/ListingDetail.js.map +1 -1
  35. package/dist/i18n/es.d.ts.map +1 -1
  36. package/dist/i18n/es.js +4 -0
  37. package/dist/i18n/es.js.map +1 -1
  38. package/dist/i18n/keys.d.ts +10 -0
  39. package/dist/i18n/keys.d.ts.map +1 -1
  40. package/dist/i18n/keys.js +16 -0
  41. package/dist/i18n/keys.js.map +1 -1
  42. package/dist/i18n/ru.d.ts.map +1 -1
  43. package/dist/i18n/ru.js +4 -0
  44. package/dist/i18n/ru.js.map +1 -1
  45. package/dist/index.d.ts +2 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +1 -1
  48. package/dist/index.js.map +1 -1
  49. package/dist/model/features.d.ts +90 -20
  50. package/dist/model/features.d.ts.map +1 -1
  51. package/dist/model/features.js +148 -17
  52. package/dist/model/features.js.map +1 -1
  53. package/dist/model/validation.d.ts +32 -0
  54. package/dist/model/validation.d.ts.map +1 -1
  55. package/dist/model/validation.js +82 -1
  56. package/dist/model/validation.js.map +1 -1
  57. package/dist/nav/manifest.d.ts.map +1 -1
  58. package/dist/nav/manifest.js +2 -0
  59. package/dist/nav/manifest.js.map +1 -1
  60. package/llms.txt +1 -1
  61. package/manifest.json +8 -1
  62. package/nav-manifest.json +3 -1
  63. package/package.json +7 -7
  64. package/src/analytics/generated/events.json +1 -1
  65. package/src/api/types.ts +10 -0
  66. package/src/default/ListingCard.tsx +17 -4
  67. package/src/default/ListingComposerPage.tsx +221 -36
  68. package/src/default/ListingDetailPane.tsx +10 -6
  69. package/src/default/ListingSerpCard.tsx +13 -4
  70. package/src/default/index.ts +7 -2
  71. package/src/default/types.ts +22 -0
  72. package/src/headless/ListingComposer.tsx +47 -6
  73. package/src/headless/ListingDetail.tsx +37 -10
  74. package/src/i18n/es.ts +5 -0
  75. package/src/i18n/keys.ts +17 -0
  76. package/src/i18n/ru.ts +5 -0
  77. package/src/index.ts +3 -0
  78. package/src/model/features.ts +253 -35
  79. package/src/model/validation.ts +84 -1
  80. package/src/nav/manifest.ts +2 -0
@@ -1 +1 @@
1
- {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../../src/nav/manifest.ts"],"names":[],"mappings":"AAyCA;;iEAEiE;AACjE,MAAM,CAAC,MAAM,eAAe,GAAG,cAAc,CAAC;AAE9C,MAAM,CAAC,MAAM,UAAU,GAAwB;IAC7C;QACE,EAAE,EAAE,iBAAiB;QACrB,QAAQ,EAAE,qBAAqB;QAC/B,IAAI,EAAE,aAAa;QACnB,KAAK,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;QACzB,SAAS,EAAE,EAAE,MAAM,EAAE,mBAAmB,EAAE,OAAO,EAAE,SAAS,EAAE;QAC9D,SAAS,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE;QAC3B,kBAAkB,EAAE,KAAK;QACzB,YAAY,EAAE,KAAK;QACnB,OAAO,EAAE,QAAQ;QACjB,KAAK,EAAE,EAAE;KACV;IACD;QACE,EAAE,EAAE,kBAAkB;QACtB,QAAQ,EAAE,sBAAsB;QAChC,IAAI,EAAE,cAAc;QACpB,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE;QACvB,SAAS,EAAE,EAAE,MAAM,EAAE,qBAAqB,EAAE,OAAO,EAAE,SAAS,EAAE;QAChE,SAAS,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE;QAC3B,kBAAkB,EAAE,IAAI;QACxB,YAAY,EAAE,IAAI;QAClB,OAAO,EAAE,QAAQ;QACjB,KAAK,EAAE,EAAE;KACV;IACD;QACE,qEAAqE;QACrE,EAAE,EAAE,eAAe;QACnB,QAAQ,EAAE,mBAAmB;QAC7B,IAAI,EAAE,iBAAiB;QACvB,KAAK,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE;QAC3B,SAAS,EAAE,EAAE,MAAM,EAAE,gBAAgB,EAAE,OAAO,EAAE,SAAS,EAAE;QAC3D,SAAS,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,eAAe,EAAE;QAC1D,kBAAkB,EAAE,IAAI;QACxB,YAAY,EAAE,IAAI;QAClB,OAAO,EAAE,QAAQ;QACjB,KAAK,EAAE,EAAE;KACV;IACD;QACE,EAAE,EAAE,oBAAoB;QACxB,QAAQ,EAAE,wBAAwB;QAClC,IAAI,EAAE,eAAe;QACrB,KAAK,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE;QAC5B,SAAS,EAAE,EAAE,MAAM,EAAE,eAAe,EAAE,OAAO,EAAE,SAAS,EAAE;QAC1D,SAAS,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,eAAe,EAAE;QAC1D,kBAAkB,EAAE,IAAI;QACxB,YAAY,EAAE,IAAI;QAClB,OAAO,EAAE,QAAQ;QACjB,KAAK,EAAE,EAAE;KACV;CACF,CAAC"}
1
+ {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../../src/nav/manifest.ts"],"names":[],"mappings":"AAyCA;;iEAEiE;AACjE,MAAM,CAAC,MAAM,eAAe,GAAG,cAAc,CAAC;AAE9C,MAAM,CAAC,MAAM,UAAU,GAAwB;IAC7C;QACE,EAAE,EAAE,iBAAiB;QACrB,QAAQ,EAAE,qBAAqB;QAC/B,IAAI,EAAE,aAAa;QACnB,KAAK,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;QACzB,SAAS,EAAE,EAAE,MAAM,EAAE,mBAAmB,EAAE,OAAO,EAAE,SAAS,EAAE;QAC9D,SAAS,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE;QAC3B,kBAAkB,EAAE,KAAK;QACzB,YAAY,EAAE,KAAK;QACnB,OAAO,EAAE,QAAQ;QACjB,KAAK,EAAE,EAAE;KACV;IACD;QACE,EAAE,EAAE,kBAAkB;QACtB,QAAQ,EAAE,sBAAsB;QAChC,aAAa,EAAE,4BAA4B;QAC3C,IAAI,EAAE,cAAc;QACpB,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE;QACvB,SAAS,EAAE,EAAE,MAAM,EAAE,qBAAqB,EAAE,OAAO,EAAE,SAAS,EAAE;QAChE,SAAS,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE;QAC3B,kBAAkB,EAAE,IAAI;QACxB,YAAY,EAAE,IAAI;QAClB,OAAO,EAAE,QAAQ;QACjB,KAAK,EAAE,EAAE;KACV;IACD;QACE,qEAAqE;QACrE,EAAE,EAAE,eAAe;QACnB,QAAQ,EAAE,mBAAmB;QAC7B,aAAa,EAAE,yBAAyB;QACxC,IAAI,EAAE,iBAAiB;QACvB,KAAK,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE;QAC3B,SAAS,EAAE,EAAE,MAAM,EAAE,gBAAgB,EAAE,OAAO,EAAE,SAAS,EAAE;QAC3D,SAAS,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,eAAe,EAAE;QAC1D,kBAAkB,EAAE,IAAI;QACxB,YAAY,EAAE,IAAI;QAClB,OAAO,EAAE,QAAQ;QACjB,KAAK,EAAE,EAAE;KACV;IACD;QACE,EAAE,EAAE,oBAAoB;QACxB,QAAQ,EAAE,wBAAwB;QAClC,IAAI,EAAE,eAAe;QACrB,KAAK,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE;QAC5B,SAAS,EAAE,EAAE,MAAM,EAAE,eAAe,EAAE,OAAO,EAAE,SAAS,EAAE;QAC1D,SAAS,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,eAAe,EAAE;QAC1D,kBAAkB,EAAE,IAAI;QACxB,YAAY,EAAE,IAAI;QAClB,OAAO,EAAE,QAAQ;QACjB,KAAK,EAAE,EAAE;KACV;CACF,CAAC"}
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # @stapel/listings-react 0.10.0
1
+ # @stapel/listings-react 0.12.0
2
2
 
3
3
  Headless React flow pair for stapel-listings (contract >=0.10 <0.11) — business + state, zero visual opinion.
4
4
  Built on @stapel/core: typed client + StapelApiError envelope, auth token refresh,
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-manifest.mjs — do not edit; drift-gated (pnpm gen:manifest:check)",
3
3
  "package": "@stapel/listings-react",
4
- "version": "0.10.0",
4
+ "version": "0.12.0",
5
5
  "backend": {
6
6
  "module": "stapel-listings",
7
7
  "contract": ">=0.10 <0.11"
@@ -921,6 +921,7 @@
921
921
  "listings.compose.details_empty",
922
922
  "listings.compose.details_failed",
923
923
  "listings.compose.details_loading",
924
+ "listings.compose.details_no_category",
924
925
  "listings.compose.edit_title",
925
926
  "listings.compose.geo_incomplete",
926
927
  "listings.compose.invalid_summary",
@@ -938,6 +939,7 @@
938
939
  "listings.compose.save",
939
940
  "listings.compose.saved",
940
941
  "listings.compose.saving",
942
+ "listings.compose.show_first_missing",
941
943
  "listings.compose.stock",
942
944
  "listings.compose.title_label",
943
945
  "listings.compose.title_too_long",
@@ -998,9 +1000,11 @@
998
1000
  "listings.moderation.rejected",
999
1001
  "listings.moderation.rejected_still_live",
1000
1002
  "listings.nav.compose",
1003
+ "listings.nav.compose.short",
1001
1004
  "listings.nav.detail",
1002
1005
  "listings.nav.favorites",
1003
1006
  "listings.nav.mine",
1007
+ "listings.nav.mine.short",
1004
1008
  "listings.page.indicator",
1005
1009
  "listings.page.next",
1006
1010
  "listings.page.prev",
@@ -1035,6 +1039,7 @@
1035
1039
  "LISTING_PUBLISH_VALIDATION_FAILED",
1036
1040
  "LISTING_STATUSES",
1037
1041
  "LISTING_TRANSITIONS",
1042
+ "LOCATION_FIELD",
1038
1043
  "ListingComposer",
1039
1044
  "ListingDetail",
1040
1045
  "ListingsProvider",
@@ -1058,6 +1063,7 @@
1058
1063
  "draftValuesFromWire",
1059
1064
  "droppedFeatureSlugs",
1060
1065
  "emptyDraftValues",
1066
+ "envelopeFieldErrors",
1061
1067
  "explainListingsError",
1062
1068
  "failedResults",
1063
1069
  "featureFromDao",
@@ -1121,6 +1127,7 @@
1121
1127
  "FavoriteToggleResponse",
1122
1128
  "FavoritesBag",
1123
1129
  "FeatureConfig",
1130
+ "FeatureCopySource",
1124
1131
  "FeatureDef",
1125
1132
  "FeatureValueDto",
1126
1133
  "FlowEndpoint",
package/nav-manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "@stapel/listings-react",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "entries": [
5
5
  {
6
6
  "id": "listings.detail",
@@ -24,6 +24,7 @@
24
24
  {
25
25
  "id": "listings.compose",
26
26
  "labelKey": "listings.nav.compose",
27
+ "shortLabelKey": "listings.nav.compose.short",
27
28
  "icon": "PlusOutlined",
28
29
  "route": {
29
30
  "path": "/new"
@@ -43,6 +44,7 @@
43
44
  {
44
45
  "id": "listings.mine",
45
46
  "labelKey": "listings.nav.mine",
47
+ "shortLabelKey": "listings.nav.mine.short",
46
48
  "icon": "ProfileOutlined",
47
49
  "route": {
48
50
  "path": "listings"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stapel/listings-react",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Headless React pair for stapel-listings: a typed client over the draft twin, the composer that turns a category schema into a submittable listing, and the owner's dashboard. Two independent axes are rendered as two — lifecycle `status` decides visibility and `moderation_status` decides nothing about it, so an edit to a live listing stays live and says 'under review' instead of vanishing. Feature values are drawn and mirrored by @stapel/attributes-react; photos arrive as an injected upload bag whose `refs` ARE `images_draft` and whose `settled` gates the submit; a publish refusal is routed onto the control that caused it by slug. Zero visual opinion in the main entry; an opt-in /default subpath ships the antd skin.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -48,9 +48,9 @@
48
48
  ],
49
49
  "size-limit": [
50
50
  {
51
- "name": "index — the headless pair (client + status axes + draft model + hooks): no antd. Raised 11 -> 11.5 KB for the elevation gate (useElevatableMandateGate + core's useElevation): the anonymous arm can now mint an identity for a named action instead of refusing, and that is a state hook the gate did not previously carry",
51
+ "name": "index — the headless pair (client + status axes + draft model + hooks): no antd. Raised 11 -> 12 KB for the elevation gate (useElevatableMandateGate + core's useElevation): the anonymous arm can now mint an identity for a named action instead of refusing, and that is a state hook the gate did not previously carry. Raised 11.5 -> 12 KB for envelopeFieldErrors and its API-field -> control table: a save-draft/create 400 names its field in the envelope rather than in a publish batch, and without the table the refusal had nowhere to land — which is how one over-precise coordinate painted two unreadable banners and left every control clean. Thirty-six bytes of routing table is what a legible refusal costs. Raised 12 -> 13 KB for two seams the mobile wave needed in the HEADLESS half: the category-feature copy source in model/features.ts (the option table a stored select does not carry, so a published listing prints \"Second-hand\" and not \"b-u\"), and firstUnsatisfied (the field a \"10 required details are still empty\" count actually points at, in the order the form asks). Both are model, not skin: a headless host renders its own controls and needs the same two answers",
52
52
  "path": "dist/index.js",
53
- "limit": "11.5 KB"
53
+ "limit": "13 KB"
54
54
  },
55
55
  {
56
56
  "name": "default — the antd skin (three cards, detail, composer, dashboard, the named slots and the delete confirmation) must stay out of the main bundle. Raised 17 -> 19 KB for the mobile wave: <ListingSerpCard> (photo strip + price-first block + action rail), <ListingFeedCard> and <FeedGrid> are two more card surfaces on the same shared target/favourite/photo parts, not three copies of one",
@@ -102,13 +102,13 @@
102
102
  "size-limit": "^11.2.0",
103
103
  "typescript": "^5.8.3",
104
104
  "vitest": "^3.2.4",
105
- "@stapel/attributes-react": "^0.5.0",
106
- "@stapel/core": "^0.20.0",
107
- "@stapel/currencies-react": "^0.2.0",
105
+ "@stapel/attributes-react": "^0.6.0",
106
+ "@stapel/core": "^0.22.0",
107
+ "@stapel/currencies-react": "^0.3.0",
108
108
  "@stapel/image": "^0.4.2",
109
109
  "@stapel/showcase": "^0.3.0",
110
110
  "@stapel/tokens": "^0.6.0",
111
- "@stapel/tokens-antd": "^0.9.0"
111
+ "@stapel/tokens-antd": "^0.11.0"
112
112
  },
113
113
  "engines": {
114
114
  "node": ">=22"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-events.mjs — do not edit; drift-gated (pnpm gen:events:check)",
3
3
  "package": "@stapel/listings-react",
4
- "version": "0.10.0",
4
+ "version": "0.12.0",
5
5
  "defined": [],
6
6
  "flows": []
7
7
  }
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";
@@ -98,8 +118,10 @@ import {
98
118
  CATEGORY_FIELD,
99
119
  DESCRIPTION_FIELD,
100
120
  IMAGES_FIELD,
121
+ LOCATION_FIELD,
101
122
  PRICE_FIELD,
102
123
  TITLE_FIELD,
124
+ envelopeFieldErrors,
103
125
  failedResults,
104
126
  } from "../model/validation.js";
105
127
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
@@ -112,6 +134,61 @@ import type { ThemeModeProp } from "./types.js";
112
134
  */
113
135
  export const COMPOSER_MEASURE = "44rem";
114
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
+
115
192
  /**
116
193
  * What `renderCategoryPicker` is handed: the current category and the ONLY
117
194
  * function that changes it. Same shape as any other bag in this monorepo — the
@@ -316,22 +393,34 @@ function SlotField(props: {
316
393
  readonly control: ReactNode | undefined;
317
394
  readonly status: { help: ReactNode; validateStatus: "error" } | Record<string, never>;
318
395
  readonly testId: string;
396
+ /** The address `revealField` aims at — see {@link composerFieldId}. */
397
+ readonly anchorId: string;
319
398
  }): ReactElement | null {
320
399
  const filled = props.control !== undefined;
321
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;
322
406
  return (
323
407
  <Form.Item
324
408
  label={props.label}
325
- {...(props.extra !== undefined ? { extra: props.extra } : {})}
409
+ {...(props.extra !== undefined && !refused ? { extra: props.extra } : {})}
326
410
  {...props.status}
327
411
  >
328
- <div data-testid={props.testId}>
412
+ <div id={props.anchorId} data-testid={props.testId}>
329
413
  {props.control ?? <SlotPlaceholder name={props.slot} visibility="visible" />}
330
414
  </div>
331
415
  </Form.Item>
332
416
  );
333
417
  }
334
418
 
419
+ /** Did this refusal land on a control? Then the control is where it is read. */
420
+ function routedToAControl(thrown: unknown): boolean {
421
+ return Object.keys(envelopeFieldErrors(thrown)).length > 0;
422
+ }
423
+
335
424
  /** A decimal string → the number a picker works in, or `null`. */
336
425
  function toNumber(value: string | null): number | null {
337
426
  if (value === null || value.length === 0) return null;
@@ -394,6 +483,76 @@ export function ListingComposerPage(
394
483
  );
395
484
 
396
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);
397
556
  // Whether an unfilled slot draws its named placeholder — and therefore
398
557
  // whether the field it belongs to exists at all.
399
558
  const namedSlots = props.slotVisibility === "visible" || isDevBuild();
@@ -444,6 +603,11 @@ export function ListingComposerPage(
444
603
  />
445
604
  ) : null}
446
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}>
447
611
  <Form layout="vertical" data-testid="listings-composer-form">
448
612
  <SlotField
449
613
  label={t(LISTINGS_I18N_KEYS.composeCategory)}
@@ -452,6 +616,7 @@ export function ListingComposerPage(
452
616
  named={namedSlots}
453
617
  status={errorOf(CATEGORY_FIELD)}
454
618
  testId="listings-composer-category"
619
+ anchorId={composerFieldId(CATEGORY_FIELD)}
455
620
  control={props.renderCategoryPicker?.({
456
621
  value: bag.values.categoryId,
457
622
  setCategory: bag.setCategory,
@@ -469,11 +634,16 @@ export function ListingComposerPage(
469
634
  />
470
635
  ) : null}
471
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
+
472
641
  <Form.Item
473
642
  label={t(LISTINGS_I18N_KEYS.composeTitleLabel)}
474
643
  {...errorOf(TITLE_FIELD)}
475
644
  >
476
645
  <Input
646
+ id={composerFieldId(TITLE_FIELD)}
477
647
  value={bag.values.title}
478
648
  aria-label={t(LISTINGS_I18N_KEYS.composeTitleLabel)}
479
649
  data-testid="listings-composer-title"
@@ -489,6 +659,7 @@ export function ListingComposerPage(
489
659
  {...errorOf(DESCRIPTION_FIELD)}
490
660
  >
491
661
  <Input.TextArea
662
+ id={composerFieldId(DESCRIPTION_FIELD)}
492
663
  rows={5}
493
664
  value={bag.values.description}
494
665
  aria-label={t(LISTINGS_I18N_KEYS.composeDescriptionLabel)}
@@ -511,6 +682,7 @@ export function ListingComposerPage(
511
682
  {...errorOf(PRICE_FIELD)}
512
683
  >
513
684
  <Input
685
+ id={composerFieldId(PRICE_FIELD)}
514
686
  inputMode="decimal"
515
687
  value={bag.values.price}
516
688
  {...(props.renderCurrencyPicker === undefined
@@ -531,6 +703,7 @@ export function ListingComposerPage(
531
703
  named={namedSlots}
532
704
  status={{}}
533
705
  testId="listings-composer-currency"
706
+ anchorId={composerFieldId("currency")}
534
707
  control={props.renderCurrencyPicker?.({
535
708
  value: bag.values.currency,
536
709
  setCurrency: (code) => {
@@ -553,8 +726,9 @@ export function ListingComposerPage(
553
726
  extra={t(LISTINGS_I18N_KEYS.composeLocationHelp)}
554
727
  slot="locationPicker"
555
728
  named={namedSlots}
556
- status={errorOf("location")}
729
+ status={errorOf(LOCATION_FIELD)}
557
730
  testId="listings-composer-location"
731
+ anchorId={composerFieldId(LOCATION_FIELD)}
558
732
  control={
559
733
  props.renderLocationPicker !== undefined ? (
560
734
  <div data-testid="listings-composer-location-slot">
@@ -601,9 +775,11 @@ export function ListingComposerPage(
601
775
  <Typography.Title level={5}>
602
776
  {t(LISTINGS_I18N_KEYS.composePhotos)}
603
777
  </Typography.Title>
604
- {props.gallerySlot ?? (
605
- <SlotPlaceholder name="gallerySlot" visibility="visible" />
606
- )}
778
+ <div id={composerFieldId(IMAGES_FIELD)}>
779
+ {props.gallerySlot ?? (
780
+ <SlotPlaceholder name="gallerySlot" visibility="visible" />
781
+ )}
782
+ </div>
607
783
  {bag.fieldErrors[IMAGES_FIELD] ? (
608
784
  <Typography.Text type="danger" data-testid="listings-composer-images-error">
609
785
  {describe(bag.fieldErrors[IMAGES_FIELD]).message}
@@ -612,33 +788,9 @@ export function ListingComposerPage(
612
788
  </>
613
789
  ) : null}
614
790
 
615
- <Divider />
616
-
617
- <Typography.Title level={5}>
618
- {t(LISTINGS_I18N_KEYS.composeDetails)}
619
- </Typography.Title>
620
- {props.featuresError !== undefined ? (
621
- <ErrorAlert
622
- testId="listings-composer-features-failed"
623
- message={t(LISTINGS_I18N_KEYS.composeDetailsFailed)}
624
- />
625
- ) : props.featuresLoading === true ? (
626
- <Typography.Text type="secondary" data-testid="listings-composer-features-loading">
627
- {t(LISTINGS_I18N_KEYS.composeDetailsLoading)}
628
- </Typography.Text>
629
- ) : props.features.length === 0 ? (
630
- <Typography.Text type="secondary" data-testid="listings-composer-features-empty">
631
- {t(LISTINGS_I18N_KEYS.composeDetailsEmpty)}
632
- </Typography.Text>
633
- ) : (
634
- <FeatureFields
635
- features={props.features}
636
- values={bag.values.features}
637
- errors={bag.fieldErrors}
638
- disabled={bag.publishing}
639
- onChange={bag.setFeature}
640
- />
641
- )}
791
+ {/* Wide: the whole form is one screen, so the details keep their
792
+ place after the photos. */}
793
+ {stacked ? null : details}
642
794
 
643
795
  <Divider />
644
796
 
@@ -680,6 +832,7 @@ export function ListingComposerPage(
680
832
  </Checkbox>
681
833
  </Form.Item>
682
834
  </Form>
835
+ </div>
683
836
 
684
837
  {bag.refusal?.kind === "invalid_draft" ? (
685
838
  <ErrorAlert
@@ -690,7 +843,13 @@ export function ListingComposerPage(
690
843
  />
691
844
  ) : null}
692
845
 
693
- {bag.refusal?.kind === "error" ? (
846
+ {/* A refusal that reached a control is READ there, under the field
847
+ the person can change. Repeating it as a banner — twice, since a
848
+ publish saves the draft first and both failures are the same 400 —
849
+ is how one refused coordinate painted two identical "Validation
850
+ error" plaques and named nothing (blocker C2). The banner is for
851
+ what has nowhere else to go. */}
852
+ {bag.refusal?.kind === "error" && !routedToAControl(bag.refusal.error) ? (
694
853
  <ErrorAlert
695
854
  testId="listings-composer-error"
696
855
  error={describe({
@@ -703,7 +862,9 @@ export function ListingComposerPage(
703
862
  />
704
863
  ) : null}
705
864
 
706
- {bag.saveError !== undefined && bag.saveError !== null ? (
865
+ {bag.saveError !== undefined &&
866
+ bag.saveError !== null &&
867
+ !routedToAControl(bag.saveError) ? (
707
868
  <ErrorAlert
708
869
  testId="listings-composer-save-error"
709
870
  thrown={bag.saveError}
@@ -734,6 +895,30 @@ export function ListingComposerPage(
734
895
  {bag.publishing ? t(LISTINGS_I18N_KEYS.composePublishing) : publishLabel}
735
896
  </GatedButton>
736
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
+
737
922
  <Flex gap={spacing[3]} wrap align="flex-start">
738
923
  <GatedButton
739
924
  gate={bag.saveGate}