@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.
- package/CHANGELOG.md +197 -0
- package/dist/api/types.d.ts +10 -0
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/ListingCard.d.ts +2 -2
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +8 -2
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingComposerPage.d.ts +22 -62
- package/dist/default/ListingComposerPage.d.ts.map +1 -1
- package/dist/default/ListingComposerPage.js +228 -56
- package/dist/default/ListingComposerPage.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +2 -2
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +6 -1
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts +2 -2
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +7 -2
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/index.d.ts +2 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -1
- package/dist/default/index.js.map +1 -1
- package/dist/default/types.d.ts +21 -0
- package/dist/default/types.d.ts.map +1 -1
- package/dist/headless/ListingComposer.d.ts +11 -0
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +31 -6
- package/dist/headless/ListingComposer.js.map +1 -1
- package/dist/headless/ListingDetail.d.ts +16 -1
- package/dist/headless/ListingDetail.d.ts.map +1 -1
- package/dist/headless/ListingDetail.js +13 -4
- package/dist/headless/ListingDetail.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +4 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +10 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +16 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +4 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/model/features.d.ts +90 -20
- package/dist/model/features.d.ts.map +1 -1
- package/dist/model/features.js +148 -17
- package/dist/model/features.js.map +1 -1
- package/dist/model/validation.d.ts +32 -0
- package/dist/model/validation.d.ts.map +1 -1
- package/dist/model/validation.js +82 -1
- package/dist/model/validation.js.map +1 -1
- package/dist/nav/manifest.d.ts.map +1 -1
- package/dist/nav/manifest.js +2 -0
- package/dist/nav/manifest.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +8 -1
- package/nav-manifest.json +3 -1
- package/package.json +7 -7
- package/src/analytics/generated/events.json +1 -1
- package/src/api/types.ts +10 -0
- package/src/default/ListingCard.tsx +17 -4
- package/src/default/ListingComposerPage.tsx +221 -36
- package/src/default/ListingDetailPane.tsx +10 -6
- package/src/default/ListingSerpCard.tsx +13 -4
- package/src/default/index.ts +7 -2
- package/src/default/types.ts +22 -0
- package/src/headless/ListingComposer.tsx +47 -6
- package/src/headless/ListingDetail.tsx +37 -10
- package/src/i18n/es.ts +5 -0
- package/src/i18n/keys.ts +17 -0
- package/src/i18n/ru.ts +5 -0
- package/src/index.ts +3 -0
- package/src/model/features.ts +253 -35
- package/src/model/validation.ts +84 -1
- package/src/nav/manifest.ts +2 -0
package/dist/nav/manifest.js.map
CHANGED
|
@@ -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
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.
|
|
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.
|
|
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.
|
|
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 ->
|
|
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": "
|
|
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.
|
|
106
|
-
"@stapel/core": "^0.
|
|
107
|
-
"@stapel/currencies-react": "^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.
|
|
111
|
+
"@stapel/tokens-antd": "^0.11.0"
|
|
112
112
|
},
|
|
113
113
|
"engines": {
|
|
114
114
|
"node": ">=22"
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
{
|
|
605
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
{
|
|
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 &&
|
|
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}
|