@stapel/listings-react 0.4.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/dist/api/generated/schema.d.ts +94 -4
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/default/FavoritesPane.d.ts +40 -3
  5. package/dist/default/FavoritesPane.d.ts.map +1 -1
  6. package/dist/default/FavoritesPane.js +30 -18
  7. package/dist/default/FavoritesPane.js.map +1 -1
  8. package/dist/default/ListingCard.d.ts +81 -4
  9. package/dist/default/ListingCard.d.ts.map +1 -1
  10. package/dist/default/ListingCard.js +97 -27
  11. package/dist/default/ListingCard.js.map +1 -1
  12. package/dist/default/ListingComposerPage.d.ts +163 -23
  13. package/dist/default/ListingComposerPage.d.ts.map +1 -1
  14. package/dist/default/ListingComposerPage.js +85 -43
  15. package/dist/default/ListingComposerPage.js.map +1 -1
  16. package/dist/default/ListingDetailPane.d.ts +41 -4
  17. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  18. package/dist/default/ListingDetailPane.js +38 -25
  19. package/dist/default/ListingDetailPane.js.map +1 -1
  20. package/dist/default/ListingPhoto.d.ts +13 -1
  21. package/dist/default/ListingPhoto.d.ts.map +1 -1
  22. package/dist/default/ListingPhoto.js +47 -9
  23. package/dist/default/ListingPhoto.js.map +1 -1
  24. package/dist/default/ListingPrice.d.ts +13 -0
  25. package/dist/default/ListingPrice.d.ts.map +1 -0
  26. package/dist/default/ListingPrice.js +60 -0
  27. package/dist/default/ListingPrice.js.map +1 -0
  28. package/dist/default/MyListingsPane.d.ts +7 -28
  29. package/dist/default/MyListingsPane.d.ts.map +1 -1
  30. package/dist/default/MyListingsPane.js +136 -67
  31. package/dist/default/MyListingsPane.js.map +1 -1
  32. package/dist/default/SignInLink.js +8 -7
  33. package/dist/default/SignInLink.js.map +1 -1
  34. package/dist/default/StatusTags.d.ts +16 -5
  35. package/dist/default/StatusTags.d.ts.map +1 -1
  36. package/dist/default/StatusTags.js +19 -13
  37. package/dist/default/StatusTags.js.map +1 -1
  38. package/dist/default/index.d.ts +15 -9
  39. package/dist/default/index.d.ts.map +1 -1
  40. package/dist/default/index.js +13 -6
  41. package/dist/default/index.js.map +1 -1
  42. package/dist/headless/Favorites.d.ts +6 -0
  43. package/dist/headless/Favorites.d.ts.map +1 -1
  44. package/dist/headless/Favorites.js +23 -7
  45. package/dist/headless/Favorites.js.map +1 -1
  46. package/dist/headless/ListingActions.d.ts +12 -0
  47. package/dist/headless/ListingActions.d.ts.map +1 -1
  48. package/dist/headless/ListingActions.js +3 -0
  49. package/dist/headless/ListingActions.js.map +1 -1
  50. package/dist/headless/ListingComposer.d.ts +23 -0
  51. package/dist/headless/ListingComposer.d.ts.map +1 -1
  52. package/dist/headless/ListingComposer.js +33 -7
  53. package/dist/headless/ListingComposer.js.map +1 -1
  54. package/dist/headless/ListingDetail.d.ts.map +1 -1
  55. package/dist/headless/ListingDetail.js +7 -5
  56. package/dist/headless/ListingDetail.js.map +1 -1
  57. package/dist/headless/useMandateGate.d.ts +33 -2
  58. package/dist/headless/useMandateGate.d.ts.map +1 -1
  59. package/dist/headless/useMandateGate.js +42 -4
  60. package/dist/headless/useMandateGate.js.map +1 -1
  61. package/dist/i18n/es.d.ts +6 -1
  62. package/dist/i18n/es.d.ts.map +1 -1
  63. package/dist/i18n/es.js +23 -5
  64. package/dist/i18n/es.js.map +1 -1
  65. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  66. package/dist/i18n/generated/errors.es.gen.js +1 -1
  67. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  68. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  69. package/dist/i18n/generated/errors.gen.js +3 -0
  70. package/dist/i18n/generated/errors.gen.js.map +1 -1
  71. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  72. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  73. package/dist/i18n/keys.d.ts +30 -3
  74. package/dist/i18n/keys.d.ts.map +1 -1
  75. package/dist/i18n/keys.js +47 -7
  76. package/dist/i18n/keys.js.map +1 -1
  77. package/dist/i18n/ru.d.ts +16 -1
  78. package/dist/i18n/ru.d.ts.map +1 -1
  79. package/dist/i18n/ru.js +35 -5
  80. package/dist/i18n/ru.js.map +1 -1
  81. package/dist/index.d.ts +1 -1
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +1 -1
  84. package/dist/index.js.map +1 -1
  85. package/dist/model/draft.d.ts +9 -6
  86. package/dist/model/draft.d.ts.map +1 -1
  87. package/dist/model/draft.js +3 -1
  88. package/dist/model/draft.js.map +1 -1
  89. package/dist/model/features.d.ts +21 -7
  90. package/dist/model/features.d.ts.map +1 -1
  91. package/dist/model/features.js +26 -0
  92. package/dist/model/features.js.map +1 -1
  93. package/llms.txt +9 -8
  94. package/manifest.json +82 -31
  95. package/nav-manifest.json +1 -1
  96. package/package.json +19 -14
  97. package/src/analytics/generated/events.json +1 -1
  98. package/src/api/generated/schema.ts +94 -4
  99. package/src/default/FavoritesPane.tsx +156 -82
  100. package/src/default/ListingCard.tsx +283 -116
  101. package/src/default/ListingComposerPage.tsx +403 -187
  102. package/src/default/ListingDetailPane.tsx +200 -98
  103. package/src/default/ListingPhoto.tsx +89 -16
  104. package/src/default/ListingPrice.tsx +77 -0
  105. package/src/default/MyListingsPane.tsx +286 -228
  106. package/src/default/SignInLink.tsx +4 -4
  107. package/src/default/StatusTags.tsx +36 -18
  108. package/src/default/index.ts +18 -7
  109. package/src/headless/Favorites.tsx +36 -7
  110. package/src/headless/ListingActions.tsx +19 -0
  111. package/src/headless/ListingComposer.tsx +56 -5
  112. package/src/headless/ListingDetail.tsx +12 -5
  113. package/src/headless/useMandateGate.ts +49 -5
  114. package/src/i18n/es.ts +29 -5
  115. package/src/i18n/generated/errors.es.gen.ts +1 -1
  116. package/src/i18n/generated/errors.gen.ts +3 -0
  117. package/src/i18n/generated/errors.json +7 -0
  118. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  119. package/src/i18n/keys.ts +54 -7
  120. package/src/i18n/ru.ts +41 -5
  121. package/src/index.ts +5 -1
  122. package/src/model/draft.ts +12 -7
  123. package/src/model/features.ts +48 -7
  124. package/dist/default/ErrorAlert.d.ts +0 -9
  125. package/dist/default/ErrorAlert.d.ts.map +0 -1
  126. package/dist/default/ErrorAlert.js +0 -27
  127. package/dist/default/ErrorAlert.js.map +0 -1
  128. package/dist/default/theme.d.ts +0 -25
  129. package/dist/default/theme.d.ts.map +0 -1
  130. package/dist/default/theme.js +0 -40
  131. package/dist/default/theme.js.map +0 -1
  132. package/src/default/ErrorAlert.tsx +0 -46
  133. package/src/default/theme.tsx +0 -67
