@stapel/listings-react 0.7.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 (57) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/api/generated/schema.d.ts +92 -2
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/default/ListingCard.d.ts +56 -0
  5. package/dist/default/ListingCard.d.ts.map +1 -1
  6. package/dist/default/ListingCard.js +92 -29
  7. package/dist/default/ListingCard.js.map +1 -1
  8. package/dist/headless/Favorites.d.ts.map +1 -1
  9. package/dist/headless/Favorites.js +19 -7
  10. package/dist/headless/Favorites.js.map +1 -1
  11. package/dist/headless/ListingDetail.d.ts.map +1 -1
  12. package/dist/headless/ListingDetail.js +7 -5
  13. package/dist/headless/ListingDetail.js.map +1 -1
  14. package/dist/headless/useMandateGate.d.ts +33 -2
  15. package/dist/headless/useMandateGate.d.ts.map +1 -1
  16. package/dist/headless/useMandateGate.js +42 -4
  17. package/dist/headless/useMandateGate.js.map +1 -1
  18. package/dist/i18n/es.d.ts.map +1 -1
  19. package/dist/i18n/es.js +2 -1
  20. package/dist/i18n/es.js.map +1 -1
  21. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  22. package/dist/i18n/generated/errors.es.gen.js +1 -1
  23. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  24. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  25. package/dist/i18n/generated/errors.gen.js +3 -0
  26. package/dist/i18n/generated/errors.gen.js.map +1 -1
  27. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  28. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  29. package/dist/i18n/keys.d.ts +11 -1
  30. package/dist/i18n/keys.d.ts.map +1 -1
  31. package/dist/i18n/keys.js +12 -2
  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 +2 -1
  35. package/dist/i18n/ru.js.map +1 -1
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -1
  39. package/dist/index.js.map +1 -1
  40. package/llms.txt +3 -3
  41. package/manifest.json +30 -21
  42. package/nav-manifest.json +1 -1
  43. package/package.json +6 -6
  44. package/src/analytics/generated/events.json +1 -1
  45. package/src/api/generated/schema.ts +92 -2
  46. package/src/default/ListingCard.tsx +233 -108
  47. package/src/headless/Favorites.tsx +26 -7
  48. package/src/headless/ListingDetail.tsx +12 -5
  49. package/src/headless/useMandateGate.ts +49 -5
  50. package/src/i18n/es.ts +3 -1
  51. package/src/i18n/generated/errors.es.gen.ts +1 -1
  52. package/src/i18n/generated/errors.gen.ts +3 -0
  53. package/src/i18n/generated/errors.json +7 -0
  54. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  55. package/src/i18n/keys.ts +12 -2
  56. package/src/i18n/ru.ts +3 -1
  57. package/src/index.ts +5 -1
@@ -13,7 +13,10 @@ import type { ListingCard, ListingPageParams } from "../api/types.js";
13
13
  import { useMyFavorites } from "../model/queries.js";
14
14
  import { useFavoriteListing } from "../model/mutations.js";
15
15
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
16
- import { useMandateGate } from "./useMandateGate.js";
16
+ import {
17
+ LISTINGS_ELEVATION_ACTIONS,
18
+ useElevatableMandateGate,
19
+ } from "./useMandateGate.js";
17
20
 
