@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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The two axes, drawn as two.
2
+ * The two axes, drawn as two — in ONE visual treatment.
3
3
  *
4
4
  * `<LifecycleTag>` is the state; `<ModerationNote>` is what the moderation
5
5
  * axis adds ON TOP of it, and it renders nothing when there is nothing to add
@@ -8,13 +8,24 @@
8
8
  * than one overwriting the other — the failure `model/status.ts` exists to
9
9
  * prevent, made visual.
10
10
  *
11
+ * ── Why the note is a line and not an Alert ────────────────────────────────
12
+ *
13
+ * It was an antd `Alert`, toned by the moderation status, and the visual pass
14
+ * measured the result: one concept drawn three ways down a single dashboard —
15
+ * a full-bleed olive bar for one row, a full-bleed grey bar for the next, bare
16
+ * red text for a third — and a phone screen where a 340px khaki box wrapped
17
+ * one sentence over six ragged lines. A status is a status: ONE tag carrying
18
+ * the tone, and the sentence beneath it as ordinary text. Loud is not the
19
+ * same as clear, and a row that shouts three different ways is neither.
20
+ *
11
21
  * A tone maps to an antd preset here and nowhere else. No hex leaves this
12
22
  * file, because none enters it: the presets resolve through the theme, which
13
23
  * resolves through `@stapel/tokens`.
14
24
  */
15
25
  import type { ReactElement } from "react";
16
- import { Alert, Tag } from "antd";
26
+ import { Flex, Tag, Typography, theme as antdTheme } from "antd";
17
27
  import { useT } from "@stapel/core";
28
+ import { spacing } from "@stapel/tokens";
18
29
  import type { ListingStatusTone, ListingStatusView } from "../model/status.js";
19
30
 
