@stapel/listings-react 0.21.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/dist/api/generated/schema.d.ts +125 -2
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/listingsApi.d.ts +20 -1
  5. package/dist/api/listingsApi.d.ts.map +1 -1
  6. package/dist/api/listingsApi.js +1 -0
  7. package/dist/api/listingsApi.js.map +1 -1
  8. package/dist/api/types.d.ts +32 -2
  9. package/dist/api/types.d.ts.map +1 -1
  10. package/dist/api/types.js +7 -0
  11. package/dist/api/types.js.map +1 -1
  12. package/dist/default/ListingCard.d.ts.map +1 -1
  13. package/dist/default/ListingCard.js +54 -5
  14. package/dist/default/ListingCard.js.map +1 -1
  15. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  16. package/dist/default/ListingSerpCard.js +14 -4
  17. package/dist/default/ListingSerpCard.js.map +1 -1
  18. package/dist/default/MyListingsPane.d.ts +19 -1
  19. package/dist/default/MyListingsPane.d.ts.map +1 -1
  20. package/dist/default/MyListingsPane.js +99 -12
  21. package/dist/default/MyListingsPane.js.map +1 -1
  22. package/dist/headless/ListingActions.d.ts +64 -13
  23. package/dist/headless/ListingActions.d.ts.map +1 -1
  24. package/dist/headless/ListingActions.js +60 -5
  25. package/dist/headless/ListingActions.js.map +1 -1
  26. package/dist/i18n/es.d.ts.map +1 -1
  27. package/dist/i18n/es.js +8 -2
  28. package/dist/i18n/es.js.map +1 -1
  29. package/dist/i18n/keys.d.ts +20 -0
  30. package/dist/i18n/keys.d.ts.map +1 -1
  31. package/dist/i18n/keys.js +52 -1
  32. package/dist/i18n/keys.js.map +1 -1
  33. package/dist/i18n/ru.d.ts.map +1 -1
  34. package/dist/i18n/ru.js +12 -2
  35. package/dist/i18n/ru.js.map +1 -1
  36. package/dist/index.d.ts +5 -5
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +2 -2
  39. package/dist/index.js.map +1 -1
  40. package/dist/model/mine.d.ts +20 -0
  41. package/dist/model/mine.d.ts.map +1 -1
  42. package/dist/model/mine.js +22 -0
  43. package/dist/model/mine.js.map +1 -1
  44. package/dist/model/mutations.d.ts +24 -1
  45. package/dist/model/mutations.d.ts.map +1 -1
  46. package/dist/model/mutations.js +28 -0
  47. package/dist/model/mutations.js.map +1 -1
  48. package/dist/model/status.d.ts.map +1 -1
  49. package/dist/model/status.js +50 -12
  50. package/dist/model/status.js.map +1 -1
  51. package/dist/model/transitions.d.ts +38 -1
  52. package/dist/model/transitions.d.ts.map +1 -1
  53. package/dist/model/transitions.js +73 -1
  54. package/dist/model/transitions.js.map +1 -1
  55. package/llms.txt +4 -2
  56. package/manifest.json +26 -2
  57. package/nav-manifest.json +1 -1
  58. package/package.json +6 -6
  59. package/src/analytics/generated/events.json +1 -1
  60. package/src/api/generated/schema.ts +125 -2
  61. package/src/api/listingsApi.ts +27 -0
  62. package/src/api/types.ts +47 -2
  63. package/src/default/ListingCard.tsx +73 -5
  64. package/src/default/ListingSerpCard.tsx +17 -4
  65. package/src/default/MyListingsPane.tsx +205 -39
  66. package/src/headless/ListingActions.tsx +137 -16
  67. package/src/i18n/es.ts +8 -2
  68. package/src/i18n/keys.ts +52 -1
  69. package/src/i18n/ru.ts +12 -2
  70. package/src/index.ts +19 -3
  71. package/src/model/mine.ts +23 -0
  72. package/src/model/mutations.ts +41 -0
  73. package/src/model/status.ts +51 -12
  74. 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 * `pending` - Pending Review
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;
@@ -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
- export type ListingCard = WithOptionalEngagement<Schemas["ListingCard"]>;
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 = WithOptionalEngagement<Schemas["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 { ErrorAlert, GatedControl, SkinTheme } from "@stapel/tokens-antd/skin";
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
- `${media}{min-inline-size:0}`,
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
- `.${hover}:hover{box-shadow:var(--listing-card-hover-shadow);` +
337
- `border-color:var(--listing-card-focus)}`,
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 type="secondary" data-testid="listings-card-location">
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
- {...(viewed
251
- ? { className: CARD_VIEWED_CLASS, "data-listing-viewed": "true" }
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={{ ["--listing-card-focus" as string]: token.colorPrimary }}
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] }}>