18
21
  /**
19
22
  * Saving something for later — first-class in stapel-listings (a `Favorite`
@@ -48,23 +51,28 @@ export function useFavoriteToggle(
48
51
  id: number,
49
52
  favorited: boolean | null | undefined
50
53
  ): FavoriteToggleBag {
51
- const mandate = useMandateGate();
54
+ const { gate: mandate, elevation } = useElevatableMandateGate(
55
+ LISTINGS_ELEVATION_ACTIONS.favorite
56
+ );
52
57
  const mutation = useFavoriteListing();
53
58
  const gate = firstBlock(
54
59
  mandate,
55
- mutation.isPending
60
+ mutation.isPending || elevation.pending
56
61
  ? actionBlocked(LISTINGS_I18N_KEYS.blockedInFlight)
57
62
  : actionAvailable()
58
63
  );
59
64
  return {
60
65
  favorited: favorited === true,
61
66
  gate,
67
+ // On a host with auto-anonymous wired, the first heart an anonymous
68
+ // visitor presses mints their account and then saves — one press, no
69
+ // form, nothing said about it. Everywhere else `run` performs directly.
62
70
  toggle: () => {
63
71
  if (!gate.available) return;
64
- mutation.mutate({ id, favorited: favorited !== true });
72
+ elevation.run(() => mutation.mutate({ id, favorited: favorited !== true }));
65
73
  },
66
- inFlight: mutation.isPending,
67
- error: mutation.error,
74
+ inFlight: mutation.isPending || elevation.pending,
75
+ error: mutation.error ?? elevation.error,
68
76
  };
69
77
  }
70
78
 
@@ -92,7 +100,18 @@ export interface UseFavoritesOptions {
92
100
  /** The favourites page: a real keyset list, unlike the owner's own listings
93
101
  * (see `MyListings.tsx` for why those are different). */
