@stapel/listings-react 0.21.0 → 0.22.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 +63 -0
- package/dist/api/generated/schema.d.ts +125 -2
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/listingsApi.d.ts +20 -1
- 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 +32 -2
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js +7 -0
- package/dist/api/types.js.map +1 -1
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +54 -5
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +14 -4
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/MyListingsPane.d.ts +19 -1
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +99 -12
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/headless/ListingActions.d.ts +64 -13
- package/dist/headless/ListingActions.d.ts.map +1 -1
- package/dist/headless/ListingActions.js +60 -5
- package/dist/headless/ListingActions.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +8 -2
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +20 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +52 -1
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +12 -2
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/model/mine.d.ts +20 -0
- package/dist/model/mine.d.ts.map +1 -1
- package/dist/model/mine.js +22 -0
- package/dist/model/mine.js.map +1 -1
- package/dist/model/mutations.d.ts +24 -1
- package/dist/model/mutations.d.ts.map +1 -1
- package/dist/model/mutations.js +28 -0
- package/dist/model/mutations.js.map +1 -1
- package/dist/model/status.d.ts.map +1 -1
- package/dist/model/status.js +26 -9
- package/dist/model/status.js.map +1 -1
- package/dist/model/transitions.d.ts +38 -1
- package/dist/model/transitions.d.ts.map +1 -1
- package/dist/model/transitions.js +73 -1
- package/dist/model/transitions.js.map +1 -1
- package/llms.txt +4 -2
- package/manifest.json +26 -2
- package/nav-manifest.json +1 -1
- package/package.json +4 -4
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +125 -2
- package/src/api/listingsApi.ts +27 -0
- package/src/api/types.ts +47 -2
- package/src/default/ListingCard.tsx +73 -5
- package/src/default/ListingSerpCard.tsx +17 -4
- package/src/default/MyListingsPane.tsx +205 -39
- package/src/headless/ListingActions.tsx +137 -16
- package/src/i18n/es.ts +8 -2
- package/src/i18n/keys.ts +52 -1
- package/src/i18n/ru.ts +12 -2
- package/src/index.ts +19 -3
- package/src/model/mine.ts +23 -0
- package/src/model/mutations.ts +41 -0
- package/src/model/status.ts +27 -9
- package/src/model/transitions.ts +80 -1
|
@@ -260,6 +260,40 @@ export interface paths {
|
|
|
260
260
|
patch?: never;
|
|
261
261
|
trace?: never;
|
|
262
262
|
};
|
|
263
|
+
"/listings/api/v1/listings/{id}/transition/": {
|
|
264
|
+
parameters: {
|
|
265
|
+
query?: never;
|
|
266
|
+
header?: never;
|
|
267
|
+
path?: never;
|
|
268
|
+
cookie?: never;
|
|
269
|
+
};
|
|
270
|
+
get?: never;
|
|
271
|
+
put?: never;
|
|
272
|
+
/**
|
|
273
|
+
* @description Move the listing to ``to``, if that is a move its OWNER may make.
|
|
274
|
+
*
|
|
275
|
+
* The seller's way forward, as one route instead of one endpoint per
|
|
276
|
+
* edge. ``archive`` and ``complete`` were the only two the API ever
|
|
277
|
+
* offered, and both of them are exits — so a listing that reached
|
|
278
|
+
* ARCHIVED, PAUSED, EXPIRED, SOLD, REJECTED or BLOCKED had no call left
|
|
279
|
+
* that would move it, and the cabinet correctly showed its owner
|
|
280
|
+
* nothing but «удалить».
|
|
281
|
+
*
|
|
282
|
+
* Which moves exist is ``models.OWNER_TRANSITIONS``, and it is the same
|
|
283
|
+
* list ``available_transitions`` puts on the card — the point of routing
|
|
284
|
+
* every edge through one allowlist is that the set a client is offered
|
|
285
|
+
* and the set the server accepts are one object, not two that agree
|
|
286
|
+
* today.
|
|
287
|
+
*
|
|
288
|
+
* **Permissions:** `IsAuthenticated`
|
|
289
|
+
*/
|
|
290
|
+
post: operations["listings_api_v1_listings_transition_create"];
|
|
291
|
+
delete?: never;
|
|
292
|
+
options?: never;
|
|
293
|
+
head?: never;
|
|
294
|
+
patch?: never;
|
|
295
|
+
trace?: never;
|
|
296
|
+
};
|
|
263
297
|
"/listings/api/v1/listings/{id}/unfavorite/": {
|
|
264
298
|
parameters: {
|
|
265
299
|
query?: never;
|
|
@@ -437,6 +471,8 @@ export interface paths {
|
|
|
437
471
|
export type webhooks = Record<string, never>;
|
|
438
472
|
export interface components {
|
|
439
473
|
schemas: {
|
|
474
|
+
/** @enum {string} */
|
|
475
|
+
AvailableTransitionsEnum: "draft" | "pending" | "published" | "paused" | "expired" | "sold" | "rejected" | "blocked" | "archived";
|
|
440
476
|
/** @description Serializer for boolean feature DAO. */
|
|
441
477
|
BoolDao: {
|
|
442
478
|
name?: string | null;
|
|
@@ -815,6 +851,11 @@ export interface components {
|
|
|
815
851
|
lat?: string | null;
|
|
816
852
|
/** Format: decimal */
|
|
817
853
|
lon?: string | null;
|
|
854
|
+
/**
|
|
855
|
+
* Format: double
|
|
856
|
+
* @description How wide the area `lat`/`lon` describe, in kilometres. On a PUBLIC read this is ~1.113 (the pair is rounded to two decimals and `geohash` comes back empty): draw a CIRCLE, never a marker — the listing is somewhere in it, and for a private seller the true point is a home address. `0` means the exact point, which only the listing's own owner, staff and the service transport get. Proximity itself is unaffected: `distance_km` on a search hit is computed server-side from the true coordinates.
|
|
857
|
+
*/
|
|
858
|
+
readonly geo_precision_km: number;
|
|
818
859
|
countable?: boolean;
|
|
819
860
|
/** Format: int64 */
|
|
820
861
|
stock_quantity?: number | null;
|
|
@@ -845,6 +886,11 @@ export interface components {
|
|
|
845
886
|
lat?: string | null;
|
|
846
887
|
/** Format: decimal */
|
|
847
888
|
lon?: string | null;
|
|
889
|
+
/**
|
|
890
|
+
* Format: double
|
|
891
|
+
* @description How wide the area `lat`/`lon` describe, in kilometres. On a PUBLIC read this is ~1.113 (the pair is rounded to two decimals and `geohash` comes back empty): draw a CIRCLE, never a marker — the listing is somewhere in it, and for a private seller the true point is a home address. `0` means the exact point, which only the listing's own owner, staff and the service transport get. Proximity itself is unaffected: `distance_km` on a search hit is computed server-side from the true coordinates.
|
|
892
|
+
*/
|
|
893
|
+
readonly geo_precision_km: number;
|
|
848
894
|
readonly features: (components["schemas"]["FeatureDao"] | {
|
|
849
895
|
slug?: string;
|
|
850
896
|
type?: string;
|
|
@@ -981,13 +1027,29 @@ export interface components {
|
|
|
981
1027
|
owner_id: string;
|
|
982
1028
|
};
|
|
983
1029
|
/**
|
|
984
|
-
* @description
|
|
1030
|
+
* @description The body of ``POST listings/{id}/transition/``: where to move it.
|
|
1031
|
+
*
|
|
1032
|
+
* A ``ChoiceField`` over the whole lifecycle rather than over the moves this
|
|
1033
|
+
* particular listing has, because the two refusals are different sentences
|
|
1034
|
+
* and a client should be able to tell them apart: a status that does not
|
|
1035
|
+
* exist is a 400 (the caller is confused about the vocabulary), a status
|
|
1036
|
+
* that exists but is not this listing's to reach is a 409 with
|
|
1037
|
+
* ``from_status`` (the caller is confused about the row). Narrowing the
|
|
1038
|
+
* field to the per-row set would collapse both into 400 and lose the
|
|
1039
|
+
* ``from_status`` that tells a storefront what to re-render.
|
|
1040
|
+
*/
|
|
1041
|
+
ListingTransitionRequest: {
|
|
1042
|
+
to: components["schemas"]["ToEnum"];
|
|
1043
|
+
};
|
|
1044
|
+
/**
|
|
1045
|
+
* @description * `not_submitted` - Not submitted for review
|
|
1046
|
+
* * `pending` - Pending Review
|
|
985
1047
|
* * `approved` - Approved
|
|
986
1048
|
* * `rejected` - Rejected
|
|
987
1049
|
* * `needs_review` - Needs Manual Review
|
|
988
1050
|
* @enum {string}
|
|
989
1051
|
*/
|
|
990
|
-
ModerationStatusEnum: "pending" | "approved" | "rejected" | "needs_review";
|
|
1052
|
+
ModerationStatusEnum: "not_submitted" | "pending" | "approved" | "rejected" | "needs_review";
|
|
991
1053
|
/** @description Listing counts by tab for the current user. */
|
|
992
1054
|
MyCountersResponse: {
|
|
993
1055
|
active: number;
|
|
@@ -1059,6 +1121,11 @@ export interface components {
|
|
|
1059
1121
|
lat?: string | null;
|
|
1060
1122
|
/** Format: decimal */
|
|
1061
1123
|
lon?: string | null;
|
|
1124
|
+
/**
|
|
1125
|
+
* Format: double
|
|
1126
|
+
* @description How wide the area `lat`/`lon` describe, in kilometres. On a PUBLIC read this is ~1.113 (the pair is rounded to two decimals and `geohash` comes back empty): draw a CIRCLE, never a marker — the listing is somewhere in it, and for a private seller the true point is a home address. `0` means the exact point, which only the listing's own owner, staff and the service transport get. Proximity itself is unaffected: `distance_km` on a search hit is computed server-side from the true coordinates.
|
|
1127
|
+
*/
|
|
1128
|
+
readonly geo_precision_km: number;
|
|
1062
1129
|
countable?: boolean;
|
|
1063
1130
|
/** Format: int64 */
|
|
1064
1131
|
stock_quantity?: number | null;
|
|
@@ -1067,6 +1134,7 @@ export interface components {
|
|
|
1067
1134
|
readonly viewed: boolean | null;
|
|
1068
1135
|
readonly view_count: number;
|
|
1069
1136
|
moderation_status?: components["schemas"]["ModerationStatusEnum"];
|
|
1137
|
+
readonly available_transitions: components["schemas"]["AvailableTransitionsEnum"][];
|
|
1070
1138
|
title_draft?: string;
|
|
1071
1139
|
/** Format: decimal */
|
|
1072
1140
|
price_draft?: string | null;
|
|
@@ -1286,6 +1354,19 @@ export interface components {
|
|
|
1286
1354
|
* @enum {string}
|
|
1287
1355
|
*/
|
|
1288
1356
|
StyleEnum: "l" | "m";
|
|
1357
|
+
/**
|
|
1358
|
+
* @description * `draft` - Draft
|
|
1359
|
+
* * `pending` - Pending Moderation
|
|
1360
|
+
* * `published` - Published
|
|
1361
|
+
* * `paused` - Paused
|
|
1362
|
+
* * `expired` - Expired
|
|
1363
|
+
* * `sold` - Sold
|
|
1364
|
+
* * `rejected` - Rejected
|
|
1365
|
+
* * `blocked` - Blocked (moderation takedown)
|
|
1366
|
+
* * `archived` - Archived
|
|
1367
|
+
* @enum {string}
|
|
1368
|
+
*/
|
|
1369
|
+
ToEnum: "draft" | "pending" | "published" | "paused" | "expired" | "sold" | "rejected" | "blocked" | "archived";
|
|
1289
1370
|
/**
|
|
1290
1371
|
* @description * `convertible_unit` - convertible_unit
|
|
1291
1372
|
* @enum {string}
|
|
@@ -1663,6 +1744,48 @@ export interface operations {
|
|
|
1663
1744
|
};
|
|
1664
1745
|
};
|
|
1665
1746
|
};
|
|
1747
|
+
listings_api_v1_listings_transition_create: {
|
|
1748
|
+
parameters: {
|
|
1749
|
+
query?: never;
|
|
1750
|
+
header?: never;
|
|
1751
|
+
path: {
|
|
1752
|
+
/** @description A unique integer value identifying this listing. */
|
|
1753
|
+
id: number;
|
|
1754
|
+
};
|
|
1755
|
+
cookie?: never;
|
|
1756
|
+
};
|
|
1757
|
+
requestBody: {
|
|
1758
|
+
content: {
|
|
1759
|
+
"application/json": components["schemas"]["ListingTransitionRequest"];
|
|
1760
|
+
"application/x-www-form-urlencoded": components["schemas"]["ListingTransitionRequest"];
|
|
1761
|
+
"multipart/form-data": components["schemas"]["ListingTransitionRequest"];
|
|
1762
|
+
};
|
|
1763
|
+
};
|
|
1764
|
+
responses: {
|
|
1765
|
+
200: {
|
|
1766
|
+
headers: {
|
|
1767
|
+
[name: string]: unknown;
|
|
1768
|
+
};
|
|
1769
|
+
content: {
|
|
1770
|
+
"application/json": components["schemas"]["ListingActionResponse"];
|
|
1771
|
+
};
|
|
1772
|
+
};
|
|
1773
|
+
/** @description No response body */
|
|
1774
|
+
400: {
|
|
1775
|
+
headers: {
|
|
1776
|
+
[name: string]: unknown;
|
|
1777
|
+
};
|
|
1778
|
+
content?: never;
|
|
1779
|
+
};
|
|
1780
|
+
/** @description No response body */
|
|
1781
|
+
409: {
|
|
1782
|
+
headers: {
|
|
1783
|
+
[name: string]: unknown;
|
|
1784
|
+
};
|
|
1785
|
+
content?: never;
|
|
1786
|
+
};
|
|
1787
|
+
};
|
|
1788
|
+
};
|
|
1666
1789
|
listings_api_v1_listings_unfavorite_create: {
|
|
1667
1790
|
parameters: {
|
|
1668
1791
|
query?: never;
|
package/src/api/listingsApi.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
ListingEngagementBatch,
|
|
8
8
|
ListingDraft,
|
|
9
9
|
ListingDraftPatch,
|
|
10
|
+
ListingOwnerTransition,
|
|
10
11
|
ListingPageParams,
|
|
11
12
|
ListingStatusInfo,
|
|
12
13
|
MyCounters,
|
|
@@ -204,6 +205,29 @@ export interface ListingsApi {
|
|
|
204
205
|
/** Mark SOLD. Same 409 contract as {@link archive}. */
|
|
205
206
|
complete(id: number): Promise<ListingActionResponse>;
|
|
206
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Move the listing to `to` — ONE route for every edge a seller owns
|
|
210
|
+
* (`POST listings/{id}/transition/`, stapel-listings 0.20.0).
|
|
211
|
+
*
|
|
212
|
+
* {@link archive} and {@link complete} are still here and still work; they
|
|
213
|
+
* are two named exits, and for two releases they were the ONLY two the
|
|
214
|
+
* owner API had. That is what made a cabinet a one-way door: every status a
|
|
215
|
+
* seller could put a listing INTO — SOLD, ARCHIVED, PAUSED, EXPIRED — was
|
|
216
|
+
* one no call could get it out of, and `DELETE` was the only answer left.
|
|
217
|
+
*
|
|
218
|
+
* The set this accepts is `models.OWNER_TRANSITIONS`, which is the same set
|
|
219
|
+
* `MyListingCard.available_transitions` reports, deliberately: the moves a
|
|
220
|
+
* client is offered and the moves the server takes are one object rather
|
|
221
|
+
* than two that agree today. Two refusals, and they are different
|
|
222
|
+
* sentences — a status that does not exist is a 400, a status that exists
|
|
223
|
+
* but is not this row's to reach is `error.409.invalid_listing_transition`
|
|
224
|
+
* with `params.from_status`.
|
|
225
|
+
*/
|
|
226
|
+
transition(
|
|
227
|
+
id: number,
|
|
228
|
+
to: ListingOwnerTransition
|
|
229
|
+
): Promise<ListingActionResponse>;
|
|
230
|
+
|
|
207
231
|
/** Favourite a listing. Idempotent server-side (`get_or_create`). */
|
|
208
232
|
favorite(id: number): Promise<FavoriteToggleResponse>;
|
|
209
233
|
|
|
@@ -306,6 +330,9 @@ export function createListingsApi(client: StapelClient): ListingsApi {
|
|
|
306
330
|
|
|
307
331
|
complete: (id) => client.post(`${listingPath(id)}complete/`),
|
|
308
332
|
|
|
333
|
+
transition: (id, to) =>
|
|
334
|
+
client.post(`${listingPath(id)}transition/`, { to }),
|
|
335
|
+
|
|
309
336
|
favorite: (id) => client.post(`${listingPath(id)}favorite/`),
|
|
310
337
|
|
|
311
338
|
unfavorite: (id) => client.post(`${listingPath(id)}unfavorite/`),
|
package/src/api/types.ts
CHANGED
|
@@ -140,8 +140,27 @@ export type ListingDetail = WithOptionalEngagement<Schemas["ListingDetail"]>;
|
|
|
140
140
|
* type, for data no one can supply; it is precisely the case
|
|
141
141
|
* `<ListingEngagementScope>` exists to answer. A deployment running a server
|
|
142
142
|
* older than 0.16 is the same shape of fact.
|
|
143
|
+
*
|
|
144
|
+
* `geo_precision_km` is relaxed for the same reason and by the same argument.
|
|
145
|
+
* stapel-listings 0.21.0 puts it on every card this module serves — how wide
|
|
146
|
+
* an area `lat`/`lon` describe, so a reader draws a circle instead of pinning
|
|
147
|
+
* a private seller's front door with a marker — and the SEARCH document does
|
|
148
|
+
* not carry it, because stapel-search coarsens its own card independently
|
|
149
|
+
* (`CARD_COORD_PRECISION`, the same ~1.1km). Requiring it on the prop would
|
|
150
|
+
* make the pair's primary consumer unable to satisfy its own type for a
|
|
151
|
+
* number nobody in that path holds. A reader that needs the precision must
|
|
152
|
+
* treat its absence as UNSTATED and never as `0`: `0` means the exact point,
|
|
153
|
+
* which is the one reading that could publish an address.
|
|
143
154
|
*/
|
|
144
|
-
|
|
155
|
+
type WithOptionalPrecision<Row extends { geo_precision_km?: number }> = Omit<
|
|
156
|
+
Row,
|
|
157
|
+
"geo_precision_km"
|
|
158
|
+
> &
|
|
159
|
+
Partial<Pick<Schemas["ListingCard"], "geo_precision_km">>;
|
|
160
|
+
|
|
161
|
+
export type ListingCard = WithOptionalPrecision<
|
|
162
|
+
WithOptionalEngagement<Schemas["ListingCard"]>
|
|
163
|
+
>;
|
|
145
164
|
|
|
146
165
|
/** `POST /listings/` request+response and `POST /{pk}/save-draft/` response —
|
|
147
166
|
* the draft twin. Every user-editable field is a `*_draft` one, promoted onto
|
|
@@ -197,7 +216,9 @@ export type PaginatedListingCards = Omit<
|
|
|
197
216
|
* tab keyed off them is a column of blank rows. `myListingTitle` /
|
|
198
217
|
* `myListingPrice` (`model/mine.ts`) are the one place the fallback lives.
|
|
199
218
|
*/
|
|
200
|
-
export type MyListingCard =
|
|
219
|
+
export type MyListingCard = WithOptionalPrecision<
|
|
220
|
+
WithOptionalEngagement<Schemas["MyListingCard"]>
|
|
221
|
+
>;
|
|
201
222
|
|
|
202
223
|
/** The keyset envelope `GET /listings/my/listings/` comes back in — the same
|
|
203
224
|
* `IDAnchorPagination` shape as {@link PaginatedListingCards}, over the owner
|
|
@@ -232,9 +253,33 @@ export const LISTING_STATUSES: readonly ListingLifecycleStatus[] = [
|
|
|
232
253
|
* only one (`models.py`: "no visibility-reads-moderation_status coupling"). */
|
|
233
254
|
export type ListingLifecycleStatus = Schemas["StatusD41Enum"];
|
|
234
255
|
|
|
256
|
+
/**
|
|
257
|
+
* One move the OWNER of a listing may make from where it is now — the third
|
|
258
|
+
* axis, and the one stapel-listings 0.20.0 added because the first two cannot
|
|
259
|
+
* answer it.
|
|
260
|
+
*
|
|
261
|
+
* `status` says where the listing IS and `moderation_status` says what is
|
|
262
|
+
* being waited on. Neither says *what can I do about it*, and a dashboard
|
|
263
|
+
* that works it out for itself is re-implementing `models.OWNER_TRANSITIONS`
|
|
264
|
+
* from the outside — which is how a cabinet ends up drawing "Mark sold" on a
|
|
265
|
+
* listing that is already sold, and drawing no way back at all.
|
|
266
|
+
*
|
|
267
|
+
* The values are lifecycle states (the DESTINATION of a move), so this is
|
|
268
|
+
* `ListingLifecycleStatus` narrowed by the server rather than a second
|
|
269
|
+
* vocabulary.
|
|
270
|
+
*/
|
|
271
|
+
export type ListingOwnerTransition = Schemas["AvailableTransitionsEnum"];
|
|
272
|
+
|
|
235
273
|
/** The four content-moderation states, as `models.ModerationStatus` declares
|
|
236
274
|
* them. Independent of the lifecycle: see `model/status.ts`. */
|
|
237
275
|
export const MODERATION_STATUSES: readonly ListingModerationStatus[] = [
|
|
276
|
+
// NOBODY HAS ASKED YET, and it is the DEFAULT (stapel-listings 0.20.0).
|
|
277
|
+
// Before it, a draft was born `pending` and every dashboard row announced a
|
|
278
|
+
// moderation decision that no case existed behind — one live stand held 167
|
|
279
|
+
// of them. The distinction lives in the data because every reader asks the
|
|
280
|
+
// same question and would each otherwise re-derive "…unless it was never
|
|
281
|
+
// submitted" from a second column.
|
|
282
|
+
"not_submitted",
|
|
238
283
|
"pending",
|
|
239
284
|
"approved",
|
|
240
285
|
"rejected",
|
|
@@ -107,7 +107,12 @@
|
|
|
107
107
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
108
108
|
import { Card, Flex, Typography, theme as antdTheme } from "antd";
|
|
109
109
|
import { SkinButton as Button } from "@stapel/tokens-antd/skin";
|
|
110
|
-
import {
|
|
110
|
+
import {
|
|
111
|
+
ErrorAlert,
|
|
112
|
+
GatedControl,
|
|
113
|
+
SKIN_CAROUSEL_SLIDE_CLASS,
|
|
114
|
+
SkinTheme,
|
|
115
|
+
} from "@stapel/tokens-antd/skin";
|
|
111
116
|
import { useActionGate, useT } from "@stapel/core";
|
|
112
117
|
import type { LinkComponent, SignInCtaProp } from "@stapel/core";
|
|
113
118
|
import { spacing } from "@stapel/tokens";
|
|
@@ -305,11 +310,37 @@ export function cardTargetCss(): string {
|
|
|
305
310
|
return [
|
|
306
311
|
`.${CARD_TARGET_CLASS}{display:block;color:inherit;text-decoration:none}`,
|
|
307
312
|
`.${CARD_TARGET_CLASS}:focus-visible{outline:2px solid var(--listing-card-focus);outline-offset:2px}`,
|
|
313
|
+
// A PRESS, answered where the press lands (D176). The rule is on the
|
|
314
|
+
// TARGET rather than on the card's frame because that is the one element
|
|
315
|
+
// all three surfaces share: the feed tile is a bare div with this anchor
|
|
316
|
+
// over it and no card chrome at all, so a rule written for the frame
|
|
317
|
+
// reached the two surfaces that have one and missed the phone's home
|
|
318
|
+
// screen entirely. `matchMedia("(hover: hover)")` is false on that
|
|
319
|
+
// device, which makes this the ONLY feedback a finger ever gets between
|
|
320
|
+
// touching a tile and the next screen arriving.
|
|
321
|
+
`.${CARD_TARGET_CLASS}{transition:transform 90ms ease}`,
|
|
322
|
+
`.${CARD_TARGET_CLASS}:active{transform:scale(0.985)}`,
|
|
323
|
+
`@media (prefers-reduced-motion:reduce){.${CARD_TARGET_CLASS}{transition:none}` +
|
|
324
|
+
`.${CARD_TARGET_CLASS}:active{transform:none;opacity:0.9}}`,
|
|
308
325
|
// The card asks about its OWN width, not the window's — see
|
|
309
326
|
// `LISTING_CARD_ROW_MIN`.
|
|
310
327
|
`${q}{container-type:inline-size}`,
|
|
311
328
|
`${frame}{display:flex;flex-direction:column;min-inline-size:0}`,
|
|
312
|
-
|
|
329
|
+
// THE MEDIA WELL IS CLIPPED TO THE CARD'S OWN CORNER (D180).
|
|
330
|
+
//
|
|
331
|
+
// The photo strip runs edge to edge at the top of a stacked card, so the
|
|
332
|
+
// card's rounded corner is the corner of the PICTURE. It was not being
|
|
333
|
+
// cut to it: the well was a square box inside a rounded card, and each
|
|
334
|
+
// carousel slide carried a radius of its own — so a multi-photo tile drew
|
|
335
|
+
// a rounded sliver of the next photograph in the corner where the card's
|
|
336
|
+
// curve should be, and every row of tiles on the live feed read as a set
|
|
337
|
+
// of pictures with a torn right edge. One radius, stated by the card,
|
|
338
|
+
// applied to the box that holds the pictures; the slides inside it are
|
|
339
|
+
// square because the well is what has the shape.
|
|
340
|
+
`${media}{min-inline-size:0;overflow:hidden;` +
|
|
341
|
+
`border-start-start-radius:var(--listing-card-radius);` +
|
|
342
|
+
`border-start-end-radius:var(--listing-card-radius)}`,
|
|
343
|
+
`${media} .${SKIN_CAROUSEL_SLIDE_CLASS}{border-radius:0}`,
|
|
313
344
|
`${main}{display:flex;flex-direction:column;flex:1 1 auto;min-inline-size:0}`,
|
|
314
345
|
// The row arm. `align-items:flex-start` so a short text column does not
|
|
315
346
|
// stretch the photo, and a fixed media basis so the picture cannot grow
|
|
@@ -317,6 +348,9 @@ export function cardTargetCss(): string {
|
|
|
317
348
|
`@container (min-width:${String(LISTING_CARD_ROW_MIN)}px){` +
|
|
318
349
|
`${frame}{flex-direction:row;align-items:flex-start}` +
|
|
319
350
|
`${media}{flex:0 0 ${String(LISTING_CARD_ROW_MEDIA)}px;` +
|
|
351
|
+
// Inset on every side in the row arm, so the photo is a rounded block
|
|
352
|
+
// beside the text rather than two rounded corners and two square ones.
|
|
353
|
+
`border-radius:var(--listing-card-radius);` +
|
|
320
354
|
`max-inline-size:${String(LISTING_CARD_ROW_MEDIA)}px}` +
|
|
321
355
|
// A full-bleed stacked card has no padding of its own to give the row
|
|
322
356
|
// arm, so the row arm states its own; the reading column's padding is
|
|
@@ -333,8 +367,24 @@ export function cardTargetCss(): string {
|
|
|
333
367
|
// tokens, and a transition that a person who asked for less motion does
|
|
334
368
|
// not get.
|
|
335
369
|
`.${hover}{transition:box-shadow 160ms ease,border-color 160ms ease}`,
|
|
336
|
-
|
|
337
|
-
|
|
370
|
+
// HOVER IS FOR A POINTER THAT HAS ONE. Measured on the phone this loop
|
|
371
|
+
// walks: `matchMedia("(hover: hover)")` is false, and an unguarded
|
|
372
|
+
// `:hover` on a touch device is worse than absent — it latches after a
|
|
373
|
+
// tap and leaves one tile lit until something else is touched.
|
|
374
|
+
`@media (hover:hover){.${hover}:hover{` +
|
|
375
|
+
`box-shadow:var(--listing-card-hover-shadow);` +
|
|
376
|
+
`border-color:var(--listing-card-focus)}}`,
|
|
377
|
+
// PRESSED, and not the same thing as hovered. The card had a hover state
|
|
378
|
+
// and nothing at all on the way down, so a tap on a phone — where there
|
|
379
|
+
// is no hover to begin with — gave no feedback between the finger landing
|
|
380
|
+
// and the next screen arriving. The lift is TAKEN AWAY rather than
|
|
381
|
+
// increased: a pressed card is a card pushed into the page, which is the
|
|
382
|
+
// one direction a shadow can say without a second token.
|
|
383
|
+
`.${hover}:active{box-shadow:none;border-color:var(--listing-card-focus)}`,
|
|
384
|
+
// The photo is the largest target on the tile and is its own link, so it
|
|
385
|
+
// answers a press on its own — the card behind it may not be under the
|
|
386
|
+
// finger at all.
|
|
387
|
+
`.${PHOTO_LINK_CLASS}:active{opacity:0.88}`,
|
|
338
388
|
`@media (prefers-reduced-motion:reduce){.${hover}{transition:none}}`,
|
|
339
389
|
// Already seen. The photo and everything inside the card's anchor dim
|
|
340
390
|
// together; the heart is outside the anchor and stays as it was — see
|
|
@@ -578,9 +628,23 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
578
628
|
/>
|
|
579
629
|
) : null}
|
|
580
630
|
|
|
631
|
+
{/* ONE line, and it truncates (D185). Measured on a live 1440px feed:
|
|
632
|
+
a two-part city-and-district name wrapped, the text block grew
|
|
633
|
+
104 -> 128px, and the two cards on either side of it stood 24px
|
|
634
|
+
shorter — a row of tiles with a ragged bottom edge and a heart
|
|
635
|
+
hanging below the line it belongs to. The place is a subtitle, not
|
|
636
|
+
prose: which city it is reads from the first words, and the tile's
|
|
637
|
+
height must be a property of the GRID rather than of how long this
|
|
638
|
+
particular neighbourhood is called. The full string stays in the
|
|
639
|
+
title attribute the way `ellipsis` puts it there, so nothing is
|
|
640
|
+
lost — it is one hover, or the listing's own page. */}
|
|
581
641
|
{listing.location_label !== undefined &&
|
|
582
642
|
listing.location_label.length > 0 ? (
|
|
583
|
-
<Typography.Text
|
|
643
|
+
<Typography.Text
|
|
644
|
+
type="secondary"
|
|
645
|
+
ellipsis={{ tooltip: listing.location_label }}
|
|
646
|
+
data-testid="listings-card-location"
|
|
647
|
+
>
|
|
584
648
|
{listing.location_label}
|
|
585
649
|
</Typography.Text>
|
|
586
650
|
) : null}
|
|
@@ -617,6 +681,10 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
617
681
|
style={{
|
|
618
682
|
["--listing-card-focus" as string]: token.colorPrimary,
|
|
619
683
|
["--listing-card-inset" as string]: `${String(token.paddingSM)}px`,
|
|
684
|
+
// The card's OWN corner, handed to the media well so the picture is
|
|
685
|
+
// cut to the same curve the card is drawn with — in whatever theme,
|
|
686
|
+
// at whatever radius a deployment retuned it to.
|
|
687
|
+
["--listing-card-radius" as string]: `${String(token.borderRadiusLG)}px`,
|
|
620
688
|
// The theme's own elevation, so the lift is the same one every
|
|
621
689
|
// raised surface in the skin uses and it is right in both modes.
|
|
622
690
|
["--listing-card-hover-shadow" as string]: token.boxShadowSecondary,
|
|
@@ -99,6 +99,7 @@ import {
|
|
|
99
99
|
CARD_MEDIA_CLASS,
|
|
100
100
|
CARD_QUERY_CLASS,
|
|
101
101
|
CARD_TARGET_STYLE_HREF,
|
|
102
|
+
CARD_HOVER_CLASS,
|
|
102
103
|
CARD_VIEWED_CLASS,
|
|
103
104
|
CardTarget,
|
|
104
105
|
cardTargetCss,
|
|
@@ -247,11 +248,23 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
|
247
248
|
{...(status !== undefined
|
|
248
249
|
? { "data-listing-status": status.status }
|
|
249
250
|
: {})}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
251
|
+
// THE PHONE CARD SAYS IT IS A TARGET, TOO (D176). It carried no
|
|
252
|
+
// hover and no pressed state at all, and the device this card exists
|
|
253
|
+
// for answers `matchMedia("(hover: hover)")` with false — so a tap
|
|
254
|
+
// gave no feedback whatever until the next screen arrived. The shared
|
|
255
|
+
// class is one rule for a pointer that hovers and one for a finger
|
|
256
|
+
// that presses; `cardTargetCss` states both.
|
|
257
|
+
className={
|
|
258
|
+
viewed ? `${CARD_HOVER_CLASS} ${CARD_VIEWED_CLASS}` : CARD_HOVER_CLASS
|
|
259
|
+
}
|
|
260
|
+
{...(viewed ? { "data-listing-viewed": "true" } : {})}
|
|
253
261
|
styles={{ body: { minWidth: 0, padding: token.paddingSM } }}
|
|
254
|
-
style={{
|
|
262
|
+
style={{
|
|
263
|
+
["--listing-card-focus" as string]: token.colorPrimary,
|
|
264
|
+
// The corner the media well is cut to — see `cardTargetCss` (D180).
|
|
265
|
+
["--listing-card-radius" as string]: `${String(token.borderRadiusLG)}px`,
|
|
266
|
+
["--listing-card-hover-shadow" as string]: token.boxShadowSecondary,
|
|
267
|
+
}}
|
|
255
268
|
>
|
|
256
269
|
<div className={CARD_QUERY_CLASS}>
|
|
257
270
|
<div className={CARD_FRAME_CLASS} style={{ gap: spacing[3] }}>
|