@stapel/listings-react 0.22.1 → 0.24.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 +109 -0
- package/README.md +73 -6
- package/dist/api/generated/schema.d.ts +119 -6
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/listingsApi.d.ts +25 -0
- package/dist/api/listingsApi.d.ts.map +1 -1
- package/dist/api/listingsApi.js +1 -0
- package/dist/api/listingsApi.js.map +1 -1
- package/dist/api/types.d.ts +46 -3
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/CardBadges.d.ts +47 -0
- package/dist/default/CardBadges.d.ts.map +1 -0
- package/dist/default/CardBadges.js +38 -0
- package/dist/default/CardBadges.js.map +1 -0
- package/dist/default/FavoritesPane.d.ts +18 -2
- package/dist/default/FavoritesPane.d.ts.map +1 -1
- package/dist/default/FavoritesPane.js +3 -3
- package/dist/default/FavoritesPane.js.map +1 -1
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +6 -6
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +12 -19
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +12 -8
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +3 -5
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/ListingSpecList.d.ts +74 -0
- package/dist/default/ListingSpecList.d.ts.map +1 -0
- package/dist/default/ListingSpecList.js +90 -0
- package/dist/default/ListingSpecList.js.map +1 -0
- package/dist/default/MyListingsPane.d.ts +22 -2
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +1 -1
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/default/cardGallery.d.ts +81 -0
- package/dist/default/cardGallery.d.ts.map +1 -0
- package/dist/default/cardGallery.js +266 -0
- package/dist/default/cardGallery.js.map +1 -0
- package/dist/default/index.d.ts +8 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +5 -0
- package/dist/default/index.js.map +1 -1
- package/dist/headless/ListingComposer.d.ts +16 -10
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +23 -4
- package/dist/headless/ListingComposer.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +1 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.es.gen.js +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +6 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +3 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +3 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/model/cardBadges.d.ts +134 -0
- package/dist/model/cardBadges.d.ts.map +1 -0
- package/dist/model/cardBadges.js +131 -0
- package/dist/model/cardBadges.js.map +1 -0
- package/dist/model/featureText.d.ts +63 -0
- package/dist/model/featureText.d.ts.map +1 -0
- package/dist/model/featureText.js +133 -0
- package/dist/model/featureText.js.map +1 -0
- package/dist/model/features.d.ts.map +1 -1
- package/dist/model/features.js +59 -0
- package/dist/model/features.js.map +1 -1
- package/dist/model/queries.d.ts +15 -1
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +20 -0
- package/dist/model/queries.js.map +1 -1
- package/dist/model/queryKeys.d.ts +4 -0
- package/dist/model/queryKeys.d.ts.map +1 -1
- package/dist/model/queryKeys.js +1 -0
- package/dist/model/queryKeys.js.map +1 -1
- package/llms.txt +6 -2
- package/manifest.json +57 -1
- package/nav-manifest.json +1 -1
- package/package.json +5 -5
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +119 -6
- package/src/api/listingsApi.ts +30 -0
- package/src/api/types.ts +55 -5
- package/src/default/CardBadges.tsx +105 -0
- package/src/default/FavoritesPane.tsx +20 -5
- package/src/default/ListingCard.tsx +7 -21
- package/src/default/ListingDetailPane.tsx +13 -37
- package/src/default/ListingPhoto.tsx +54 -26
- package/src/default/ListingSerpCard.tsx +10 -28
- package/src/default/ListingSpecList.tsx +195 -0
- package/src/default/MyListingsPane.tsx +23 -5
- package/src/default/cardGallery.ts +287 -0
- package/src/default/index.ts +24 -1
- package/src/headless/ListingComposer.tsx +42 -17
- package/src/i18n/es.ts +3 -0
- package/src/i18n/generated/errors.es.gen.ts +1 -1
- package/src/i18n/generated/errors.gen.ts +3 -0
- package/src/i18n/generated/errors.json +9 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/ru.ts +5 -0
- package/src/index.ts +16 -0
- package/src/model/cardBadges.ts +237 -0
- package/src/model/featureText.ts +199 -0
- package/src/model/features.ts +60 -0
- package/src/model/queries.ts +25 -0
- package/src/model/queryKeys.ts +5 -0
|
@@ -159,6 +159,50 @@ export interface paths {
|
|
|
159
159
|
patch?: never;
|
|
160
160
|
trace?: never;
|
|
161
161
|
};
|
|
162
|
+
"/listings/api/v1/listings/{id}/draft/": {
|
|
163
|
+
parameters: {
|
|
164
|
+
query?: never;
|
|
165
|
+
header?: never;
|
|
166
|
+
path?: never;
|
|
167
|
+
cookie?: never;
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* @description Read back the draft twins — the reopen half of ``save-draft``.
|
|
171
|
+
*
|
|
172
|
+
* Every user-editable field on this module is a ``*_draft`` column
|
|
173
|
+
* (``title_draft``, ``description_draft``, ``images_draft``,
|
|
174
|
+
* ``features_draft``, the location trio, ...) and until now the only
|
|
175
|
+
* call that ever returned one was the write itself: ``create``,
|
|
176
|
+
* ``update`` and ``save-draft`` echo the bag back, and no READ did. So a
|
|
177
|
+
* composer reopening a listing by id had exactly one source, the detail
|
|
178
|
+
* read, which serves the PUBLISHED fields — empty on everything that has
|
|
179
|
+
* never been published. The seller pressed «продолжить», got a blank
|
|
180
|
+
* form, and their text and photos were sitting in the row the whole
|
|
181
|
+
* time.
|
|
182
|
+
*
|
|
183
|
+
* Deliberately a separate route rather than draft columns bolted onto
|
|
184
|
+
* the detail read: the detail read is the PUBLIC one (a stranger and a
|
|
185
|
+
* crawler read it without a session), and a shape whose key set depends
|
|
186
|
+
* on who is asking is the shape a redaction bug hides in. Here the
|
|
187
|
+
* answer is structural — ``_get_own`` is the module's one ownership
|
|
188
|
+
* gate, 403 for someone else's listing, 404 for one that is absent or
|
|
189
|
+
* soft-deleted — so no draft field can reach a non-owner by omission.
|
|
190
|
+
*
|
|
191
|
+
* The payload is ``ListingDraftSerializer``, byte for byte what
|
|
192
|
+
* ``save-draft`` returns, so a client reopens with the mapper it already
|
|
193
|
+
* has for the write's response instead of a second one.
|
|
194
|
+
*
|
|
195
|
+
* **Permissions:** `IsAuthenticated`
|
|
196
|
+
*/
|
|
197
|
+
get: operations["listings_api_v1_listings_draft_retrieve"];
|
|
198
|
+
put?: never;
|
|
199
|
+
post?: never;
|
|
200
|
+
delete?: never;
|
|
201
|
+
options?: never;
|
|
202
|
+
head?: never;
|
|
203
|
+
patch?: never;
|
|
204
|
+
trace?: never;
|
|
205
|
+
};
|
|
162
206
|
"/listings/api/v1/listings/{id}/favorite/": {
|
|
163
207
|
parameters: {
|
|
164
208
|
query?: never;
|
|
@@ -285,6 +329,15 @@ export interface paths {
|
|
|
285
329
|
* and the set the server accepts are one object, not two that agree
|
|
286
330
|
* today.
|
|
287
331
|
*
|
|
332
|
+
* One edge is not a lifecycle hop: ARCHIVED -> PUBLISHED is
|
|
333
|
+
* «опубликовать снова», and it runs the publish service (validate,
|
|
334
|
+
* promote, re-submit for review — see ``RESTORE_EDGES``). So it can
|
|
335
|
+
* answer 400 when the draft is not publishable, and the ``status`` it
|
|
336
|
+
* returns is where the deployment's moderation policy put the listing,
|
|
337
|
+
* which under the default pre-moderation gate is ``pending`` and not the
|
|
338
|
+
* ``published`` that was asked for. Read the status from the response,
|
|
339
|
+
* never from the request.
|
|
340
|
+
*
|
|
288
341
|
* **Permissions:** `IsAuthenticated`
|
|
289
342
|
*/
|
|
290
343
|
post: operations["listings_api_v1_listings_transition_create"];
|
|
@@ -815,7 +868,13 @@ export interface components {
|
|
|
815
868
|
price_base?: string | null;
|
|
816
869
|
currency?: string;
|
|
817
870
|
readonly images: string[] | null;
|
|
818
|
-
readonly features_title: (components["schemas"]["FeatureDao"]
|
|
871
|
+
readonly features_title: ((Omit<components["schemas"]["FeatureDao"], "type"> & {
|
|
872
|
+
label: string;
|
|
873
|
+
unit?: string;
|
|
874
|
+
name: string;
|
|
875
|
+
/** @enum {unknown} */
|
|
876
|
+
presentation: "value" | "value_unit" | "name_value" | "name";
|
|
877
|
+
}) | {
|
|
819
878
|
slug?: string;
|
|
820
879
|
type?: string;
|
|
821
880
|
name?: string | null;
|
|
@@ -830,7 +889,13 @@ export interface components {
|
|
|
830
889
|
redacted: true;
|
|
831
890
|
present: boolean;
|
|
832
891
|
})[];
|
|
833
|
-
readonly features_badges: (components["schemas"]["FeatureDao"]
|
|
892
|
+
readonly features_badges: ((Omit<components["schemas"]["FeatureDao"], "type"> & {
|
|
893
|
+
label: string;
|
|
894
|
+
unit?: string;
|
|
895
|
+
name: string;
|
|
896
|
+
/** @enum {unknown} */
|
|
897
|
+
presentation: "value" | "value_unit" | "name_value" | "name";
|
|
898
|
+
}) | {
|
|
834
899
|
slug?: string;
|
|
835
900
|
type?: string;
|
|
836
901
|
name?: string | null;
|
|
@@ -906,7 +971,13 @@ export interface components {
|
|
|
906
971
|
redacted: true;
|
|
907
972
|
present: boolean;
|
|
908
973
|
})[];
|
|
909
|
-
readonly features_title: (components["schemas"]["FeatureDao"]
|
|
974
|
+
readonly features_title: ((Omit<components["schemas"]["FeatureDao"], "type"> & {
|
|
975
|
+
label: string;
|
|
976
|
+
unit?: string;
|
|
977
|
+
name: string;
|
|
978
|
+
/** @enum {unknown} */
|
|
979
|
+
presentation: "value" | "value_unit" | "name_value" | "name";
|
|
980
|
+
}) | {
|
|
910
981
|
slug?: string;
|
|
911
982
|
type?: string;
|
|
912
983
|
name?: string | null;
|
|
@@ -921,7 +992,13 @@ export interface components {
|
|
|
921
992
|
redacted: true;
|
|
922
993
|
present: boolean;
|
|
923
994
|
})[];
|
|
924
|
-
readonly features_badges: (components["schemas"]["FeatureDao"]
|
|
995
|
+
readonly features_badges: ((Omit<components["schemas"]["FeatureDao"], "type"> & {
|
|
996
|
+
label: string;
|
|
997
|
+
unit?: string;
|
|
998
|
+
name: string;
|
|
999
|
+
/** @enum {unknown} */
|
|
1000
|
+
presentation: "value" | "value_unit" | "name_value" | "name";
|
|
1001
|
+
}) | {
|
|
925
1002
|
slug?: string;
|
|
926
1003
|
type?: string;
|
|
927
1004
|
name?: string | null;
|
|
@@ -982,6 +1059,7 @@ export interface components {
|
|
|
982
1059
|
features_draft?: {
|
|
983
1060
|
[key: string]: components["schemas"]["FeatureDto"];
|
|
984
1061
|
} | null;
|
|
1062
|
+
draft_meta?: unknown;
|
|
985
1063
|
auto_republish?: boolean;
|
|
986
1064
|
countable?: boolean;
|
|
987
1065
|
/** Format: int64 */
|
|
@@ -1085,7 +1163,13 @@ export interface components {
|
|
|
1085
1163
|
price_base?: string | null;
|
|
1086
1164
|
currency?: string;
|
|
1087
1165
|
readonly images: string[] | null;
|
|
1088
|
-
readonly features_title: (components["schemas"]["FeatureDao"]
|
|
1166
|
+
readonly features_title: ((Omit<components["schemas"]["FeatureDao"], "type"> & {
|
|
1167
|
+
label: string;
|
|
1168
|
+
unit?: string;
|
|
1169
|
+
name: string;
|
|
1170
|
+
/** @enum {unknown} */
|
|
1171
|
+
presentation: "value" | "value_unit" | "name_value" | "name";
|
|
1172
|
+
}) | {
|
|
1089
1173
|
slug?: string;
|
|
1090
1174
|
type?: string;
|
|
1091
1175
|
name?: string | null;
|
|
@@ -1100,7 +1184,13 @@ export interface components {
|
|
|
1100
1184
|
redacted: true;
|
|
1101
1185
|
present: boolean;
|
|
1102
1186
|
})[];
|
|
1103
|
-
readonly features_badges: (components["schemas"]["FeatureDao"]
|
|
1187
|
+
readonly features_badges: ((Omit<components["schemas"]["FeatureDao"], "type"> & {
|
|
1188
|
+
label: string;
|
|
1189
|
+
unit?: string;
|
|
1190
|
+
name: string;
|
|
1191
|
+
/** @enum {unknown} */
|
|
1192
|
+
presentation: "value" | "value_unit" | "name_value" | "name";
|
|
1193
|
+
}) | {
|
|
1104
1194
|
slug?: string;
|
|
1105
1195
|
type?: string;
|
|
1106
1196
|
name?: string | null;
|
|
@@ -1199,6 +1289,7 @@ export interface components {
|
|
|
1199
1289
|
features_draft?: {
|
|
1200
1290
|
[key: string]: components["schemas"]["FeatureDto"];
|
|
1201
1291
|
} | null;
|
|
1292
|
+
draft_meta?: unknown;
|
|
1202
1293
|
auto_republish?: boolean;
|
|
1203
1294
|
countable?: boolean;
|
|
1204
1295
|
/** Format: int64 */
|
|
@@ -1648,6 +1739,28 @@ export interface operations {
|
|
|
1648
1739
|
};
|
|
1649
1740
|
};
|
|
1650
1741
|
};
|
|
1742
|
+
listings_api_v1_listings_draft_retrieve: {
|
|
1743
|
+
parameters: {
|
|
1744
|
+
query?: never;
|
|
1745
|
+
header?: never;
|
|
1746
|
+
path: {
|
|
1747
|
+
/** @description A unique integer value identifying this listing. */
|
|
1748
|
+
id: number;
|
|
1749
|
+
};
|
|
1750
|
+
cookie?: never;
|
|
1751
|
+
};
|
|
1752
|
+
requestBody?: never;
|
|
1753
|
+
responses: {
|
|
1754
|
+
200: {
|
|
1755
|
+
headers: {
|
|
1756
|
+
[name: string]: unknown;
|
|
1757
|
+
};
|
|
1758
|
+
content: {
|
|
1759
|
+
"application/json": components["schemas"]["ListingDraft"];
|
|
1760
|
+
};
|
|
1761
|
+
};
|
|
1762
|
+
};
|
|
1763
|
+
};
|
|
1651
1764
|
listings_api_v1_listings_favorite_create: {
|
|
1652
1765
|
parameters: {
|
|
1653
1766
|
query?: never;
|
package/src/api/listingsApi.ts
CHANGED
|
@@ -169,6 +169,33 @@ export interface ListingsApi {
|
|
|
169
169
|
*/
|
|
170
170
|
saveDraft(id: number, body: ListingDraftPatch): Promise<ListingDraft>;
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* The draft twin, read back — `GET {id}/draft/` (stapel-listings 0.21.1),
|
|
174
|
+
* owner-only. Answers the exact `ListingDraftSerializer` shape `save-draft`
|
|
175
|
+
* does, which is what finally lets a reopened listing seed from what was
|
|
176
|
+
* actually typed rather than from the published half (see
|
|
177
|
+
* `headless/ListingComposer.tsx`'s header on why that used to be
|
|
178
|
+
* impossible).
|
|
179
|
+
*
|
|
180
|
+
* A stranger's id 403s; an absent one 404s — same as every other owner
|
|
181
|
+
* read here. A build against an OLDER backend also 404s (the route did not
|
|
182
|
+
* exist yet), and a caller cannot tell the two apart from the response
|
|
183
|
+
* alone; `useListingComposer` treats either as "fall back to the detail
|
|
184
|
+
* seed" rather than surfacing a hard failure, because a reopened draft is
|
|
185
|
+
* strictly better served by the published half than by an error banner.
|
|
186
|
+
*
|
|
187
|
+
* Hand-authored: `stapel-listings` 0.21.1 has not been regenerated into
|
|
188
|
+
* this pair's pinned schema yet, so there is no `Schemas["..."]` for this
|
|
189
|
+
* operation to bind to. The response shape is declared by hand against
|
|
190
|
+
* {@link ListingDraft} — the same type `save-draft` already returns — the
|
|
191
|
+
* way `@stapel/categories-react` hand-declares `children_as` ahead of its
|
|
192
|
+
* own pin.
|
|
193
|
+
*/
|
|
194
|
+
draft(
|
|
195
|
+
id: number,
|
|
196
|
+
options?: { readonly signal?: AbortSignal }
|
|
197
|
+
): Promise<ListingDraft>;
|
|
198
|
+
|
|
172
199
|
/**
|
|
173
200
|
* Ask what publishing WOULD say, without publishing. Same validator as
|
|
174
201
|
* `publish` (`services.publish.validate_draft`), so the two cannot disagree
|
|
@@ -321,6 +348,9 @@ export function createListingsApi(client: StapelClient): ListingsApi {
|
|
|
321
348
|
|
|
322
349
|
saveDraft: (id, body) => client.post(`${listingPath(id)}save-draft/`, body),
|
|
323
350
|
|
|
351
|
+
draft: (id, options) =>
|
|
352
|
+
client.get(`${listingPath(id)}draft/`, signal(options)),
|
|
353
|
+
|
|
324
354
|
validateDraft: (id, options) =>
|
|
325
355
|
client.get(`${listingPath(id)}validate-draft/`, signal(options)),
|
|
326
356
|
|
package/src/api/types.ts
CHANGED
|
@@ -123,8 +123,58 @@ export function engagementIds(ids: readonly number[]): readonly number[] {
|
|
|
123
123
|
return [...seen].sort((a, b) => a - b).slice(0, LISTINGS_ENGAGEMENT_BATCH_LIMIT);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* The stored-feature columns, restated as the runtime shape.
|
|
128
|
+
*
|
|
129
|
+
* `features`, `features_title` and `features_badges` are `JSONField`s (this
|
|
130
|
+
* file's header, note 2): what reaches the wire is what `build_features_list`
|
|
131
|
+
* stored, and the OpenAPI union beside them is a DESCRIPTION swapped in by an
|
|
132
|
+
* extension rather than a filter anything applies. stapel-listings 0.21.3
|
|
133
|
+
* makes that gap load-bearing: its union spells a badge element with `label`,
|
|
134
|
+
* `name` and `presentation` REQUIRED, so a row from a server that predates the
|
|
135
|
+
* contract — the fallback arm `<CardBadges>` exists for, and every row on
|
|
136
|
+
* every listing published before 0.21.3 — no longer satisfies the generated
|
|
137
|
+
* type although it is exactly what a card receives.
|
|
138
|
+
*
|
|
139
|
+
* So the columns are typed as {@link ListingFeatureDao}, the mirror that says
|
|
140
|
+
* what a stored row IS, and the contract keys are read off the generated
|
|
141
|
+
* element instead (`model/cardBadges.ts`) — a rename upstream still breaks the
|
|
142
|
+
* build, without the generated union refusing the older half of the wire.
|
|
143
|
+
*/
|
|
144
|
+
/**
|
|
145
|
+
* The badge element the CARD BADGE CONTRACT describes, as the generated schema
|
|
146
|
+
* spells it (stapel-listings 0.21.3).
|
|
147
|
+
*
|
|
148
|
+
* Taken off `Schemas` rather than the relaxed row above, because this is the
|
|
149
|
+
* one place the contract's own names and readings should still come from the
|
|
150
|
+
* wire: `model/cardBadges.ts` derives its keys from here, so a rename or a
|
|
151
|
+
* fifth `presentation` upstream turns this build red.
|
|
152
|
+
*/
|
|
153
|
+
export type ListingCardBadgeElement = Extract<
|
|
154
|
+
Schemas["ListingCard"]["features_badges"][number],
|
|
155
|
+
{ presentation: unknown }
|
|
156
|
+
>;
|
|
157
|
+
|
|
158
|
+
type StoredFeatureColumn = "features" | "features_title" | "features_badges";
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A stored row AS IT ARRIVES: every key of the mirror, none of them promised.
|
|
162
|
+
*
|
|
163
|
+
* `slug` is required on {@link ListingFeatureDao} because a row without one is
|
|
164
|
+
* unusable — and a JSONField ships one anyway when the projection misses it,
|
|
165
|
+
* which `featureFromDao` reports rather than papering over with a synthesized
|
|
166
|
+
* index. `asFeatureDaoList` is the single narrowing point between the two.
|
|
167
|
+
*/
|
|
168
|
+
export type ListingFeatureRow = Partial<ListingFeatureDao>;
|
|
169
|
+
|
|
170
|
+
type WithStoredFeatures<Row> = Omit<Row, StoredFeatureColumn> & {
|
|
171
|
+
readonly [K in Extract<keyof Row, StoredFeatureColumn>]: readonly ListingFeatureRow[];
|
|
172
|
+
};
|
|
173
|
+
|
|
126
174
|
/** `GET /listings/{pk}/` 200 — everything a detail page reads. */
|
|
127
|
-
export type ListingDetail =
|
|
175
|
+
export type ListingDetail = WithStoredFeatures<
|
|
176
|
+
WithOptionalEngagement<Schemas["ListingDetail"]>
|
|
177
|
+
>;
|
|
128
178
|
|
|
129
179
|
/**
|
|
130
180
|
* One row of a card list (`GET /listings/`, `GET /listings/my/favorites/`) —
|
|
@@ -158,8 +208,8 @@ type WithOptionalPrecision<Row extends { geo_precision_km?: number }> = Omit<
|
|
|
158
208
|
> &
|
|
159
209
|
Partial<Pick<Schemas["ListingCard"], "geo_precision_km">>;
|
|
160
210
|
|
|
161
|
-
export type ListingCard =
|
|
162
|
-
WithOptionalEngagement<Schemas["ListingCard"]
|
|
211
|
+
export type ListingCard = WithStoredFeatures<
|
|
212
|
+
WithOptionalPrecision<WithOptionalEngagement<Schemas["ListingCard"]>>
|
|
163
213
|
>;
|
|
164
214
|
|
|
165
215
|
/** `POST /listings/` request+response and `POST /{pk}/save-draft/` response —
|
|
@@ -216,8 +266,8 @@ export type PaginatedListingCards = Omit<
|
|
|
216
266
|
* tab keyed off them is a column of blank rows. `myListingTitle` /
|
|
217
267
|
* `myListingPrice` (`model/mine.ts`) are the one place the fallback lives.
|
|
218
268
|
*/
|
|
219
|
-
export type MyListingCard =
|
|
220
|
-
WithOptionalEngagement<Schemas["MyListingCard"]
|
|
269
|
+
export type MyListingCard = WithStoredFeatures<
|
|
270
|
+
WithOptionalPrecision<WithOptionalEngagement<Schemas["MyListingCard"]>>
|
|
221
271
|
>;
|
|
222
272
|
|
|
223
273
|
/** The keyset envelope `GET /listings/my/listings/` comes back in — the same
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A card's badge line, drawn against the CARD BADGE CONTRACT when the server
|
|
3
|
+
* speaks it and exactly as it draws today when it does not.
|
|
4
|
+
*
|
|
5
|
+
* The contract itself — the five keys, the four presentations, and the
|
|
6
|
+
* measured "Brick · 3 · 9" that earned it — is `model/cardBadges.ts`.
|
|
7
|
+
* This file is only the two arms:
|
|
8
|
+
*
|
|
9
|
+
* - **the contract arm.** Each element already carries everything a reader
|
|
10
|
+
* needs (its name, its unit, its resolved copy, and the server's decision
|
|
11
|
+
* about which of them to print), so the text comes from `cardBadgeText`
|
|
12
|
+
* and nothing here reaches for a category, a formatter or an option table;
|
|
13
|
+
* - **the fallback arm.** No element declares a `presentation`, which means
|
|
14
|
+
* a backend older than stapel-listings 0.21.3 — so the row goes through
|
|
15
|
+
* `@stapel/attributes-react`'s `<FeatureBadges>` off the stored DAO's own
|
|
16
|
+
* config, byte-identical to the release before this one.
|
|
17
|
+
*
|
|
18
|
+
* One component, called by all three card surfaces, for the same reason
|
|
19
|
+
* `CardTarget` is one function: three cards each deciding what a badge says
|
|
20
|
+
* is three places for the bare numbers to come back.
|
|
21
|
+
*/
|
|
22
|
+
import type { ReactElement } from "react";
|
|
23
|
+
import { Flex, Tag, Typography } from "antd";
|
|
24
|
+
import { SkinTheme } from "@stapel/tokens-antd/skin";
|
|
25
|
+
import { useI18n } from "@stapel/core";
|
|
26
|
+
import { spacing } from "@stapel/tokens";
|
|
27
|
+
import { FeatureBadges } from "@stapel/attributes-react/default";
|
|
28
|
+
import type { ListingFeatureDao } from "../api/types.js";
|
|
29
|
+
import type { CardBadgeRow } from "../model/cardBadges.js";
|
|
30
|
+
import { cardBadgeTexts, hasCardBadgeContract } from "../model/cardBadges.js";
|
|
31
|
+
import { featuresDtoFromDaoList, featuresFromDaoList } from "../model/features.js";
|
|
32
|
+
import type { FeatureCopySource } from "../model/features.js";
|
|
33
|
+
|
|
34
|
+
export interface CardBadgesProps {
|
|
35
|
+
/** The stored projection — `features_badges` or `features_title`. */
|
|
36
|
+
readonly rows: readonly ListingFeatureDao[];
|
|
37
|
+
/** The category's option table, where the surface has one. Used by the
|
|
38
|
+
* FALLBACK arm only: a contract element carries its own resolved copy. */
|
|
39
|
+
readonly copy: FeatureCopySource;
|
|
40
|
+
/**
|
|
41
|
+
* `"badges"` draws tags (the card's badge strip); `"line"` draws one
|
|
42
|
+
* dot-separated run of secondary text (the seller's own spec line under
|
|
43
|
+
* the title, which is already a line on every surface).
|
|
44
|
+
*/
|
|
45
|
+
readonly variant: "badges" | "line";
|
|
46
|
+
readonly testId?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The separator of a spec line — the one every classified uses. */
|
|
50
|
+
const LINE_SEPARATOR = " · ";
|
|
51
|
+
|
|
52
|
+
export function CardBadges(props: CardBadgesProps): ReactElement | null {
|
|
53
|
+
const { locale } = useI18n();
|
|
54
|
+
const rows = props.rows as readonly CardBadgeRow[];
|
|
55
|
+
|
|
56
|
+
if (rows.length === 0) return null;
|
|
57
|
+
|
|
58
|
+
if (hasCardBadgeContract(rows)) {
|
|
59
|
+
const printed = cardBadgeTexts(rows, locale);
|
|
60
|
+
if (printed.length === 0) return null;
|
|
61
|
+
if (props.variant === "line") {
|
|
62
|
+
return (
|
|
63
|
+
<span data-testid={props.testId ?? "listings-card-badges"}>
|
|
64
|
+
{printed.map((one) => one.text).join(LINE_SEPARATOR)}
|
|
65
|
+
</span>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return (
|
|
69
|
+
<SkinTheme surface="bare">
|
|
70
|
+
<Flex gap={spacing[1]} wrap data-testid={props.testId ?? "listings-card-badges"}>
|
|
71
|
+
{printed.map((one) => (
|
|
72
|
+
<Tag key={one.slug} data-testid={`listings-card-badge-${one.slug}`}>
|
|
73
|
+
{one.text}
|
|
74
|
+
</Tag>
|
|
75
|
+
))}
|
|
76
|
+
</Flex>
|
|
77
|
+
</SkinTheme>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The older backend. Exactly what shipped before the contract existed.
|
|
82
|
+
const views = featuresFromDaoList(rows, props.copy);
|
|
83
|
+
if (views.length === 0) return null;
|
|
84
|
+
return (
|
|
85
|
+
<FeatureBadges
|
|
86
|
+
features={views.map((view) => view.feature)}
|
|
87
|
+
values={featuresDtoFromDaoList(rows)}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The spec line under a card's title, wrapped in the secondary, truncating
|
|
93
|
+
* text every surface already draws it in. */
|
|
94
|
+
export function CardSpecLine(props: {
|
|
95
|
+
readonly rows: readonly ListingFeatureDao[];
|
|
96
|
+
readonly copy: FeatureCopySource;
|
|
97
|
+
readonly testId: string;
|
|
98
|
+
}): ReactElement | null {
|
|
99
|
+
if (props.rows.length === 0) return null;
|
|
100
|
+
return (
|
|
101
|
+
<Typography.Text type="secondary" ellipsis data-testid={props.testId}>
|
|
102
|
+
<CardBadges rows={props.rows} copy={props.copy} variant="line" testId={`${props.testId}-text`} />
|
|
103
|
+
</Typography.Text>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -43,6 +43,16 @@ import { ListingCard } from "./ListingCard.js";
|
|
|
43
43
|
import type { ListingCardOpenProps } from "./ListingCard.js";
|
|
44
44
|
import { SignInLink } from "./SignInLink.js";
|
|
45
45
|
import type { ThemeModeProp } from "./types.js";
|
|
46
|
+
import type { ListingCard as ListingCardRow } from "../api/types.js";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The row `hrefFor` is called with as its second argument — the same
|
|
50
|
+
* {@link ListingCardRow} the grid renders each card from. `title` is
|
|
51
|
+
* guaranteed present here: a favourite is always a PUBLISHED listing (a
|
|
52
|
+
* draft has nothing to favourite), so the public card's `title` is never the
|
|
53
|
+
* blank the wire allows for an unpublished one.
|
|
54
|
+
*/
|
|
55
|
+
export type FavoritesHrefRow = ListingCardRow & { title: string };
|
|
46
56
|
|
|
47
57
|
/** The narrowest a card may get before the grid drops a column — the same
|
|
48
58
|
* element-relative rule the detail gallery uses. It was `width: 240`, which
|
|
@@ -59,8 +69,13 @@ export const FAVORITES_MEASURE = "72rem";
|
|
|
59
69
|
* card no longer allows. */
|
|
60
70
|
export type FavoritesPaneOpenProps =
|
|
61
71
|
| {
|
|
62
|
-
/**
|
|
63
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Where a card leads. The container owns routing. Called with the
|
|
74
|
+
* row's id and the row itself ({@link FavoritesHrefRow}, `title`
|
|
75
|
+
* guaranteed present) — a container that only needs the id keeps
|
|
76
|
+
* working unchanged.
|
|
77
|
+
*/
|
|
78
|
+
readonly hrefFor: (id: number, row: FavoritesHrefRow) => string;
|
|
64
79
|
/** The host's `<Link>`, so a click stays inside the SPA. */
|
|
65
80
|
readonly linkComponent?: LinkComponent;
|
|
66
81
|
readonly onOpen?: undefined;
|
|
@@ -95,10 +110,10 @@ export type FavoritesPaneProps = ThemeModeProp &
|
|
|
95
110
|
/** The card's own open props for one row. One arm, never two. */
|
|
96
111
|
function cardOpenProps(
|
|
97
112
|
props: FavoritesPaneOpenProps,
|
|
98
|
-
|
|
113
|
+
row: FavoritesHrefRow
|
|
99
114
|
): ListingCardOpenProps {
|
|
100
115
|
if (props.hrefFor !== undefined) {
|
|
101
|
-
const href = props.hrefFor(id);
|
|
116
|
+
const href = props.hrefFor(row.id, row);
|
|
102
117
|
return props.linkComponent !== undefined
|
|
103
118
|
? { href, linkComponent: props.linkComponent }
|
|
104
119
|
: { href };
|
|
@@ -180,7 +195,7 @@ export function FavoritesPane(props: FavoritesPaneProps): ReactElement {
|
|
|
180
195
|
listing={row}
|
|
181
196
|
blockedReason="line"
|
|
182
197
|
{...(props.signIn !== undefined ? { signIn: props.signIn } : {})}
|
|
183
|
-
{...cardOpenProps(props, row
|
|
198
|
+
{...cardOpenProps(props, row as FavoritesHrefRow)}
|
|
184
199
|
/>
|
|
185
200
|
))}
|
|
186
201
|
</div>
|
|
@@ -116,15 +116,15 @@ import {
|
|
|
116
116
|
import { useActionGate, useT } from "@stapel/core";
|
|
117
117
|
import type { LinkComponent, SignInCtaProp } from "@stapel/core";
|
|
118
118
|
import { spacing } from "@stapel/tokens";
|
|
119
|
-
import { FeatureBadges } from "@stapel/attributes-react/default";
|
|
120
119
|
import type { ListingCard as ListingCardData } from "../api/types.js";
|
|
121
|
-
import { asFeatureDaoList
|
|
120
|
+
import { asFeatureDaoList } from "../model/features.js";
|
|
122
121
|
import type { FeatureCopySource } from "../model/features.js";
|
|
123
122
|
import { lifecycleCaption } from "../model/status.js";
|
|
124
123
|
import { isListingViewed } from "../model/engagement.js";
|
|
125
124
|
import { useEngagedListing } from "../headless/Engagement.js";
|
|
126
125
|
import { useFavoriteToggle } from "../headless/Favorites.js";
|
|
127
126
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
127
|
+
import { CardBadges, CardSpecLine } from "./CardBadges.js";
|
|
128
128
|
import { GateReasonPopover } from "./GateReasonPopover.js";
|
|
129
129
|
import { HeartIcon } from "./icons.js";
|
|
130
130
|
import { SignInLink } from "./SignInLink.js";
|
|
@@ -539,13 +539,13 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
539
539
|
const badgeDaos = asFeatureDaoList(listing.features_badges);
|
|
540
540
|
// The category's own option table, when the surface has it: a stored
|
|
541
541
|
// `select` carries no table of its own, so without this a badge prints the
|
|
542
|
-
// storage slug. Absent on a mixed grid, which is why it is optional.
|
|
542
|
+
// storage slug. Absent on a mixed grid, which is why it is optional. Read
|
|
543
|
+
// by the FALLBACK arm of `<CardBadges>` only — an element that speaks the
|
|
544
|
+
// card badge contract carries its own resolved copy.
|
|
543
545
|
const copy: FeatureCopySource =
|
|
544
546
|
props.categoryFeatures !== undefined
|
|
545
547
|
? { categoryFeatures: props.categoryFeatures }
|
|
546
548
|
: {};
|
|
547
|
-
const badgeFeatures = featuresFromDaoList(badgeDaos, copy);
|
|
548
|
-
const badgeValues = featuresDtoFromDaoList(badgeDaos);
|
|
549
549
|
const titleDaos = asFeatureDaoList(listing.features_title);
|
|
550
550
|
|
|
551
551
|
const status = listing.status === undefined ? undefined : lifecycleCaption(listing.status);
|
|
@@ -610,23 +610,9 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
610
610
|
|
|
611
611
|
{/* The title features are a stored projection too — the seller's
|
|
612
612
|
"1.5 TB, black" line, already ordered by the server. */}
|
|
613
|
-
{titleDaos
|
|
614
|
-
<Typography.Text type="secondary" ellipsis>
|
|
615
|
-
<FeatureBadges
|
|
616
|
-
features={featuresFromDaoList(titleDaos, copy).map(
|
|
617
|
-
(view) => view.feature
|
|
618
|
-
)}
|
|
619
|
-
values={featuresDtoFromDaoList(titleDaos)}
|
|
620
|
-
/>
|
|
621
|
-
</Typography.Text>
|
|
622
|
-
) : null}
|
|
613
|
+
<CardSpecLine rows={titleDaos} copy={copy} testId="listings-card-specs" />
|
|
623
614
|
|
|
624
|
-
{
|
|
625
|
-
<FeatureBadges
|
|
626
|
-
features={badgeFeatures.map((view) => view.feature)}
|
|
627
|
-
values={badgeValues}
|
|
628
|
-
/>
|
|
629
|
-
) : null}
|
|
615
|
+
<CardBadges rows={badgeDaos} copy={copy} variant="badges" />
|
|
630
616
|
|
|
631
617
|
{/* ONE line, and it truncates (D185). Measured on a live 1440px feed:
|
|
632
618
|
a two-part city-and-district name wrapped, the text block grew
|
|
@@ -69,13 +69,14 @@ import {
|
|
|
69
69
|
} from "@stapel/core";
|
|
70
70
|
import type { SignInCta } from "@stapel/core";
|
|
71
71
|
import { spacing } from "@stapel/tokens";
|
|
72
|
-
import {
|
|
73
|
-
import { formatFeatureValue, isRedactedValue } from "@stapel/attributes-react";
|
|
72
|
+
import { isRedactedValue } from "@stapel/attributes-react";
|
|
74
73
|
import { useListingDetail } from "../headless/ListingDetail.js";
|
|
75
74
|
import { useListingActions } from "../headless/ListingActions.js";
|
|
76
75
|
import { asFeatureDaoList, featureValuesForDisplay } from "../model/features.js";
|
|
76
|
+
import { formatSpecValue } from "../model/featureText.js";
|
|
77
77
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
78
78
|
import { GateReasonPopover } from "./GateReasonPopover.js";
|
|
79
|
+
import { ListingSpecColumns, ListingSpecList } from "./ListingSpecList.js";
|
|
79
80
|
import { SignInLink } from "./SignInLink.js";
|
|
80
81
|
import { HeartIcon } from "./icons.js";
|
|
81
82
|
import { ListingPhoto } from "./ListingPhoto.js";
|
|
@@ -338,7 +339,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
338
339
|
// leaked identifier would be read out loud.
|
|
339
340
|
.filter((view) => !isRedactedValue(view.value))
|
|
340
341
|
.map((view) =>
|
|
341
|
-
|
|
342
|
+
formatSpecValue(view.feature, view.value, { t, locale })
|
|
342
343
|
)
|
|
343
344
|
.filter((text): text is string => text !== undefined)
|
|
344
345
|
.join(" · ")}
|
|
@@ -548,47 +549,22 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
548
549
|
? { categoryFeatures: props.categoryFeatures }
|
|
549
550
|
: {}
|
|
550
551
|
);
|
|
551
|
-
/* Two spec columns in the split
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
552
|
+
/* Two spec columns in the split — a grid of whole ROWS, cut by
|
|
553
|
+
row count so the category's declaration order survives: the
|
|
554
|
+
first (larger) half fills the left list and the page reads
|
|
555
|
+
top-to-bottom, left column first, exactly as the one-column
|
|
556
|
+
list reads. The label is never a column of its own; see
|
|
557
|
+
`<ListingSpecList>` for the shape and why the table went. */
|
|
558
|
+
const specFeatures = bag.features.map((view) => view.feature);
|
|
557
559
|
const specs =
|
|
558
560
|
bag.features.length === 0 ? (
|
|
559
561
|
<Typography.Text type="secondary" data-testid="listings-detail-no-specs">
|
|
560
562
|
{t(LISTINGS_I18N_KEYS.detailNoSpecs)}
|
|
561
563
|
</Typography.Text>
|
|
562
564
|
) : split ? (
|
|
563
|
-
<
|
|
564
|
-
data-testid="listings-detail-specs-split"
|
|
565
|
-
style={{
|
|
566
|
-
display: "grid",
|
|
567
|
-
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
|
568
|
-
gap: spacing[4],
|
|
569
|
-
alignItems: "start",
|
|
570
|
-
}}
|
|
571
|
-
>
|
|
572
|
-
<FeatureValueList
|
|
573
|
-
features={bag.features
|
|
574
|
-
.slice(0, specHalf)
|
|
575
|
-
.map((view) => view.feature)}
|
|
576
|
-
values={specValues}
|
|
577
|
-
/>
|
|
578
|
-
{bag.features.length > specHalf ? (
|
|
579
|
-
<FeatureValueList
|
|
580
|
-
features={bag.features
|
|
581
|
-
.slice(specHalf)
|
|
582
|
-
.map((view) => view.feature)}
|
|
583
|
-
values={specValues}
|
|
584
|
-
/>
|
|
585
|
-
) : null}
|
|
586
|
-
</div>
|
|
565
|
+
<ListingSpecColumns features={specFeatures} values={specValues} />
|
|
587
566
|
) : (
|
|
588
|
-
<
|
|
589
|
-
features={bag.features.map((view) => view.feature)}
|
|
590
|
-
values={specValues}
|
|
591
|
-
/>
|
|
567
|
+
<ListingSpecList features={specFeatures} values={specValues} />
|
|
592
568
|
);
|
|
593
569
|
|
|
594
570
|
const specsSection = (
|