94
102
  export function useFavorites(options: UseFavoritesOptions = {}): FavoritesBag {
95
- const gate = useMandateGate();
103
+ // The READ side of elevation, and the one place `identified` is the right
104
+ // question. A guest who saved listings must be able to come back and see
105
+ // them — an account that can save and cannot re-read is worse than the
106
+ // refusal it replaced — but a visitor who has never elevated has nothing
107
+ // here, so this page must not mint just to render. `covers` alone would
108
+ // open it for them and buy a 401.
109
+ const { gate: mandateGate, elevation } = useElevatableMandateGate(
110
+ LISTINGS_ELEVATION_ACTIONS.favorite
111
+ );
112
+ const gate = elevation.covers && !elevation.identified
113
+ ? actionBlocked(LISTINGS_I18N_KEYS.blockedSignIn)
114
+ : mandateGate;
96
115
  const [page, setPage] = useState<ListingPageParams>(
97
116
  options.limit !== undefined ? { limit: options.limit } : {}
98
117
  );
@@ -21,7 +21,10 @@ import { asFeatureDaoList, featuresFromDaoList, unreadableFeatureCount } from ".
21
21
  import { listingStatusView } from "../model/status.js";
22
22
  import type { ListingStatusView } from "../model/status.js";
23
23
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
24
- import { useMandateGate } from "./useMandateGate.js";
24
+ import {
25
+ LISTINGS_ELEVATION_ACTIONS,
26
+ useElevatableMandateGate,
27
+ } from "./useMandateGate.js";
25
28
 
26
29
  /**
27
30
  * `images` is `string[] | NULL` on the wire, and the null is the SERVER
@@ -110,7 +113,9 @@ export function useListingDetail(
110
113
  const detail = useListing(id);
111
114
  const probe = useListingStatus(id);
112
115
  const favorite = useFavoriteListing();
113
- const mandate = useMandateGate();
116
+ const { gate: mandate, elevation } = useElevatableMandateGate(
117
+ LISTINGS_ELEVATION_ACTIONS.favorite
118
+ );
114
119
 
115
120
  const state: LoadState<ListingDetailData> =
116
121
  detail.status === "error"
@@ -178,7 +183,7 @@ export function useListingDetail(
178
183
 
179
184
  const favoriteGate = firstBlock(
180
185
  mandate,
181
- favorite.isPending
186
+ favorite.isPending || elevation.pending
182
187
  ? actionBlocked(LISTINGS_I18N_KEYS.blockedInFlight)
183
188
  : actionAvailable(),
184
189
  detail.data === undefined
@@ -206,9 +211,11 @@ export function useListingDetail(
206
211
  favoriteGate,
207
212
  toggleFavorite: () => {
208
213
  if (!favoriteGate.available) return;
209
- favorite.mutate({ id, favorited: isFavorited !== true });
214
+ // Mints the anonymous account first where the host permits it for this
215
+ // action; a direct call everywhere else.
216
+ elevation.run(() => favorite.mutate({ id, favorited: isFavorited !== true }));
210
217
  },
211
- favoriteInFlight: favorite.isPending,
218
+ favoriteInFlight: favorite.isPending || elevation.pending,
212
219
  refetch: () => {
213
220
  void detail.refetch();
214
221
  void probe.refetch();
@@ -1,8 +1,24 @@
1
- import { matchMandate, useMandate } from "@stapel/core";
2
- import type { ActionAvailability } from "@stapel/core";
1
+ import { matchMandate, useElevation, useMandate } from "@stapel/core";
2
+ import type { ActionAvailability, Elevation } from "@stapel/core";
3
3
  import { actionAvailable, actionBlocked } from "@stapel/core";
4
4
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
5
5
 
6
+ /**
7
+ * The names this pair's gated writes use when asking core's elevation seam
8
+ * whether an anonymous visitor may be given an identity instead of a
9
+ * refusal. A host lists the ones it permits (see `ElevationSource.actions`);
10
+ * anything it leaves out keeps its wall.
11
+ *
12
+ * Only `favorite` is named. Publishing a listing is deliberately absent and
13
+ * stays absent: a seller who cannot be reached again is not a seller, and an
14
+ * automatically-minted account is exactly that. Saving something for later
15
+ * is the opposite case — losing a shortlist because a stranger had not
16
+ * registered is friction with nothing behind it.
17
+ */
18
+ export const LISTINGS_ELEVATION_ACTIONS = {
19
+ favorite: "listings.favorite",
20
+ } as const;
21
+
6
22
  /**
7
23
  * "May this person act at all?" — the first gate on every write in the pair.
8
24
  *
@@ -28,13 +44,41 @@ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
28
44
  * than throwing, so a pair rendered in a host that never wired the axis
29
45
  * degrades to "we could not check" — visible, fixable, and not a blank page.
30
46
  */
31
- export function useMandateGate(): ActionAvailability {
47
+ export function useMandateGate(action?: string): ActionAvailability {
48
+ return useElevatableMandateGate(action).gate;
49
+ }
50
+
51
+ /**
52
+ * The same gate, with the elevation handle the caller needs to ACT on it.
53
+ *
54
+ * A control that passes an `action` is saying "for this one act, an
55
+ * anonymous visitor may be given an identity rather than a refusal" — and
56
+ * then it must run its write through `elevation.run`, or the write goes out
57
+ * before the account exists and buys a 401. The two halves are returned
58
+ * together so a caller cannot take the unblocked gate and forget the mint.
59
+ *
60
+ * Passing no `action` is the default and the majority: publishing, saving a
61
+ * draft, marking something sold. Those keep the wall.
62
+ */
63
+ export function useElevatableMandateGate(action?: string): {
64
+ readonly gate: ActionAvailability;
65
+ readonly elevation: Elevation;
66
+ } {
32
67
  const mandate = useMandate();
33
- return matchMandate<ActionAvailability>(mandate, {
68
+ const elevation = useElevation(action);
69
+ const gate = matchMandate<ActionAvailability>(mandate, {
34
70
  member: () => actionAvailable(),
35
71
  guest: () => actionBlocked(LISTINGS_I18N_KEYS.blockedGuest),
36
- anonymous: () => actionBlocked(LISTINGS_I18N_KEYS.blockedSignIn),
72
+ // The one arm elevation changes. An identity can be minted at the moment
73
+ // of the act for the actions the host listed, so the control is offered
74
+ // rather than refused — and the mandate axis itself is untouched, which
75
+ // is what keeps the composer's wall standing for the same visitor.
76
+ anonymous: () =>
77
+ elevation.covers
78
+ ? actionAvailable()
79
+ : actionBlocked(LISTINGS_I18N_KEYS.blockedSignIn),
37
80
  asking: () => actionBlocked(LISTINGS_I18N_KEYS.blockedMandateUnknown),
38
81
  unavailable: () => actionBlocked(LISTINGS_I18N_KEYS.blockedMandateUnknown),
39
82
  });
83
+ return { gate, elevation };
40
84
  }
package/src/i18n/es.ts CHANGED
@@ -32,6 +32,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
32
32
  "Estado de anuncio desconocido: «{status}»",
33
33
  "error.400.publish_validation_failed":
34
34
  "El anuncio no pasó la revisión y no se publicó",
35
+ "error.403.listing_anonymous_not_allowed":
36
+ "Para publicar un anuncio, inicia sesión o crea una cuenta",
35
37
  "error.403.listing_not_owner": "Este anuncio no es tuyo",
36
38
  "error.404.listing_not_found": "No se encontró el anuncio",
37
39
  "error.409.already_favorited": "El anuncio ya está en favoritos",
@@ -72,7 +74,7 @@ export const listingsI18nBundleEs: I18nDictionary = {
72
74
  "listings.card.price_absent": "Precio a consultar",
73
75
  "listings.card.favorite_add": "Guardar en favoritos",
74
76
  "listings.card.favorite_remove": "Quitar de favoritos",
75
- "listings.card.open": "Abrir",
77
+ "listings.card.untitled": "Anuncio sin título",
76
78
  "listings.card.sign_in": "Iniciar sesión",
77
79
 
78
80
  "listings.detail.loading": "Cargando el anuncio…",
@@ -8,7 +8,7 @@ import type { ListingsErrorCode } from "./errors.gen.js";
8
8
  /**
9
9
  * `es` texts for the backend error codes this catalog carries.
10
10
  *
11
- * PARTIAL, and deliberately typed to say so: 22 key(s) owned by
11
+ * PARTIAL, and deliberately typed to say so: 23 key(s) owned by
12
12
  * stapel_attributes, stapel_listings are absent, because that owner ships no locale catalog
13
13
  * (ERRORS_LOCALE_EXEMPT_OWNERS). English for them still comes from the registry
14
14
  * artifact via the en bundle; the pair layers its own authored `es`
@@ -74,6 +74,7 @@ export const LISTINGS_ERRORS = {
74
74
  "error.401.unauthorized": { status: 401, params: [], remediation: "reauthenticate", en: "Authentication required" },
75
75
  "error.402.payment_required": { status: 402, params: [], remediation: "retry", en: "Payment required" },
76
76
  "error.403.forbidden": { status: 403, params: [], remediation: "retry", en: "You do not have permission to perform this action" },
77
+ "error.403.listing_anonymous_not_allowed": { status: 403, params: [], remediation: "retry", en: "A guest account may not publish a listing" },
77
78
  "error.403.listing_not_owner": { status: 403, params: [], remediation: "retry", en: "Not your listing" },
78
79
  "error.403.network_blocked": { status: 403, params: [], remediation: "contact_support", en: "Requests from this network are not allowed" },
79
80
  "error.403.verification_enrollment_required": { status: 403, params: [], remediation: "verify", en: "Verification factor enrollment required" },
@@ -144,6 +145,7 @@ export const LISTINGS_ERROR_CODES: readonly ListingsErrorCode[] = [
144
145
  "error.401.unauthorized",
145
146
  "error.402.payment_required",
146
147
  "error.403.forbidden",
148
+ "error.403.listing_anonymous_not_allowed",
147
149
  "error.403.listing_not_owner",
148
150
  "error.403.network_blocked",
149
151
  "error.403.verification_enrollment_required",
@@ -216,6 +218,7 @@ export const listingsErrorBundleEn: Record<ListingsErrorCode, string> = {
216
218
  "error.401.unauthorized": "Authentication required",
217
219
  "error.402.payment_required": "Payment required",
218
220
  "error.403.forbidden": "You do not have permission to perform this action",
221
+ "error.403.listing_anonymous_not_allowed": "A guest account may not publish a listing",
219
222
  "error.403.listing_not_owner": "Not your listing",
220
223
  "error.403.network_blocked": "Requests from this network are not allowed",
221
224
  "error.403.verification_enrollment_required": "Verification factor enrollment required",
@@ -326,6 +326,13 @@
326
326
  "remediation": "retry",
327
327
  "en": "You do not have permission to perform this action"
328
328
  },
329
+ {
330
+ "code": "error.403.listing_anonymous_not_allowed",
331
+ "status": 403,
332
+ "params": [],
333
+ "remediation": "retry",
334
+ "en": "A guest account may not publish a listing"
335
+ },
329
336
  {
330
337
  "code": "error.403.listing_not_owner",
331
338
  "status": 403,
@@ -8,7 +8,7 @@ import type { ListingsErrorCode } from "./errors.gen.js";
8
8
  /**
9
9
  * `ru` texts for the backend error codes this catalog carries.
10
10
  *
11
- * PARTIAL, and deliberately typed to say so: 22 key(s) owned by
11
+ * PARTIAL, and deliberately typed to say so: 23 key(s) owned by
12
12
  * stapel_attributes, stapel_listings are absent, because that owner ships no locale catalog
13
13
  * (ERRORS_LOCALE_EXEMPT_OWNERS). English for them still comes from the registry
14
14
  * artifact via the en bundle; the pair layers its own authored `ru`
package/src/i18n/keys.ts CHANGED
@@ -62,7 +62,17 @@ export const LISTINGS_I18N_KEYS = {
62
62
  cardPriceAbsent: "listings.card.price_absent",
63
63
  cardFavoriteAdd: "listings.card.favorite_add",
64
64
  cardFavoriteRemove: "listings.card.favorite_remove",
65
- cardOpen: "listings.card.open",
65
+ /**
66
+ * The accessible name of a card whose listing has no title.
67
+ *
68
+ * The whole card is ONE anchor now, and an anchor's name is its `aria-label`
69
+ * — so a titleless listing would otherwise be a link announced as nothing at
70
+ * all, in a list of forty.
71
+ *
72
+ * This replaces `listings.card.open` (retired), which named a separate
73
+ * "Open" control that no longer exists: the card itself is the control.
74
+ */
75
+ cardUntitled: "listings.card.untitled",
66
76
  /** The door beside a blocked favourite: the container supplies WHERE. */
67
77
  cardSignIn: "listings.card.sign_in",
68
78
 
@@ -246,7 +256,7 @@ export const listingsI18nBundleEn: Record<string, string> = {
246
256
  "listings.card.price_absent": "Price on request",
247
257
  "listings.card.favorite_add": "Save to favourites",
248
258
  "listings.card.favorite_remove": "Remove from favourites",
249
- "listings.card.open": "Open",
259
+ "listings.card.untitled": "Untitled listing",
250
260
  "listings.card.sign_in": "Sign in",
251
261
 
252
262
  "listings.detail.loading": "Loading the listing…",
package/src/i18n/ru.ts CHANGED
@@ -48,6 +48,8 @@ export const listingsI18nBundleRu: I18nDictionary = {
48
48
  "Неизвестный статус объявления: «{status}»",
49
49
  "error.400.publish_validation_failed":
50
50
  "Объявление не прошло проверку и не опубликовано",
51
+ "error.403.listing_anonymous_not_allowed":
52
+ "Чтобы подать объявление, войдите или зарегистрируйтесь",
51
53
  "error.403.listing_not_owner": "Это не ваше объявление",
52
54
  "error.404.listing_not_found": "Объявление не найдено",
53
55
  "error.409.already_favorited": "Объявление уже в избранном",
@@ -88,7 +90,7 @@ export const listingsI18nBundleRu: I18nDictionary = {
88
90
  "listings.card.price_absent": "Цена по запросу",
89
91
  "listings.card.favorite_add": "В избранное",
90
92
  "listings.card.favorite_remove": "Убрать из избранного",
91
- "listings.card.open": "Открыть",
93
+ "listings.card.untitled": "Объявление без названия",
92
94
  "listings.card.sign_in": "Войти",
93
95
 
94
96
  "listings.detail.loading": "Загружаем объявление…",
package/src/index.ts CHANGED
@@ -214,7 +214,11 @@ export type {
214
214
 
215
215
  // ── headless (renderless components + their bags) ────────────────────────────
216
216
  export { ListingsProvider } from "./headless/ListingsProvider.js";
217
- export { useMandateGate } from "./headless/useMandateGate.js";
217
+ export {
218
+ useMandateGate,
219
+ useElevatableMandateGate,
220
+ LISTINGS_ELEVATION_ACTIONS,
221
+ } from "./headless/useMandateGate.js";
218
222
  export { ListingDetail, useListingDetail } from "./headless/ListingDetail.js";
219
223
  export type {
220
224
  ListingDetailBag,