@stapel/listings-react 0.3.1 → 0.7.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 (164) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/MODULE.md +12 -6
  3. package/README.md +33 -17
  4. package/dist/api/generated/schema.d.ts +129 -3
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/api/listingsApi.d.ts +35 -22
  7. package/dist/api/listingsApi.d.ts.map +1 -1
  8. package/dist/api/listingsApi.js +17 -0
  9. package/dist/api/listingsApi.js.map +1 -1
  10. package/dist/api/types.d.ts +43 -2
  11. package/dist/api/types.d.ts.map +1 -1
  12. package/dist/api/types.js.map +1 -1
  13. package/dist/default/FavoritesPane.d.ts +40 -3
  14. package/dist/default/FavoritesPane.d.ts.map +1 -1
  15. package/dist/default/FavoritesPane.js +30 -18
  16. package/dist/default/FavoritesPane.js.map +1 -1
  17. package/dist/default/ListingCard.d.ts +25 -4
  18. package/dist/default/ListingCard.d.ts.map +1 -1
  19. package/dist/default/ListingCard.js +19 -12
  20. package/dist/default/ListingCard.js.map +1 -1
  21. package/dist/default/ListingComposerPage.d.ts +163 -23
  22. package/dist/default/ListingComposerPage.d.ts.map +1 -1
  23. package/dist/default/ListingComposerPage.js +85 -43
  24. package/dist/default/ListingComposerPage.js.map +1 -1
  25. package/dist/default/ListingDetailPane.d.ts +41 -4
  26. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  27. package/dist/default/ListingDetailPane.js +38 -25
  28. package/dist/default/ListingDetailPane.js.map +1 -1
  29. package/dist/default/ListingPhoto.d.ts +13 -1
  30. package/dist/default/ListingPhoto.d.ts.map +1 -1
  31. package/dist/default/ListingPhoto.js +47 -9
  32. package/dist/default/ListingPhoto.js.map +1 -1
  33. package/dist/default/ListingPrice.d.ts +13 -0
  34. package/dist/default/ListingPrice.d.ts.map +1 -0
  35. package/dist/default/ListingPrice.js +60 -0
  36. package/dist/default/ListingPrice.js.map +1 -0
  37. package/dist/default/MyListingsPane.d.ts +9 -17
  38. package/dist/default/MyListingsPane.d.ts.map +1 -1
  39. package/dist/default/MyListingsPane.js +148 -63
  40. package/dist/default/MyListingsPane.js.map +1 -1
  41. package/dist/default/SignInLink.js +8 -7
  42. package/dist/default/SignInLink.js.map +1 -1
  43. package/dist/default/StatusTags.d.ts +16 -5
  44. package/dist/default/StatusTags.d.ts.map +1 -1
  45. package/dist/default/StatusTags.js +19 -13
  46. package/dist/default/StatusTags.js.map +1 -1
  47. package/dist/default/index.d.ts +15 -9
  48. package/dist/default/index.d.ts.map +1 -1
  49. package/dist/default/index.js +13 -6
  50. package/dist/default/index.js.map +1 -1
  51. package/dist/headless/Favorites.d.ts +6 -0
  52. package/dist/headless/Favorites.d.ts.map +1 -1
  53. package/dist/headless/Favorites.js +4 -0
  54. package/dist/headless/Favorites.js.map +1 -1
  55. package/dist/headless/ListingActions.d.ts +12 -0
  56. package/dist/headless/ListingActions.d.ts.map +1 -1
  57. package/dist/headless/ListingActions.js +3 -0
  58. package/dist/headless/ListingActions.js.map +1 -1
  59. package/dist/headless/ListingComposer.d.ts +23 -0
  60. package/dist/headless/ListingComposer.d.ts.map +1 -1
  61. package/dist/headless/ListingComposer.js +33 -7
  62. package/dist/headless/ListingComposer.js.map +1 -1
  63. package/dist/headless/MyListings.d.ts +27 -12
  64. package/dist/headless/MyListings.d.ts.map +1 -1
  65. package/dist/headless/MyListings.js +29 -7
  66. package/dist/headless/MyListings.js.map +1 -1
  67. package/dist/i18n/es.d.ts +6 -1
  68. package/dist/i18n/es.d.ts.map +1 -1
  69. package/dist/i18n/es.js +28 -6
  70. package/dist/i18n/es.js.map +1 -1
  71. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  72. package/dist/i18n/generated/errors.es.gen.js +1 -1
  73. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  74. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  75. package/dist/i18n/generated/errors.gen.js +3 -0
  76. package/dist/i18n/generated/errors.gen.js.map +1 -1
  77. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  78. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  79. package/dist/i18n/keys.d.ts +28 -4
  80. package/dist/i18n/keys.d.ts.map +1 -1
  81. package/dist/i18n/keys.js +49 -8
  82. package/dist/i18n/keys.js.map +1 -1
  83. package/dist/i18n/ru.d.ts +16 -1
  84. package/dist/i18n/ru.d.ts.map +1 -1
  85. package/dist/i18n/ru.js +40 -6
  86. package/dist/i18n/ru.js.map +1 -1
  87. package/dist/index.d.ts +8 -6
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +8 -6
  90. package/dist/index.js.map +1 -1
  91. package/dist/model/draft.d.ts +9 -6
  92. package/dist/model/draft.d.ts.map +1 -1
  93. package/dist/model/draft.js +3 -1
  94. package/dist/model/draft.js.map +1 -1
  95. package/dist/model/features.d.ts +21 -7
  96. package/dist/model/features.d.ts.map +1 -1
  97. package/dist/model/features.js +26 -0
  98. package/dist/model/features.js.map +1 -1
  99. package/dist/model/mine.d.ts +44 -0
  100. package/dist/model/mine.d.ts.map +1 -0
  101. package/dist/model/mine.js +52 -0
  102. package/dist/model/mine.js.map +1 -0
  103. package/dist/model/mineSource.d.ts +39 -34
  104. package/dist/model/mineSource.d.ts.map +1 -1
  105. package/dist/model/mineSource.js +10 -41
  106. package/dist/model/mineSource.js.map +1 -1
  107. package/dist/model/mutations.d.ts.map +1 -1
  108. package/dist/model/mutations.js +11 -4
  109. package/dist/model/mutations.js.map +1 -1
  110. package/dist/model/queryKeys.d.ts +7 -2
  111. package/dist/model/queryKeys.d.ts.map +1 -1
  112. package/dist/model/queryKeys.js +2 -0
  113. package/dist/model/queryKeys.js.map +1 -1
  114. package/dist/model/status.d.ts +12 -31
  115. package/dist/model/status.d.ts.map +1 -1
  116. package/dist/model/status.js +44 -0
  117. package/dist/model/status.js.map +1 -1
  118. package/llms.txt +11 -8
  119. package/manifest.json +88 -14
  120. package/nav-manifest.json +1 -1
  121. package/package.json +17 -12
  122. package/src/analytics/generated/events.json +1 -1
  123. package/src/api/generated/schema.ts +129 -3
  124. package/src/api/listingsApi.ts +60 -21
  125. package/src/api/types.ts +46 -2
  126. package/src/default/FavoritesPane.tsx +156 -82
  127. package/src/default/ListingCard.tsx +88 -46
  128. package/src/default/ListingComposerPage.tsx +403 -187
  129. package/src/default/ListingDetailPane.tsx +200 -98
  130. package/src/default/ListingPhoto.tsx +89 -16
  131. package/src/default/ListingPrice.tsx +77 -0
  132. package/src/default/MyListingsPane.tsx +344 -222
  133. package/src/default/SignInLink.tsx +4 -4
  134. package/src/default/StatusTags.tsx +36 -18
  135. package/src/default/index.ts +18 -7
  136. package/src/headless/Favorites.tsx +10 -0
  137. package/src/headless/ListingActions.tsx +19 -0
  138. package/src/headless/ListingComposer.tsx +56 -5
  139. package/src/headless/MyListings.tsx +62 -22
  140. package/src/i18n/es.ts +36 -7
  141. package/src/i18n/generated/errors.es.gen.ts +1 -1
  142. package/src/i18n/generated/errors.gen.ts +3 -0
  143. package/src/i18n/generated/errors.json +9 -0
  144. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  145. package/src/i18n/keys.ts +58 -9
  146. package/src/i18n/ru.ts +48 -7
  147. package/src/index.ts +16 -5
  148. package/src/model/draft.ts +12 -7
  149. package/src/model/features.ts +48 -7
  150. package/src/model/mine.ts +70 -0
  151. package/src/model/mineSource.ts +46 -41
  152. package/src/model/mutations.ts +11 -4
  153. package/src/model/queryKeys.ts +9 -2
  154. package/src/model/status.ts +15 -0
  155. package/dist/default/ErrorAlert.d.ts +0 -9
  156. package/dist/default/ErrorAlert.d.ts.map +0 -1
  157. package/dist/default/ErrorAlert.js +0 -27
  158. package/dist/default/ErrorAlert.js.map +0 -1
  159. package/dist/default/theme.d.ts +0 -25
  160. package/dist/default/theme.d.ts.map +0 -1
  161. package/dist/default/theme.js +0 -40
  162. package/dist/default/theme.js.map +0 -1
  163. package/src/default/ErrorAlert.tsx +0 -46
  164. package/src/default/theme.tsx +0 -67