20
31
  const TONE_COLOR: Readonly<Record<ListingStatusTone, string>> = {
@@ -25,14 +36,17 @@ const TONE_COLOR: Readonly<Record<ListingStatusTone, string>> = {
25
36
  stopped: "error",
26
37
  };
27
38
 
28
- const TONE_ALERT: Readonly<
29
- Record<ListingStatusTone, "info" | "success" | "warning" | "error">
39
+ /** How urgent the sentence beneath the tag reads. `warning` is the only tone
40
+ * that asks the owner to DO something, so it is the only one that gets antd's
41
+ * danger colour; the rest are secondary text. */
42
+ const TONE_TEXT: Readonly<
43
+ Record<ListingStatusTone, "secondary" | "warning" | "success">
30
44
  > = {
31
- neutral: "info",
32
- waiting: "info",
45
+ neutral: "secondary",
46
+ waiting: "secondary",
33
47
  good: "success",
34
48
  warning: "warning",
35
- stopped: "error",
49
+ stopped: "warning",
36
50
  };
37
51
 
38
52
  export interface ListingStatusProps {
@@ -40,7 +54,7 @@ export interface ListingStatusProps {
40
54
  }
41
55
 
42
56
  /** The lifecycle state — the field that decides whether anyone else can see
43
- * this listing. */
57
+ * this listing. One tag, never full-bleed, never bare text. */
44
58
  export function LifecycleTag(props: ListingStatusProps): ReactElement {
45
59
  const t = useT();
46
60
  return (
@@ -48,6 +62,7 @@ export function LifecycleTag(props: ListingStatusProps): ReactElement {
48
62
  color={TONE_COLOR[props.status.lifecycle.tone]}
49
63
  data-testid="listings-status-tag"
50
64
  data-listing-status={props.status.lifecycle.status}
65
+ style={{ marginInlineEnd: 0 }}
51
66
  >
52
67
  {t(props.status.lifecycle.labelKey)}
53
68
  </Tag>
@@ -55,32 +70,35 @@ export function LifecycleTag(props: ListingStatusProps): ReactElement {
55
70
  }
56
71
 
57
72
  /**
58
- * What the moderation axis adds. Renders `null` when it adds nothing, so a
59
- * calm listing shows no banner at all.
73
+ * What the moderation axis adds, as one sentence. Renders `null` when it adds
74
+ * nothing, so a calm listing shows no note at all.
60
75
  */
61
76
  export function ModerationNote(props: ListingStatusProps): ReactElement | null {
62
77
  const t = useT();
78
+ const { token } = antdTheme.useToken();
63
79
  const notice = props.status.moderation;
64
80
  if (notice === undefined) return null;
65
81
  return (
66
- <Alert
67
- type={TONE_ALERT[notice.tone]}
68
- showIcon
82
+ <Typography.Text
83
+ type={TONE_TEXT[notice.tone]}
69
84
  data-testid="listings-moderation-note"
70
85
  data-listing-moderation={notice.moderationStatus}
71
86
  data-listing-live-under-review={String(notice.liveDuringReview)}
72
- message={t(notice.messageKey)}
73
- />
87
+ style={{ fontSize: token.fontSizeSM }}
88
+ >
89
+ {t(notice.messageKey)}
90
+ </Typography.Text>
74
91
  );
75
92
  }
76
93
 
77
94
  /** Both, in the order a person reads them: what it IS, then what is happening
78
- * to it. */
95
+ * to it. A column so the sentence can never be squeezed beside the tag on a
96
+ * 390px row — the shape that split "Draf/t" across two lines. */
79
97
  export function ListingStatusBlock(props: ListingStatusProps): ReactElement {
80
98
  return (
81
- <>
99
+ <Flex vertical align="flex-start" gap={spacing[1]} style={{ minWidth: 0 }}>
82
100
  <LifecycleTag status={props.status} />
83
101
  <ModerationNote status={props.status} />
84
- </>
102
+ </Flex>
85
103
  );
86
104
  }
@@ -13,23 +13,37 @@
13
13
  * // the search pair's card slot — the container is the seam, not an import
14
14
  * <SearchPage renderCard={(item) => <ListingCard listing={item.card} href={`/l/${item.id}`} />} />
15
15
  * ```
16
+ *
17
+ * ── What this barrel no longer exports, and where it went ──────────────────
18
+ *
19
+ * `ListingsSkinTheme` and `ErrorAlert` were this pair's copies of two
20
+ * components nine pairs each carried a copy of. They live in
21
+ * `@stapel/tokens-antd/skin` now, as `SkinTheme` and `ErrorAlert`: one place
22
+ * for the reactive-theme fix, one place for the message/detail split. A host
23
+ * that wrapped its own composition in `<ListingsSkinTheme>` imports
24
+ * `<SkinTheme>` from the substrate instead — same props, plus a `surface`.
16
25
  */
17
26
  export { ListingCard } from "./ListingCard.js";
18
27
  export type {
19
28
  ListingCardProps,
20
29
  ListingCardBaseProps,
21
30
  ListingCardOpenProps,
31
+ ListingCardBlockedReason,
22
32
  } from "./ListingCard.js";
23
- export { ListingDetailPane } from "./ListingDetailPane.js";
33
+ export { ListingDetailPane, DETAIL_MEASURE, DETAIL_PHOTO_MIN } from "./ListingDetailPane.js";
24
34
  export type { ListingDetailPaneProps } from "./ListingDetailPane.js";
25
- export { ListingComposerPage } from "./ListingComposerPage.js";
35
+ export { ListingComposerPage, COMPOSER_MEASURE } from "./ListingComposerPage.js";
26
36
  export type {
27
37
  ListingComposerPageProps,
28
38
  ComposerCategorySlot,
39
+ ComposerCurrencySlot,
40
+ ComposerLocationSlot,
41
+ ComposerLocationValue,
42
+ ComposerLocationPickerProps,
29
43
  } from "./ListingComposerPage.js";
30
44
  export { MyListingsPane } from "./MyListingsPane.js";
31
45
  export type { MyListingsPaneProps } from "./MyListingsPane.js";
32
- export { FavoritesPane } from "./FavoritesPane.js";
46
+ export { FavoritesPane, FAVORITES_CARD_MIN } from "./FavoritesPane.js";
33
47
  export type {
34
48
  FavoritesPaneProps,
35
49
  FavoritesPaneOpenProps,
@@ -37,11 +51,8 @@ export type {
37
51
 
38
52
  export { LifecycleTag, ListingStatusBlock, ModerationNote } from "./StatusTags.js";
39
53
  export type { ListingStatusProps } from "./StatusTags.js";
40
- export { ListingPhoto } from "./ListingPhoto.js";
54
+ export { ListingPhoto, LISTING_PHOTO_ASPECT } from "./ListingPhoto.js";
41
55
  export type { ListingPhotoProps } from "./ListingPhoto.js";
42
- export { ErrorAlert } from "./ErrorAlert.js";
43
56
  export { SignInLink } from "./SignInLink.js";
44
57
  export type { SignInLinkProps } from "./SignInLink.js";
45
- export { ListingsSkinTheme } from "./theme.js";
46
- export type { ListingsSkinThemeProps } from "./theme.js";
47
58
  export type { ThemeModeProp } from "./types.js";
@@ -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,29 +51,40 @@ 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
 
71
79
  export interface FavoritesBag {
72
80
  readonly rows: LoadState<readonly ListingCard[]>;
73
81
  readonly page: ListingPageParams;
82
+ /**
83
+ * Which page is on screen, 1-based. Keyset paging has no offset to read a
84
+ * number off, so it is counted: a pager with no indicator leaves a person
85
+ * unable to tell a second page from a reloaded first one.
86
+ */
87
+ readonly pageNumber: number;
74
88
  readonly nextPage: ActionAvailability;
75
89
  readonly prevPage: ActionAvailability;
76
90
  goNext(): void;
@@ -86,10 +100,22 @@ export interface UseFavoritesOptions {
86
100
  /** The favourites page: a real keyset list, unlike the owner's own listings
87
101
  * (see `MyListings.tsx` for why those are different). */
88
102
  export function useFavorites(options: UseFavoritesOptions = {}): FavoritesBag {
89
- 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;
90
115
  const [page, setPage] = useState<ListingPageParams>(
91
116
  options.limit !== undefined ? { limit: options.limit } : {}
92
117
  );
118
+ const [pageNumber, setPageNumber] = useState(1);
93
119
  const query = useMyFavorites(page, { enabled: gate.available });
94
120
  const envelope = query.data;
95
121
 
@@ -101,6 +127,7 @@ export function useFavorites(options: UseFavoritesOptions = {}): FavoritesBag {
101
127
  ? loadReady(envelope.items)
102
128
  : loadLoading(),
103
129
  page,
130
+ pageNumber,
104
131
  nextPage:
105
132
  envelope?.has_next === true && envelope.next_anchor != null
106
133
  ? actionAvailable()
@@ -113,11 +140,13 @@ export function useFavorites(options: UseFavoritesOptions = {}): FavoritesBag {
113
140
  const anchor = envelope?.next_anchor;
114
141
  if (anchor == null) return;
115
142
  setPage((current) => ({ ...current, anchor, direction: "next" }));
143
+ setPageNumber((current) => current + 1);
116
144
  },
117
145
  goPrev: () => {
118
146
  const anchor = envelope?.prev_anchor;
119
147
  if (anchor == null) return;
120
148
  setPage((current) => ({ ...current, anchor, direction: "prev" }));
149
+ setPageNumber((current) => Math.max(1, current - 1));
121
150
  },
122
151
  gate,
123
152
  refetch: () => {
@@ -33,6 +33,18 @@ export interface ListingActionsBag {
33
33
  readonly archive: ActionAvailability;
34
34
  readonly complete: ActionAvailability;
35
35
  readonly remove: ActionAvailability;
36
+ /**
37
+ * Whether "edit this listing" is offerable — and if not, WHY.
38
+ *
39
+ * Editing is not an endpoint this pair calls: the composer is a screen, and
40
+ * whether the app has one is the container's fact, which is why it arrives
41
+ * as an argument rather than being derived. It is a gate all the same,
42
+ * because the alternative shipped for two releases: `<GatedButton gate={{
43
+ * available: true }}>` beside an absent `onEdit`, i.e. an enabled button
44
+ * that did nothing at all in the scripted scaffold (§83, "a control that
45
+ * offers something meaningless in the current state").
46
+ */
47
+ editGate(hasEditor: boolean): ActionAvailability;
36
48
  doArchive(): void;
37
49
  doComplete(): void;
38
50
  doRemove(): void;
@@ -86,6 +98,13 @@ export function useListingActions(
86
98
  archive: archiveGate,
87
99
  complete: completeGate,
88
100
  remove: removeGate,
101
+ editGate: (hasEditor) =>
102
+ firstBlock(
103
+ mandate,
104
+ hasEditor
105
+ ? actionAvailable()
106
+ : actionBlocked(LISTINGS_I18N_KEYS.blockedNoEditor)
107
+ ),
89
108
  doArchive: () => {
90
109
  if (archiveGate.available) archive.mutate(id);
91
110
  },
@@ -122,6 +122,18 @@ export interface UseListingComposerOptions {
122
122
  /** Value types the rendering half can draw — pass
123
123
  * `BUILTIN_VALUE_EDITOR_TYPES` from `@stapel/attributes-react/default`. */
124
124
  readonly editorTypes?: readonly string[];
125
+ /**
126
+ * The language a NEW draft is written in — the composer's own UI locale, in
127
+ * practice (`useI18n().locale`).
128
+ *
129
+ * `Listing.language` is sent on every save and was never settable: a blank
130
+ * value let the server's deployment default decide, so a Spanish seller's
131
+ * listing was filed as Russian because that is what the storefront defaults
132
+ * to. Seeding it from the locale the form is being READ in is the only
133
+ * honest guess a library can make, and a host that knows better passes
134
+ * `initialValues.language`, which still wins.
135
+ */
136
+ readonly language?: string;
125
137
  /** The upload queue. Absent: no gallery, and `images` is set through
126
138
  * `setValue` by whatever the host uses instead. */
127
139
  readonly images?: ListingImagesBag;
@@ -186,6 +198,17 @@ export interface ListingComposerBag {
186
198
  readonly saveGate: ActionAvailability;
187
199
  readonly publishGate: ActionAvailability;
188
200
  save(): void;
201
+ /**
202
+ * Save once the state written in this same handler has landed.
203
+ *
204
+ * `save()` closes over the values of the render it was created in, so a
205
+ * picker doing `setLocation(place); save()` in one click saved the draft as
206
+ * it was BEFORE the place was chosen — the plain fields never hit this
207
+ * because a blur is always a separate tick from the keystroke. A picker has
208
+ * no blur: choosing a suggestion IS the commit, so it needs a save that runs
209
+ * after the write it belongs to.
210
+ */
211
+ saveSoon(): void;
189
212
  publish(): void;
190
213
  readonly saving: boolean;
191
214
  readonly publishing: boolean;
@@ -204,7 +227,10 @@ export function useListingComposer(
204
227
  options.listingId
205
228
  );
206
229
  const [values, setValues] = useState<ListingDraftValues>(() => ({
207
- ...emptyDraftValues({ currency: runtime.currency }),
230
+ ...emptyDraftValues({
231
+ currency: runtime.currency,
232
+ ...(options.language !== undefined ? { language: options.language } : {}),
233
+ }),
208
234
  ...options.initialValues,
209
235
  }));
210
236
  const [showErrors, setShowErrors] = useState(false);
@@ -212,6 +238,9 @@ export function useListingComposer(
212
238
  const [refusal, setRefusal] = useState<PublishRefusal | undefined>(undefined);
213
239
  const [outcome, setOutcome] = useState<PublishOutcome | undefined>(undefined);
214
240
  const [saved, setSaved] = useState(false);
241
+ // A counter rather than a boolean: two picks in a row are two saves, and a
242
+ // boolean that was already `true` would swallow the second.
243
+ const [saveRequest, setSaveRequest] = useState(0);
215
244
 
216
245
  const existing = useListing(options.listingId);
217
246
  const createDraft = useCreateDraft();
@@ -349,18 +378,26 @@ export function useListingComposer(
349
378
  mandate,
350
379
  categoryGate,
351
380
  schemaGate,
381
+ // The type names (`size_grid`) are this build's vocabulary, not copy: a
382
+ // seller can act on "finish it somewhere else", never on the identifier.
352
383
  unsupported.length > 0
353
- ? actionBlocked(LISTINGS_I18N_KEYS.composeBlockedUnsupportedType, {
354
- types: unsupported.join(", "),
355
- })
384
+ ? actionBlocked(LISTINGS_I18N_KEYS.composeBlockedUnsupportedType)
356
385
  : actionAvailable(),
357
386
  options.images?.settled ?? actionAvailable(),
358
387
  busyGate,
359
388
  publishListing.isPending
360
389
  ? actionBlocked(LISTINGS_I18N_KEYS.composeBlockedBusy)
361
390
  : actionAvailable(),
391
+ // "Fix the highlighted fields first" is only true once something IS
392
+ // highlighted — the mirror reaches the fields only after `showErrors`.
393
+ // Before that the same block has to say what is actually missing.
362
394
  Object.keys(mirror).length > 0
363
- ? actionBlocked(LISTINGS_I18N_KEYS.composeBlockedMirror)
395
+ ? actionBlocked(
396
+ showErrors
397
+ ? LISTINGS_I18N_KEYS.composeBlockedMirror
398
+ : LISTINGS_I18N_KEYS.composeBlockedIncomplete,
399
+ showErrors ? undefined : { count: Object.keys(mirror).length }
400
+ )
364
401
  : actionAvailable()
365
402
  );
366
403
 
@@ -401,6 +438,19 @@ export function useListingComposer(
401
438
  });
402
439
  }, [saveGate.available, persist]);
403
440
 
441
+ // `save` through a ref, so the effect below can run the CURRENT one without
442
+ // re-firing every time the form changes shape.
443
+ const saveRef = useRef(save);
444
+ saveRef.current = save;
445
+ useEffect(() => {
446
+ if (saveRequest === 0) return;
447
+ saveRef.current();
448
+ }, [saveRequest]);
449
+
450
+ const saveSoon = useCallback((): void => {
451
+ setSaveRequest((current) => current + 1);
452
+ }, []);
453
+
404
454
  const publish = useCallback((): void => {
405
455
  setShowErrors(true);
406
456
  if (!publishGate.available) return;
@@ -489,6 +539,7 @@ export function useListingComposer(
489
539
  saveGate,
490
540
  publishGate,
491
541
  save,
542
+ saveSoon,
492
543
  publish,
493
544
  saving: busy,
494
545
  publishing: publishListing.isPending,
@@ -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
@@ -1,4 +1,5 @@
1
1
  import type { I18nDictionary, I18nEngine } from "@stapel/core";
2
+ import { registerAttributesI18nEs } from "@stapel/attributes-react/i18n/es";
2
3
  import { listingsErrorBundleEs } from "./generated/errors.es.gen.js";
3
4
 
4
5
  export { listingsErrorBundleEs } from "./generated/errors.es.gen.js";
@@ -31,6 +32,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
31
32
  "Estado de anuncio desconocido: «{status}»",
32
33
  "error.400.publish_validation_failed":
33
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",
34
37
  "error.403.listing_not_owner": "Este anuncio no es tuyo",
35
38
  "error.404.listing_not_found": "No se encontró el anuncio",
36
39
  "error.409.already_favorited": "El anuncio ya está en favoritos",
@@ -71,7 +74,7 @@ export const listingsI18nBundleEs: I18nDictionary = {
71
74
  "listings.card.price_absent": "Precio a consultar",
72
75
  "listings.card.favorite_add": "Guardar en favoritos",
73
76
  "listings.card.favorite_remove": "Quitar de favoritos",
74
- "listings.card.open": "Abrir",
77
+ "listings.card.untitled": "Anuncio sin título",
75
78
  "listings.card.sign_in": "Iniciar sesión",
76
79
 
77
80
  "listings.detail.loading": "Cargando el anuncio…",
@@ -94,6 +97,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
94
97
  "listings.detail.published_at": "Publicado el {date}",
95
98
  "listings.detail.expires_at": "A la venta hasta el {date}",
96
99
  "listings.detail.stock": "Disponibles",
100
+ "listings.detail.edit": "Editar el anuncio",
101
+ "listings.detail.take_down": "Retirarlo",
97
102
 
98
103
  "listings.compose.new_title": "Anuncio nuevo",
99
104
  "listings.compose.edit_title": "Editar el anuncio",
@@ -112,8 +117,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
112
117
  "Escribe el precio como un número, con dos decimales como mucho",
113
118
  "listings.compose.currency_label": "Moneda",
114
119
  "listings.compose.location_label": "Dónde está",
115
- "listings.compose.lat_label": "Latitud",
116
- "listings.compose.lon_label": "Longitud",
120
+ "listings.compose.location_help":
121
+ "La gente busca por distancia — un anuncio sin lugar es un anuncio que no encontrarán",
117
122
  "listings.compose.geo_incomplete":
118
123
  "Una latitud necesita su longitud al lado — media coordenada no apunta a ningún sitio",
119
124
  "listings.compose.photos": "Fotos",
@@ -143,12 +148,13 @@ export const listingsI18nBundleEs: I18nDictionary = {
143
148
  "listings.compose.blocked.no_category":
144
149
  "Elige una categoría — el resto del formulario depende de ella",
145
150
  "listings.compose.blocked.unsupported_type":
146
- "Esta categoría pide un tipo de detalle que la aplicación todavía no sabe mostrar ({types}), así que aquí no se puede rellenar",
151
+ "Esta categoría pide un tipo de detalle que la aplicación todavía no sabe mostrar, así que el anuncio no se puede completar aquí",
147
152
  "listings.compose.blocked.photos_pending":
148
153
  "Espera a que terminen de subirse las fotos",
149
154
  "listings.compose.blocked.no_draft": "El borrador todavía no está creado",
150
155
  "listings.compose.blocked.busy":
151
156
  "Un momento — el último cambio se está guardando",
157
+ "listings.compose.blocked.incomplete": "Faltan {count} datos obligatorios",
152
158
  "listings.compose.blocked.mirror": "Corrige primero los campos marcados",
153
159
  "listings.compose.blocked.details_unavailable":
154
160
  "No pudimos cargar lo que pide esta categoría, así que no podemos revisar el formulario",
@@ -167,6 +173,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
167
173
  "listings.mine.empty.archived": "Nada archivado, pausado, caducado ni vendido todavía",
168
174
  "listings.mine.blocked.title":
169
175
  "Moderación retiró {count} de tus anuncios",
176
+ "listings.mine.blocked.title.one": "Moderación retiró uno de tus anuncios",
177
+ "listings.mine.blocked.title.other": "Moderación retiró {count} de tus anuncios",
170
178
  "listings.mine.blocked.load_failed":
171
179
  "No pudimos comprobar si alguno de tus anuncios fue retirado",
172
180
  "listings.mine.live_under_review": "Publicado, cambios en revisión",
@@ -174,11 +182,18 @@ export const listingsI18nBundleEs: I18nDictionary = {
174
182
  "listings.mine.archive": "Archivar",
175
183
  "listings.mine.complete": "Marcar como vendido",
176
184
  "listings.mine.delete": "Borrar",
185
+ "listings.mine.delete_confirm_title": "¿Borrar este anuncio?",
186
+ "listings.mine.delete_confirm_body":
187
+ "Desaparece de tu panel y no se puede recuperar. Archivarlo lo conserva.",
177
188
 
178
189
  "listings.favorites.title": "Favoritos",
179
190
  "listings.favorites.loading": "Cargando tus favoritos…",
180
191
  "listings.favorites.load_failed": "No pudimos cargar tus favoritos",
181
192
  "listings.favorites.empty": "Todavía no has guardado nada",
193
+ "listings.favorites.empty_hint":
194
+ "Toca el corazón en cualquier anuncio y te estará esperando aquí.",
195
+ "listings.favorites.sign_in_hint":
196
+ "Los favoritos se guardan en tu cuenta, así que te siguen entre dispositivos.",
182
197
 
183
198
  "listings.blocked.sign_in": "Inicia sesión para hacer esto",
184
199
  "listings.blocked.guest":
@@ -190,9 +205,12 @@ export const listingsI18nBundleEs: I18nDictionary = {
190
205
  "listings.blocked.delete_active":
191
206
  "Archívalo primero — un anuncio a la venta no se puede borrar",
192
207
  "listings.blocked.in_flight": "Un momento — eso ya está en marcha",
208
+ "listings.blocked.no_editor":
209
+ "Esta aplicación todavía no tiene una pantalla para editar un anuncio",
193
210
 
194
211
  "listings.page.prev": "Anterior",
195
212
  "listings.page.next": "Siguiente",
213
+ "listings.page.indicator": "Página {page}",
196
214
 
197
215
  "listings.nav.detail": "Anuncio",
198
216
  "listings.nav.compose": "Poner un anuncio",
@@ -200,7 +218,13 @@ export const listingsI18nBundleEs: I18nDictionary = {
200
218
  "listings.nav.favorites": "Favoritos",
201
219
  };
202
220
 
203
- /** Register the Spanish bundle into a core i18n engine. */
221
+ /**
222
+ * Register the Spanish bundle — and, exactly as in `./ru.ts`, the twelve
223
+ * `stapel_attributes` sentences this pair does not author. The ownership split
224
+ * is a rule about who WRITES a string, not an instruction to ship two thirds
225
+ * of a locale and document the rest.
226
+ */
204
227
  export function registerListingsI18nEs(i18n: I18nEngine): void {
228
+ registerAttributesI18nEs(i18n);
205
229
  i18n.registerBundle("es", listingsI18nBundleEs);
206
230
  }
@@ -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`