@@ -12,12 +12,21 @@ export interface paths {
12
12
  /**
13
13
  * @description Listings CRUD plus owner lifecycle actions and favorites.
14
14
  *
15
+ * Read and write live in one class, so the guest wall
16
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
17
+ * is applied per ACTION and never as a class permission: an anonymous
18
+ * session must keep browsing and keep its favorites, and only the
19
+ * authorship actions — ``create``, ``update``/``partial_update``,
20
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
21
+ * seller.
22
+ *
15
23
  * **Permissions:** `IsAuthenticatedOrReadOnly`
16
24
  */
17
25
  get: operations["listings_api_v1_listings_list"];
18
26
  put?: never;
19
27
  /**
20
- * @description Listings CRUD plus owner lifecycle actions and favorites.
28
+ * @description Open a draft the first act of becoming a seller, so the guest
29
+ * wall stands here and not only at ``publish``.
21
30
  *
22
31
  * **Permissions:** `IsAuthenticatedOrReadOnly`
23
32
  */
@@ -38,6 +47,14 @@ export interface paths {
38
47
  /**
39
48
  * @description Listings CRUD plus owner lifecycle actions and favorites.
40
49
  *
50
+ * Read and write live in one class, so the guest wall
51
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
52
+ * is applied per ACTION and never as a class permission: an anonymous
53
+ * session must keep browsing and keep its favorites, and only the
54
+ * authorship actions — ``create``, ``update``/``partial_update``,
55
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
56
+ * seller.
57
+ *
41
58
  * **Permissions:** `IsAuthenticatedOrReadOnly`
42
59
  */
43
60
  get: operations["listings_api_v1_listings_retrieve"];
@@ -53,6 +70,14 @@ export interface paths {
53
70
  /**
54
71
  * @description Listings CRUD plus owner lifecycle actions and favorites.
55
72
  *
73
+ * Read and write live in one class, so the guest wall
74
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
75
+ * is applied per ACTION and never as a class permission: an anonymous
76
+ * session must keep browsing and keep its favorites, and only the
77
+ * authorship actions — ``create``, ``update``/``partial_update``,
78
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
79
+ * seller.
80
+ *
56
81
  * **Permissions:** `IsAuthenticatedOrReadOnly`
57
82
  */
58
83
  delete: operations["listings_api_v1_listings_destroy"];
@@ -61,6 +86,14 @@ export interface paths {
61
86
  /**
62
87
  * @description Listings CRUD plus owner lifecycle actions and favorites.
63
88
  *
89
+ * Read and write live in one class, so the guest wall
90
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
91
+ * is applied per ACTION and never as a class permission: an anonymous
92
+ * session must keep browsing and keep its favorites, and only the
93
+ * authorship actions — ``create``, ``update``/``partial_update``,
94
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
95
+ * seller.
96
+ *
64
97
  * **Permissions:** `IsAuthenticatedOrReadOnly`
65
98
  */
66
99
  patch: operations["listings_api_v1_listings_partial_update"];
@@ -78,6 +111,14 @@ export interface paths {
78
111
  /**
79
112
  * @description Listings CRUD plus owner lifecycle actions and favorites.
80
113
  *
114
+ * Read and write live in one class, so the guest wall
115
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
116
+ * is applied per ACTION and never as a class permission: an anonymous
117
+ * session must keep browsing and keep its favorites, and only the
118
+ * authorship actions — ``create``, ``update``/``partial_update``,
119
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
120
+ * seller.
121
+ *
81
122
  * **Permissions:** `IsAuthenticated`
82
123
  */
83
124
  post: operations["listings_api_v1_listings_archive_create"];
@@ -120,6 +161,14 @@ export interface paths {
120
161
  /**
121
162
  * @description Listings CRUD plus owner lifecycle actions and favorites.
122
163
  *
164
+ * Read and write live in one class, so the guest wall
165
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
166
+ * is applied per ACTION and never as a class permission: an anonymous
167
+ * session must keep browsing and keep its favorites, and only the
168
+ * authorship actions — ``create``, ``update``/``partial_update``,
169
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
170
+ * seller.
171
+ *
123
172
  * **Permissions:** `IsAuthenticated`
124
173
  */
125
174
  post: operations["listings_api_v1_listings_favorite_create"];
@@ -139,7 +188,8 @@ export interface paths {
139
188
  get?: never;
140
189
  put?: never;
141
190
  /**
142
- * @description Listings CRUD plus owner lifecycle actions and favorites.
191
+ * @description Put a listing in front of buyers — the act the wall is named for:
192
+ * a seller nobody can reach again is not a seller.
143
193
  *
144
194
  * **Permissions:** `IsAuthenticated`
145
195
  */
@@ -181,6 +231,14 @@ export interface paths {
181
231
  /**
182
232
  * @description Listings CRUD plus owner lifecycle actions and favorites.
183
233
  *
234
+ * Read and write live in one class, so the guest wall
235
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
236
+ * is applied per ACTION and never as a class permission: an anonymous
237
+ * session must keep browsing and keep its favorites, and only the
238
+ * authorship actions — ``create``, ``update``/``partial_update``,
239
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
240
+ * seller.
241
+ *
184
242
  * **Permissions:** `AllowAny`
185
243
  */
186
244
  get: operations["listings_api_v1_listings_status_retrieve"];
@@ -204,6 +262,14 @@ export interface paths {
204
262
  /**
205
263
  * @description Listings CRUD plus owner lifecycle actions and favorites.
206
264
  *
265
+ * Read and write live in one class, so the guest wall
266
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
267
+ * is applied per ACTION and never as a class permission: an anonymous
268
+ * session must keep browsing and keep its favorites, and only the
269
+ * authorship actions — ``create``, ``update``/``partial_update``,
270
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
271
+ * seller.
272
+ *
207
273
  * **Permissions:** `IsAuthenticated`
208
274
  */
209
275
  post: operations["listings_api_v1_listings_unfavorite_create"];
@@ -223,6 +289,14 @@ export interface paths {
223
289
  /**
224
290
  * @description Listings CRUD plus owner lifecycle actions and favorites.
225
291
  *
292
+ * Read and write live in one class, so the guest wall
293
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
294
+ * is applied per ACTION and never as a class permission: an anonymous
295
+ * session must keep browsing and keep its favorites, and only the
296
+ * authorship actions — ``create``, ``update``/``partial_update``,
297
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
298
+ * seller.
299
+ *
226
300
  * **Permissions:** `IsAuthenticated`
227
301
  */
228
302
  get: operations["listings_api_v1_listings_validate_draft_retrieve"];
@@ -244,6 +318,14 @@ export interface paths {
244
318
  /**
245
319
  * @description Listings CRUD plus owner lifecycle actions and favorites.
246
320
  *
321
+ * Read and write live in one class, so the guest wall
322
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
323
+ * is applied per ACTION and never as a class permission: an anonymous
324
+ * session must keep browsing and keep its favorites, and only the
325
+ * authorship actions — ``create``, ``update``/``partial_update``,
326
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
327
+ * seller.
328
+ *
247
329
  * **Permissions:** `IsAuthenticated`
248
330
  */
249
331
  get: operations["listings_api_v1_listings_my_counters_retrieve"];
@@ -265,6 +347,14 @@ export interface paths {
265
347
  /**
266
348
  * @description Listings CRUD plus owner lifecycle actions and favorites.
267
349
  *
350
+ * Read and write live in one class, so the guest wall
351
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
352
+ * is applied per ACTION and never as a class permission: an anonymous
353
+ * session must keep browsing and keep its favorites, and only the
354
+ * authorship actions — ``create``, ``update``/``partial_update``,
355
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
356
+ * seller.
357
+ *
268
358
  * **Permissions:** `IsAuthenticated`
269
359
  */
270
360
  get: operations["listings_api_v1_listings_my_favorites_list"];
@@ -664,7 +754,7 @@ export interface components {
664
754
  images_draft?: string[];
665
755
  location_id_draft?: string;
666
756
  location_label_draft?: string;
667
- geohash_draft?: string;
757
+ readonly geohash_draft: string;
668
758
  /** Format: decimal */
669
759
  lat_draft?: string | null;
670
760
  /** Format: decimal */
@@ -805,7 +895,7 @@ export interface components {
805
895
  images_draft?: string[];
806
896
  location_id_draft?: string;
807
897
  location_label_draft?: string;
808
- geohash_draft?: string;
898
+ readonly geohash_draft?: string;
809
899
  /** Format: decimal */
810
900
  lat_draft?: string | null;
811
901
  /** Format: decimal */
@@ -6,19 +6,53 @@
6
6
  * "You have not saved anything yet" and "we could not load your favourites"
7
7
  * are the pair a `data ?? []` would have merged, and merging them is exactly
8
8
  * the substitution that cost the fleet an incident (spec §7.4).
9
+ *
10
+ * ── A visitor is told ONE thing ────────────────────────────────────────────
11
+ *
12
+ * `useFavorites` disables the query without a mandate, so a visitor's `rows`
13
+ * sit in `loading` forever — and the pane rendered the blocked notice AND the
14
+ * spinner underneath it, saying "you cannot see this" and "we are fetching it"
15
+ * at the same time, with no sign-in link anywhere on the screen. The gate is
16
+ * now the WHOLE body: one designed state, with the door the container supplies
17
+ * (`signIn`) inside it, and nothing else drawn.
18
+ *
19
+ * The pager obeys the same rule. `Previous`/`Next` were rendered disabled in
20
+ * every state including empty, failed and blocked — two controls that meant
21
+ * nothing, three times over. They render when there is a page to go to.
22
+ *
23
+ * And each one renders only when ITS page exists. The keyset gates name the
24
+ * missing page with the button's own label key, so a blocked `Previous` drew
25
+ * the word twice — once as a dead button, once as its own "reason". A pager
26
+ * does not explain itself: the page indicator says where you are, and a
27
+ * direction with nowhere to go is simply not drawn.
9
28
  */
10
- import type { ReactElement } from "react";
11
- import { Alert, Button, Empty, Flex, Space, Spin, Typography } from "antd";
12
- import { matchList, useDescribeFlowError, useT } from "@stapel/core";
13
- import type { LinkComponent } from "@stapel/core";
29
+ import type { ReactElement, ReactNode } from "react";
30
+ import { Button, Flex, Typography } from "antd";
31
+ import {
32
+ EmptyState,
33
+ ErrorAlert,
34
+ SkinTheme,
35
+ } from "@stapel/tokens-antd/skin";
36
+ import { matchList, useT } from "@stapel/core";
37
+ import type { LinkComponent, SignInCtaProp } from "@stapel/core";
38
+ import { spacing } from "@stapel/tokens";
14
39
  import { useFavorites } from "../headless/Favorites.js";
15
40
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
16
- import { ErrorAlert } from "./ErrorAlert.js";
17
41
  import { ListingCard } from "./ListingCard.js";
18
42
  import type { ListingCardOpenProps } from "./ListingCard.js";
19
- import { ListingsSkinTheme } from "./theme.js";
43
+ import { SignInLink } from "./SignInLink.js";
20
44
  import type { ThemeModeProp } from "./types.js";
21
45
 
46
+ /** The narrowest a card may get before the grid drops a column — the same
47
+ * element-relative rule the detail gallery uses. It was `width: 240`, which
48
+ * left two cards floating in a 2560px pane and overflowed a 390px one. */
49
+ export const FAVORITES_CARD_MIN = "15rem";
50
+
51
+ /** How wide the saved grid may get before it stops being a grid and becomes
52
+ * three cards adrift in a 1280px window. A measure, not a breakpoint: the
53
+ * cards keep filling it, and past it the page centres. */
54
+ export const FAVORITES_MEASURE = "72rem";
55
+
22
56
  /** How a card in this grid opens — the same one-contract union `<ListingCard>`
23
57
  * takes, one level up, so a pane cannot re-introduce the double navigation the
24
58
  * card no longer allows. */
@@ -41,7 +75,21 @@ export type FavoritesPaneOpenProps =
41
75
  readonly linkComponent?: undefined;
42
76
  };
43
77
 
44
- export type FavoritesPaneProps = ThemeModeProp & FavoritesPaneOpenProps;
78
+ /** The way OUT of an empty favourites list. */
79
+ export interface FavoritesBrowseCtaProp {
80
+ /**
81
+ * What a person with nothing saved can do next — a "Browse listings"
82
+ * button, supplied by the container because only it knows where the
83
+ * catalogue lives. An empty state without one is a dead end, and this
84
+ * screen was one of four sharing a single stock illustration and no exit.
85
+ */
86
+ readonly browseCta?: ReactNode;
87
+ }
88
+
89
+ export type FavoritesPaneProps = ThemeModeProp &
90
+ SignInCtaProp &
91
+ FavoritesBrowseCtaProp &
92
+ FavoritesPaneOpenProps;
45
93
 
46
94
  /** The card's own open props for one row. One arm, never two. */
47
95
  function cardOpenProps(
@@ -60,93 +108,119 @@ function cardOpenProps(
60
108
 
61
109
  export function FavoritesPane(props: FavoritesPaneProps): ReactElement {
62
110
  const t = useT();
63
- const describe = useDescribeFlowError();
64
111
  const bag = useFavorites();
112
+ const paged = bag.prevPage.available || bag.nextPage.available;
65
113
 
66
114
  return (
67
- <ListingsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
68
- <Flex vertical gap={16} data-testid="listings-favorites">
115
+ <SkinTheme
116
+ surface="base"
117
+ style={{ padding: spacing[4], maxWidth: FAVORITES_MEASURE }}
118
+ {...(props.mode !== undefined ? { mode: props.mode } : {})}
119
+ >
120
+ <Flex vertical gap={spacing[4]} data-testid="listings-favorites">
69
121
  <Typography.Title level={3}>
70
122
  {t(LISTINGS_I18N_KEYS.favoritesTitle)}
71
123
  </Typography.Title>
72
124
 
73
125
  {!bag.gate.available ? (
74
- <Alert
75
- type="info"
76
- showIcon
77
- data-testid="listings-favorites-blocked"
78
- message={t(bag.gate.block.code, bag.gate.block.params)}
126
+ // The whole body, not a banner over a spinner. A blocked screen has
127
+ // one state and it carries its own way out.
128
+ <EmptyState
129
+ testId="listings-favorites-blocked"
130
+ title={t(bag.gate.block.code, bag.gate.block.params)}
131
+ hint={t(LISTINGS_I18N_KEYS.favoritesSignInHint)}
132
+ action={
133
+ <SignInLink cta={props.signIn} testId="listings-favorites-sign-in" />
134
+ }
79
135
  />
80
- ) : null}
81
-
82
- {matchList(bag.rows, {
83
- loading: () => (
84
- <Flex justify="center" data-testid="listings-favorites-loading">
85
- <Spin aria-label={t(LISTINGS_I18N_KEYS.favoritesLoading)} />
86
- </Flex>
87
- ),
88
- failed: () => (
89
- <ErrorAlert
90
- testId="listings-favorites-error"
91
- error={describe({
92
- code: LISTINGS_I18N_KEYS.favoritesLoadFailed,
93
- params: {},
94
- status: 0,
95
- message: undefined,
96
- language: undefined,
97
- })}
98
- action={
99
- <Button
100
- size="small"
101
- data-analytics="none"
102
- data-analytics-reason="retrying a read the person already asked for; not a business action"
103
- onClick={bag.refetch}
136
+ ) : (
137
+ <>
138
+ {matchList(bag.rows, {
139
+ loading: () => (
140
+ <div
141
+ role="status"
142
+ aria-busy="true"
143
+ aria-label={t(LISTINGS_I18N_KEYS.favoritesLoading)}
144
+ data-testid="listings-favorites-loading"
145
+ data-stapel-load-state="loading"
146
+ />
147
+ ),
148
+ failed: (error) => (
149
+ <ErrorAlert
150
+ testId="listings-favorites-error"
151
+ thrown={error}
152
+ message={t(LISTINGS_I18N_KEYS.favoritesLoadFailed)}
153
+ onRetry={bag.refetch}
154
+ retryLabel={t(LISTINGS_I18N_KEYS.mineRetry)}
155
+ />
156
+ ),
157
+ empty: () => (
158
+ <EmptyState
159
+ testId="listings-favorites-empty"
160
+ title={t(LISTINGS_I18N_KEYS.favoritesEmpty)}
161
+ hint={t(LISTINGS_I18N_KEYS.favoritesEmptyHint)}
162
+ {...(props.browseCta !== undefined
163
+ ? { action: props.browseCta }
164
+ : {})}
165
+ />
166
+ ),
167
+ ready: (rows) => (
168
+ <div
169
+ data-testid="listings-favorites-grid"
170
+ style={{
171
+ display: "grid",
172
+ gridTemplateColumns: `repeat(auto-fill, minmax(${FAVORITES_CARD_MIN}, 1fr))`,
173
+ gap: spacing[4],
174
+ }}
104
175
  >
105
- {t(LISTINGS_I18N_KEYS.mineRetry)}
106
- </Button>
107
- }
108
- />
109
- ),
110
- empty: () => (
111
- <Empty
112
- data-testid="listings-favorites-empty"
113
- description={t(LISTINGS_I18N_KEYS.favoritesEmpty)}
114
- />
115
- ),
116
- ready: (rows) => (
117
- <Flex wrap gap={16} data-testid="listings-favorites-grid">
118
- {rows.map((row) => (
119
- <div key={row.id} style={{ width: 240 }}>
120
- <ListingCard listing={row} {...cardOpenProps(props, row.id)} />
176
+ {rows.map((row) => (
177
+ <ListingCard
178
+ key={row.id}
179
+ listing={row}
180
+ blockedReason="line"
181
+ {...(props.signIn !== undefined ? { signIn: props.signIn } : {})}
182
+ {...cardOpenProps(props, row.id)}
183
+ />
184
+ ))}
121
185
  </div>
122
- ))}
123
- </Flex>
124
- ),
125
- })}
186
+ ),
187
+ })}
126
188
 
127
- <Space>
128
- <Button
129
- size="small"
130
- disabled={!bag.prevPage.available}
131
- data-testid="listings-favorites-prev"
132
- data-analytics="none"
133
- data-analytics-reason="paging a list the person is already reading; not a business action"
134
- onClick={bag.goPrev}
135
- >
136
- {t(LISTINGS_I18N_KEYS.pagePrev)}
137
- </Button>
138
- <Button
139
- size="small"
140
- disabled={!bag.nextPage.available}
141
- data-testid="listings-favorites-next"
142
- data-analytics="none"
143
- data-analytics-reason="paging a list the person is already reading; not a business action"
144
- onClick={bag.goNext}
145
- >
146
- {t(LISTINGS_I18N_KEYS.pageNext)}
147
- </Button>
148
- </Space>
189
+ {paged ? (
190
+ <Flex gap={spacing[3]} wrap align="center">
191
+ {bag.prevPage.available ? (
192
+ <Button
193
+ size="small"
194
+ data-testid="listings-favorites-prev"
195
+ data-analytics="none"
196
+ data-analytics-reason="paging a list the person is already reading; not a business action"
197
+ onClick={bag.goPrev}
198
+ >
199
+ {t(LISTINGS_I18N_KEYS.pagePrev)}
200
+ </Button>
201
+ ) : null}
202
+ <Typography.Text
203
+ type="secondary"
204
+ data-testid="listings-favorites-page"
205
+ >
206
+ {t(LISTINGS_I18N_KEYS.pageIndicator, { page: bag.pageNumber })}
207
+ </Typography.Text>
208
+ {bag.nextPage.available ? (
209
+ <Button
210
+ size="small"
211
+ data-testid="listings-favorites-next"
212
+ data-analytics="none"
213
+ data-analytics-reason="paging a list the person is already reading; not a business action"
214
+ onClick={bag.goNext}
215
+ >
216
+ {t(LISTINGS_I18N_KEYS.pageNext)}
217
+ </Button>
218
+ ) : null}
219
+ </Flex>
220
+ ) : null}
221
+ </>
222
+ )}
149
223
  </Flex>
150
- </ListingsSkinTheme>
224
+ </SkinTheme>
151
225
  );
152
226
  }