@@ -33,12 +33,12 @@ export function SignInLink(props: SignInLinkProps): ReactElement | null {
33
33
  const t = useT();
34
34
  const { cta } = props;
35
35
  if (cta === undefined) return null;
36
- // The separating space belongs HERE, not at the call site: a reason with no
37
- // door must render as exactly its own sentence, and a `{" "}` left behind by
38
- // an absent link is a trailing space in every caller's assertion.
36
+ // No separating space: since the shared `<GatedControl>` prints the reason
37
+ // as its own paragraph, the door is a standalone element rather than the
38
+ // tail of a sentence, and a leading `{" "}` would be a stray space in every
39
+ // caller's assertion.
39
40
  return (
40
41
  <>
41
- {" "}
42
42
  <Typography.Link
43
43
  data-testid={props.testId}
44
44
  data-analytics="none"
@@ -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";
@@ -71,6 +71,12 @@ export function useFavoriteToggle(
71
71
  export interface FavoritesBag {
72
72
  readonly rows: LoadState<readonly ListingCard[]>;
73
73
  readonly page: ListingPageParams;
74
+ /**
75
+ * Which page is on screen, 1-based. Keyset paging has no offset to read a
76
+ * number off, so it is counted: a pager with no indicator leaves a person
77
+ * unable to tell a second page from a reloaded first one.
78
+ */
79
+ readonly pageNumber: number;
74
80
  readonly nextPage: ActionAvailability;
75
81
  readonly prevPage: ActionAvailability;
76
82
  goNext(): void;
@@ -90,6 +96,7 @@ export function useFavorites(options: UseFavoritesOptions = {}): FavoritesBag {
90
96
  const [page, setPage] = useState<ListingPageParams>(
91
97
  options.limit !== undefined ? { limit: options.limit } : {}
92
98
  );
99
+ const [pageNumber, setPageNumber] = useState(1);
93
100
  const query = useMyFavorites(page, { enabled: gate.available });
94
101
  const envelope = query.data;
95
102
 
@@ -101,6 +108,7 @@ export function useFavorites(options: UseFavoritesOptions = {}): FavoritesBag {
101
108
  ? loadReady(envelope.items)
102
109
  : loadLoading(),
103
110
  page,
111
+ pageNumber,
104
112
  nextPage:
105
113
  envelope?.has_next === true && envelope.next_anchor != null
106
114
  ? actionAvailable()
@@ -113,11 +121,13 @@ export function useFavorites(options: UseFavoritesOptions = {}): FavoritesBag {
113
121
  const anchor = envelope?.next_anchor;
114
122
  if (anchor == null) return;
115
123
  setPage((current) => ({ ...current, anchor, direction: "next" }));
124
+ setPageNumber((current) => current + 1);
116
125
  },
117
126
  goPrev: () => {
118
127
  const anchor = envelope?.prev_anchor;
119
128
  if (anchor == null) return;
120
129
  setPage((current) => ({ ...current, anchor, direction: "prev" }));
130
+ setPageNumber((current) => Math.max(1, current - 1));
121
131
  },
122
132
  gate,
123
133
  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,
@@ -11,28 +11,36 @@ import {
11
11
  } from "@stapel/core";
12
12
  import type { ActionAvailability, LoadState } from "@stapel/core";
13
13
  import type {
14
- ListingCard,
15
- ListingPageParams,
14
+ MyListingCard,
15
+ MyListingsParams,
16
16
  MyCounters,
17
17
  } from "../api/types.js";
18
- import { MY_LISTINGS_SOURCE_MISSING } from "../model/mineSource.js";
18
+ import { defaultMyListingsSource } from "../model/mineSource.js";
19
19
  import type { MyListingsSource } from "../model/mineSource.js";
20
+ import { useListingsApi } from "../model/context.js";
20
21
  import { useMyCounters } from "../model/queries.js";
21
22
  import { listingsQueryKeys, pageKey } from "../model/queryKeys.js";
22
- import { MY_LISTINGS_TABS } from "../model/status.js";
23
+ import { MY_LISTINGS_TABS, MY_LISTINGS_UNTABBED_STATUSES } from "../model/status.js";
23
24
  import type { MyListingsTab } from "../model/status.js";
24
25
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
25
26
  import { useMandateGate } from "./useMandateGate.js";
26
27
 
27
28
  /**
28
- * The owner's dashboard — and the one screen in this pair whose rows the
29
- * backend cannot supply.
29
+ * The owner's dashboard.
30
30
  *
31
- * The counters are REAL and are shown. The rows come from an injected
32
- * `MyListingsSource`, and when there is none `rows` lands in the `failed` arm
33
- * carrying a NAMED reason rather than as an empty list. `model/mineSource.ts`
34
- * holds the whole argument and the upstream asks; this file is the bag over
35
- * it.
31
+ * Three counts and the rows behind them, both owner-scoped reads of
32
+ * stapel-listings: `my/counters` and since 0.7.0 `my/listings`. Until
33
+ * that release the rows had no route at all and this hook failed them with a
34
+ * named reason rather than rendering an empty grid; `model/mineSource.ts`
35
+ * keeps the argument and the seam that came out of it.
36
+ *
37
+ * TWO reads, not one, and the second one is the point of this file's shape:
38
+ * the three tabs are the SERVER's status groupings, and `blocked` — a
39
+ * moderation takedown — is in none of them, because `my/counters` counts it
40
+ * in none of them. A dashboard that only ever asked for a tab's statuses
41
+ * would hide exactly the listing whose owner most needs to know. So
42
+ * `blockedRows` is fetched beside them, off the same route, narrowed to
43
+ * whatever `MY_LISTINGS_UNTABBED_STATUSES` derives.
36
44
  */
37
45
 
38
46
  export interface MyListingsBag {
@@ -41,10 +49,16 @@ export interface MyListingsBag {
41
49
  setTab(tab: MyListingsTab): void;
42
50
  /** The three real counts. */
43
51
  readonly counters: LoadState<MyCounters>;
44
- /** The rows for the current tab. `failed` with a named reason when no
45
- * source is wired — never an empty list. */
46
- readonly rows: LoadState<readonly ListingCard[]>;
47
- readonly page: ListingPageParams;
52
+ /** The rows for the current tab. */
53
+ readonly rows: LoadState<readonly MyListingCard[]>;
54
+ /**
55
+ * The rows no tab folds in — a moderation takedown, today. Empty for
56
+ * almost every seller; when it is not, it is the most important thing on
57
+ * the screen. Never `failed` in a way that hides the tabs: this read is
58
+ * independent of `rows` and a skin renders it beside them.
59
+ */
60
+ readonly blockedRows: LoadState<readonly MyListingCard[]>;
61
+ readonly page: MyListingsParams;
48
62
  readonly nextPage: ActionAvailability;
49
63
  readonly prevPage: ActionAvailability;
50
64
  goNext(): void;
@@ -55,6 +69,8 @@ export interface MyListingsBag {
55
69
  }
56
70
 
57
71
  export interface UseMyListingsOptions {
72
+ /** Replace the contract's own `my/listings` read — a deployment that keeps
73
+ * its sellers' rows somewhere else. Absent: {@link defaultMyListingsSource}. */
58
74
  readonly source?: MyListingsSource;
59
75
  readonly initialTab?: MyListingsTab;
60
76
  readonly limit?: number;
@@ -68,27 +84,49 @@ export function useMyListings(
68
84
  const [tab, setTabState] = useState<MyListingsTab>(
69
85
  options.initialTab ?? "active"
70
86
  );
71
- const [page, setPage] = useState<ListingPageParams>(
87
+ const [page, setPage] = useState<MyListingsParams>(
72
88
  options.limit !== undefined ? { limit: options.limit } : {}
73
89
  );
74
90
 
75
91
  const counters = useMyCounters();
76
- const { source } = options;
92
+ const api = useListingsApi();
93
+ const injected = options.source;
94
+ const source = useMemo(
95
+ () => injected ?? defaultMyListingsSource(api),
96
+ [injected, api]
97
+ );
98
+ const ready = sessionReady && gate.available;
77
99
 
78
100
  const rowsQuery = useQuery({
79
101
  queryKey: listingsQueryKeys.mine(tab, pageKey(page)),
102
+ queryFn: ({ signal }) => source({ tab, page, signal }),
103
+ enabled: ready,
104
+ retry: false,
105
+ });
106
+
107
+ // The takedowns, off the same route and deliberately NOT paged: a seller
108
+ // with a page of blocked listings has a problem no "next" button improves,
109
+ // and this sits above a dashboard rather than being one.
110
+ const blockedQuery = useQuery({
111
+ queryKey: listingsQueryKeys.mineUntabbed(),
80
112
  queryFn: ({ signal }) =>
81
- (source as MyListingsSource)({ tab, page, signal }),
82
- enabled: source !== undefined && sessionReady && gate.available,
113
+ api.myListings({ status: MY_LISTINGS_UNTABBED_STATUSES }, { signal }),
114
+ enabled: ready && MY_LISTINGS_UNTABBED_STATUSES.length > 0,
83
115
  retry: false,
84
116
  });
85
117
 
86
- const rows: LoadState<readonly ListingCard[]> = useMemo(() => {
87
- if (source === undefined) return loadFailed(MY_LISTINGS_SOURCE_MISSING);
118
+ const rows: LoadState<readonly MyListingCard[]> = useMemo(() => {
88
119
  if (rowsQuery.status === "error") return loadFailed(rowsQuery.error);
89
120
  if (rowsQuery.data !== undefined) return loadReady(rowsQuery.data.items);
90
121
  return loadLoading();
91
- }, [source, rowsQuery.status, rowsQuery.error, rowsQuery.data]);
122
+ }, [rowsQuery.status, rowsQuery.error, rowsQuery.data]);
123
+
124
+ const blockedRows: LoadState<readonly MyListingCard[]> = useMemo(() => {
125
+ if (MY_LISTINGS_UNTABBED_STATUSES.length === 0) return loadReady([]);
126
+ if (blockedQuery.status === "error") return loadFailed(blockedQuery.error);
127
+ if (blockedQuery.data !== undefined) return loadReady(blockedQuery.data.items);
128
+ return loadLoading();
129
+ }, [blockedQuery.status, blockedQuery.error, blockedQuery.data]);
92
130
 
93
131
  const envelope = rowsQuery.data;
94
132
 
@@ -110,6 +148,7 @@ export function useMyListings(
110
148
  ? loadReady(counters.data)
111
149
  : loadLoading(),
112
150
  rows,
151
+ blockedRows,
113
152
  page,
114
153
  nextPage:
115
154
  envelope?.has_next === true && envelope.next_anchor != null
@@ -133,6 +172,7 @@ export function useMyListings(
133
172
  refetch: () => {
134
173
  void counters.refetch();
135
174
  void rowsQuery.refetch();
175
+ void blockedQuery.refetch();
136
176
  },
137
177
  };
138
178
  }
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";
@@ -21,12 +22,14 @@ export { listingsErrorBundleEs } from "./generated/errors.es.gen.js";
21
22
  export const listingsI18nBundleEs: I18nDictionary = {
22
23
  ...listingsErrorBundleEs,
23
24
 
24
- // ── the 9 stapel_listings-owned codes, pair-authored ─────────────────────
25
+ // ── the 10 stapel_listings-owned codes, pair-authored ────────────────────
25
26
  "error.400.category_required": "Hay que elegir una categoría",
26
27
  "error.400.image_required":
27
28
  "Para publicar el anuncio hace falta al menos una foto",
28
29
  "error.400.listing_feature_not_allowed":
29
30
  "El detalle «{feature}» no pertenece a esta categoría",
31
+ "error.400.listing_invalid_status_filter":
32
+ "Estado de anuncio desconocido: «{status}»",
30
33
  "error.400.publish_validation_failed":
31
34
  "El anuncio no pasó la revisión y no se publicó",
32
35
  "error.403.listing_not_owner": "Este anuncio no es tuyo",
@@ -92,6 +95,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
92
95
  "listings.detail.published_at": "Publicado el {date}",
93
96
  "listings.detail.expires_at": "A la venta hasta el {date}",
94
97
  "listings.detail.stock": "Disponibles",
98
+ "listings.detail.edit": "Editar el anuncio",
99
+ "listings.detail.take_down": "Retirarlo",
95
100
 
96
101
  "listings.compose.new_title": "Anuncio nuevo",
97
102
  "listings.compose.edit_title": "Editar el anuncio",
@@ -110,8 +115,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
110
115
  "Escribe el precio como un número, con dos decimales como mucho",
111
116
  "listings.compose.currency_label": "Moneda",
112
117
  "listings.compose.location_label": "Dónde está",
113
- "listings.compose.lat_label": "Latitud",
114
- "listings.compose.lon_label": "Longitud",
118
+ "listings.compose.location_help":
119
+ "La gente busca por distancia — un anuncio sin lugar es un anuncio que no encontrarán",
115
120
  "listings.compose.geo_incomplete":
116
121
  "Una latitud necesita su longitud al lado — media coordenada no apunta a ningún sitio",
117
122
  "listings.compose.photos": "Fotos",
@@ -141,12 +146,13 @@ export const listingsI18nBundleEs: I18nDictionary = {
141
146
  "listings.compose.blocked.no_category":
142
147
  "Elige una categoría — el resto del formulario depende de ella",
143
148
  "listings.compose.blocked.unsupported_type":
144
- "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",
149
+ "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í",
145
150
  "listings.compose.blocked.photos_pending":
146
151
  "Espera a que terminen de subirse las fotos",
147
152
  "listings.compose.blocked.no_draft": "El borrador todavía no está creado",
148
153
  "listings.compose.blocked.busy":
149
154
  "Un momento — el último cambio se está guardando",
155
+ "listings.compose.blocked.incomplete": "Faltan {count} datos obligatorios",
150
156
  "listings.compose.blocked.mirror": "Corrige primero los campos marcados",
151
157
  "listings.compose.blocked.details_unavailable":
152
158
  "No pudimos cargar lo que pide esta categoría, así que no podemos revisar el formulario",
@@ -160,18 +166,32 @@ export const listingsI18nBundleEs: I18nDictionary = {
160
166
  "listings.mine.empty": "Aquí todavía no hay nada",
161
167
  "listings.mine.retry": "Reintentar",
162
168
  "listings.mine.counters_failed": "No pudimos contar tus anuncios",
163
- "listings.mine.source_missing":
164
- "Esta aplicación todavía no puede enumerar tus anuncios: el servicio de anuncios no tiene una lista limitada al propietario. Los contadores de abajo sí son reales.",
169
+ "listings.mine.empty.active": "No tienes nada publicado ni en revisión",
170
+ "listings.mine.empty.drafts": "Sin borradores: lo que empieces aparecerá aquí",
171
+ "listings.mine.empty.archived": "Nada archivado, pausado, caducado ni vendido todavía",
172
+ "listings.mine.blocked.title":
173
+ "Moderación retiró {count} de tus anuncios",
174
+ "listings.mine.blocked.title.one": "Moderación retiró uno de tus anuncios",
175
+ "listings.mine.blocked.title.other": "Moderación retiró {count} de tus anuncios",
176
+ "listings.mine.blocked.load_failed":
177
+ "No pudimos comprobar si alguno de tus anuncios fue retirado",
165
178
  "listings.mine.live_under_review": "Publicado, cambios en revisión",
166
179
  "listings.mine.edit": "Editar",
167
180
  "listings.mine.archive": "Archivar",
168
181
  "listings.mine.complete": "Marcar como vendido",
169
182
  "listings.mine.delete": "Borrar",
183
+ "listings.mine.delete_confirm_title": "¿Borrar este anuncio?",
184
+ "listings.mine.delete_confirm_body":
185
+ "Desaparece de tu panel y no se puede recuperar. Archivarlo lo conserva.",
170
186
 
171
187
  "listings.favorites.title": "Favoritos",
172
188
  "listings.favorites.loading": "Cargando tus favoritos…",
173
189
  "listings.favorites.load_failed": "No pudimos cargar tus favoritos",
174
190
  "listings.favorites.empty": "Todavía no has guardado nada",
191
+ "listings.favorites.empty_hint":
192
+ "Toca el corazón en cualquier anuncio y te estará esperando aquí.",
193
+ "listings.favorites.sign_in_hint":
194
+ "Los favoritos se guardan en tu cuenta, así que te siguen entre dispositivos.",
175
195
 
176
196
  "listings.blocked.sign_in": "Inicia sesión para hacer esto",
177
197
  "listings.blocked.guest":
@@ -183,9 +203,12 @@ export const listingsI18nBundleEs: I18nDictionary = {
183
203
  "listings.blocked.delete_active":
184
204
  "Archívalo primero — un anuncio a la venta no se puede borrar",
185
205
  "listings.blocked.in_flight": "Un momento — eso ya está en marcha",
206
+ "listings.blocked.no_editor":
207
+ "Esta aplicación todavía no tiene una pantalla para editar un anuncio",
186
208
 
187
209
  "listings.page.prev": "Anterior",
188
210
  "listings.page.next": "Siguiente",
211
+ "listings.page.indicator": "Página {page}",
189
212
 
190
213
  "listings.nav.detail": "Anuncio",
191
214
  "listings.nav.compose": "Poner un anuncio",
@@ -193,7 +216,13 @@ export const listingsI18nBundleEs: I18nDictionary = {
193
216
  "listings.nav.favorites": "Favoritos",
194
217
  };
195
218
 
196
- /** Register the Spanish bundle into a core i18n engine. */
219
+ /**
220
+ * Register the Spanish bundle — and, exactly as in `./ru.ts`, the twelve
221
+ * `stapel_attributes` sentences this pair does not author. The ownership split
222
+ * is a rule about who WRITES a string, not an instruction to ship two thirds
223
+ * of a locale and document the rest.
224
+ */
197
225
  export function registerListingsI18nEs(i18n: I18nEngine): void {
226
+ registerAttributesI18nEs(i18n);
198
227
  i18n.registerBundle("es", listingsI18nBundleEs);
199
228
  }
@@ -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: 21 key(s) owned by
11
+ * PARTIAL, and deliberately typed to say so: 22 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`