@stapel/listings-react 0.25.9 → 0.26.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 (138) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/README.md +77 -9
  3. package/dist/api/generated/schema.d.ts +2 -2
  4. package/dist/api/generated/schema.d.ts.map +1 -1
  5. package/dist/default/CardBadges.d.ts.map +1 -1
  6. package/dist/default/CardBadges.js +3 -1
  7. package/dist/default/CardBadges.js.map +1 -1
  8. package/dist/default/ListingActions.d.ts +108 -0
  9. package/dist/default/ListingActions.d.ts.map +1 -0
  10. package/dist/default/ListingActions.js +29 -0
  11. package/dist/default/ListingActions.js.map +1 -0
  12. package/dist/default/ListingCard.d.ts.map +1 -1
  13. package/dist/default/ListingCard.js +15 -8
  14. package/dist/default/ListingCard.js.map +1 -1
  15. package/dist/default/ListingDetailPane.d.ts +76 -56
  16. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  17. package/dist/default/ListingDetailPane.js +164 -18
  18. package/dist/default/ListingDetailPane.js.map +1 -1
  19. package/dist/default/ListingFeedCard.d.ts.map +1 -1
  20. package/dist/default/ListingFeedCard.js +2 -1
  21. package/dist/default/ListingFeedCard.js.map +1 -1
  22. package/dist/default/ListingPhoto.d.ts.map +1 -1
  23. package/dist/default/ListingPhoto.js +5 -2
  24. package/dist/default/ListingPhoto.js.map +1 -1
  25. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  26. package/dist/default/ListingSerpCard.js +18 -4
  27. package/dist/default/ListingSerpCard.js.map +1 -1
  28. package/dist/default/MyListingsPane.d.ts.map +1 -1
  29. package/dist/default/MyListingsPane.js +45 -21
  30. package/dist/default/MyListingsPane.js.map +1 -1
  31. package/dist/default/ShareAction.d.ts +32 -0
  32. package/dist/default/ShareAction.d.ts.map +1 -0
  33. package/dist/default/ShareAction.js +116 -0
  34. package/dist/default/ShareAction.js.map +1 -0
  35. package/dist/default/actionRow.d.ts +58 -0
  36. package/dist/default/actionRow.d.ts.map +1 -0
  37. package/dist/default/actionRow.js +128 -0
  38. package/dist/default/actionRow.js.map +1 -0
  39. package/dist/default/cardGallery.d.ts +2 -0
  40. package/dist/default/cardGallery.d.ts.map +1 -1
  41. package/dist/default/cardGallery.js +31 -0
  42. package/dist/default/cardGallery.js.map +1 -1
  43. package/dist/default/favorite.d.ts +25 -0
  44. package/dist/default/favorite.d.ts.map +1 -1
  45. package/dist/default/favorite.js +29 -3
  46. package/dist/default/favorite.js.map +1 -1
  47. package/dist/default/icons.d.ts +19 -0
  48. package/dist/default/icons.d.ts.map +1 -1
  49. package/dist/default/icons.js +23 -0
  50. package/dist/default/icons.js.map +1 -1
  51. package/dist/default/index.d.ts +8 -1
  52. package/dist/default/index.d.ts.map +1 -1
  53. package/dist/default/index.js +6 -1
  54. package/dist/default/index.js.map +1 -1
  55. package/dist/default/notice.d.ts +14 -0
  56. package/dist/default/notice.d.ts.map +1 -0
  57. package/dist/default/notice.js +62 -0
  58. package/dist/default/notice.js.map +1 -0
  59. package/dist/headless/ListingComposer.d.ts +36 -2
  60. package/dist/headless/ListingComposer.d.ts.map +1 -1
  61. package/dist/headless/ListingComposer.js +15 -2
  62. package/dist/headless/ListingComposer.js.map +1 -1
  63. package/dist/headless/MyListings.d.ts +39 -18
  64. package/dist/headless/MyListings.d.ts.map +1 -1
  65. package/dist/headless/MyListings.js +41 -19
  66. package/dist/headless/MyListings.js.map +1 -1
  67. package/dist/headless/Share.d.ts +103 -0
  68. package/dist/headless/Share.d.ts.map +1 -0
  69. package/dist/headless/Share.js +190 -0
  70. package/dist/headless/Share.js.map +1 -0
  71. package/dist/i18n/es.d.ts.map +1 -1
  72. package/dist/i18n/es.js +21 -0
  73. package/dist/i18n/es.js.map +1 -1
  74. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  75. package/dist/i18n/generated/errors.es.gen.js +1 -1
  76. package/dist/i18n/generated/errors.gen.d.ts +18 -0
  77. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  78. package/dist/i18n/generated/errors.gen.js +9 -0
  79. package/dist/i18n/generated/errors.gen.js.map +1 -1
  80. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  81. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  82. package/dist/i18n/keys.d.ts +45 -0
  83. package/dist/i18n/keys.d.ts.map +1 -1
  84. package/dist/i18n/keys.js +60 -0
  85. package/dist/i18n/keys.js.map +1 -1
  86. package/dist/i18n/ru.d.ts.map +1 -1
  87. package/dist/i18n/ru.js +21 -0
  88. package/dist/i18n/ru.js.map +1 -1
  89. package/dist/index.d.ts +5 -3
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +2 -1
  92. package/dist/index.js.map +1 -1
  93. package/dist/model/cardBadges.d.ts +52 -3
  94. package/dist/model/cardBadges.d.ts.map +1 -1
  95. package/dist/model/cardBadges.js +74 -12
  96. package/dist/model/cardBadges.js.map +1 -1
  97. package/dist/model/mineSource.d.ts +9 -3
  98. package/dist/model/mineSource.d.ts.map +1 -1
  99. package/dist/model/mineSource.js.map +1 -1
  100. package/dist/model/status.d.ts +62 -13
  101. package/dist/model/status.d.ts.map +1 -1
  102. package/dist/model/status.js +69 -15
  103. package/dist/model/status.js.map +1 -1
  104. package/llms.txt +8 -3
  105. package/manifest.json +96 -4
  106. package/nav-manifest.json +1 -1
  107. package/package.json +11 -11
  108. package/src/analytics/generated/events.json +1 -1
  109. package/src/api/generated/schema.ts +2 -2
  110. package/src/default/CardBadges.tsx +3 -1
  111. package/src/default/ListingActions.tsx +196 -0
  112. package/src/default/ListingCard.tsx +79 -43
  113. package/src/default/ListingDetailPane.tsx +268 -81
  114. package/src/default/ListingFeedCard.tsx +2 -0
  115. package/src/default/ListingPhoto.tsx +28 -0
  116. package/src/default/ListingSerpCard.tsx +39 -12
  117. package/src/default/MyListingsPane.tsx +77 -44
  118. package/src/default/ShareAction.tsx +260 -0
  119. package/src/default/actionRow.ts +131 -0
  120. package/src/default/cardGallery.ts +31 -0
  121. package/src/default/favorite.tsx +68 -1
  122. package/src/default/icons.tsx +60 -0
  123. package/src/default/index.ts +23 -0
  124. package/src/default/notice.ts +68 -0
  125. package/src/headless/ListingComposer.tsx +68 -4
  126. package/src/headless/MyListings.tsx +89 -38
  127. package/src/headless/Share.tsx +278 -0
  128. package/src/i18n/es.ts +28 -0
  129. package/src/i18n/generated/errors.es.gen.ts +1 -1
  130. package/src/i18n/generated/errors.gen.ts +9 -0
  131. package/src/i18n/generated/errors.json +31 -0
  132. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  133. package/src/i18n/keys.ts +63 -0
  134. package/src/i18n/ru.ts +28 -0
  135. package/src/index.ts +22 -0
  136. package/src/model/cardBadges.ts +124 -12
  137. package/src/model/mineSource.ts +9 -3
  138. package/src/model/status.ts +85 -18
@@ -46,9 +46,12 @@
46
46
  */
47
47
  import { useCallback, useEffect, useRef, useState } from "react";
48
48
  import type { PointerEvent as ReactPointerEvent, RefObject } from "react";
49
+ import { fontSize, radii, spacing } from "@stapel/tokens";
49
50
 
50
51
  /** The class the gallery's own box carries — see {@link cardGalleryCss}. */
51
52
  export const CARD_GALLERY_CLASS = "stapel-listing-gallery";
53
+ /** The class the "3 of 16" pill carries — see {@link cardGalleryCss}. */
54
+ export const CARD_GALLERY_COUNTER_CLASS = "stapel-listing-gallery-count";
52
55
  /** The `href` the hoisted gallery stylesheet is deduplicated by. */
53
56
  export const CARD_GALLERY_STYLE_HREF = "stapel-listings-card-gallery";
54
57
 
@@ -338,7 +341,35 @@ export function useCardGallery(count: number): CardGallery {
338
341
  */
339
342
  export function cardGalleryCss(): string {
340
343
  const box = `.${CARD_GALLERY_CLASS}`;
344
+ const counter = `.${CARD_GALLERY_COUNTER_CLASS}`;
341
345
  return [
346
+ // The counter is placed against this box, so the box has to be the
347
+ // containing block. Nothing else about the gallery changes: a `relative`
348
+ // with no offsets moves no pixel of what is already in it.
349
+ `${box}{position:relative}`,
350
+ // "3 of 16", bottom-TRAILING corner — the dots own the bottom centre
351
+ // (`SkinCarousel`: `inset-inline:0; justify-content:center`) and two
352
+ // indicators fighting for one spot is worse than neither.
353
+ //
354
+ // `pointer-events:none` is load-bearing rather than tidy: this box owns
355
+ // the hover-scrub and the swipe, and an element that swallowed a pointer
356
+ // would make one corner of every card's photograph dead to both gestures.
357
+ //
358
+ // A FIXED scrim and white text, not a theme role, and this is the one
359
+ // place in the package where that is the right answer: the pill sits on
360
+ // an arbitrary PHOTOGRAPH, which is neither light nor dark, so a value
361
+ // that followed the page's theme would be unreadable on half the photos
362
+ // in either one.
363
+ `${counter}{position:absolute;inset-block-end:${String(spacing[2])}px;` +
364
+ `inset-inline-end:${String(spacing[2])}px;z-index:1;pointer-events:none;` +
365
+ `padding:${String(spacing[1] / 2)}px ${String(spacing[2])}px;` +
366
+ `border-radius:${String(radii.full)}px;` +
367
+ `background:rgba(0,0,0,0.55);color:#fff;` +
368
+ `font-size:${String(fontSize.xs.fontSize)}px;` +
369
+ `line-height:${String(fontSize.xs.lineHeight)}px;` +
370
+ // A counter whose digits change width makes the pill twitch on every
371
+ // swipe.
372
+ `font-variant-numeric:tabular-nums}`,
342
373
  // THE VERTICAL AXIS IS THE BROWSER'S, and is not negotiable: a card is a
343
374
  // small box in a long feed, and the one unacceptable outcome of a gallery
344
375
  // gesture is a page that will not scroll. `pan-y` says so at the platform
@@ -35,6 +35,8 @@ import { useFavoriteToggle } from "../headless/Favorites.js";
35
35
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
36
36
  import { GateReasonPopover } from "./GateReasonPopover.js";
37
37
  import { HeartIcon } from "./icons.js";
38
+ import { useNotice } from "./notice.js";
39
+ import { LISTING_ACTIONS_STYLE_HREF, actionRowCss } from "./actionRow.js";
38
40
 
39
41
  export interface FavoriteHeartProps {
40
42
  readonly listingId: number;
@@ -83,6 +85,31 @@ export interface FavoriteHeartProps {
83
85
  * its reason, which is all a surface with no sign-in route can offer.
84
86
  */
85
87
  readonly signIn?: SignInCta;
88
+ /**
89
+ * The hit-target class this surface wants — `LISTING_CARD_ACTION_CLASS` on
90
+ * a card (36px with a cursor, 44px with a thumb),
91
+ * `LISTING_ACTION_CLASS` on the listing page (44px everywhere).
92
+ *
93
+ * A CLASS rather than a size prop: the tiers are a media query, and a
94
+ * component that took a number would have to be told the viewport — which
95
+ * is the thing a leaf must never ask. See `actionRow.ts`.
96
+ */
97
+ readonly className?: string;
98
+ /**
99
+ * SAY THE OUTCOME OUT LOUD — a short toast on each successful press.
100
+ *
101
+ * Off by default and ON for the listing page: one heart on a screen the
102
+ * person came to act on earns a sentence, forty hearts on a grid do not.
103
+ * The icon fill remains the state in both cases; this is the acknowledgement
104
+ * that the press was received, which is the half a small glyph pinned to
105
+ * the corner of a photograph cannot carry on its own.
106
+ *
107
+ * It is raised on the GESTURE, from the predicted next state, for the same
108
+ * reason `useFavoriteToggle` draws that state optimistically: the toast has
109
+ * to arrive with the press. A write that then fails rolls the icon back and
110
+ * states the failure through the heart's own `ErrorAlert`.
111
+ */
112
+ readonly announce?: boolean;
86
113
  readonly style?: CSSProperties;
87
114
  }
88
115
 
@@ -134,6 +161,7 @@ export interface FavoriteHeartProps {
134
161
  */
135
162
  export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
136
163
  const t = useT();
164
+ const notice = useNotice();
137
165
  const { token } = antdTheme.useToken();
138
166
  const favorite = useFavoriteToggle(props.listingId, props.favorited);
139
167
  const label = t(
@@ -154,6 +182,22 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
154
182
  // place a blocked reason becomes words in this fleet.
155
183
  const gate = useActionGate(favorite.gate);
156
184
  const reason = gate.reason;
185
+ // The press, plus the sentence the surface asked for. The next state is the
186
+ // one the icon is about to draw, so the toast and the fill say the same
187
+ // thing at the same moment — see `announce`.
188
+ const announce = props.announce === true;
189
+ const press = (): void => {
190
+ const next = !favorite.favorited;
191
+ favorite.toggle();
192
+ if (!announce || !favorite.gate.available) return;
193
+ notice(
194
+ t(
195
+ next
196
+ ? LISTINGS_I18N_KEYS.favoriteAdded
197
+ : LISTINGS_I18N_KEYS.favoriteRemoved
198
+ )
199
+ );
200
+ };
157
201
  // D431. A blocked gate plus a door the surface handed in: the press is not
158
202
  // refused, it is ROUTED — through the door, keeping whatever `?next=` the
159
203
  // container put in the href. With no door there is nothing better for the
@@ -175,9 +219,10 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
175
219
  ...doorPress,
176
220
  ...(door?.href !== undefined ? {} : { "aria-pressed": favorite.favorited }),
177
221
  }
178
- : { "aria-pressed": favorite.favorited, onClick: favorite.toggle };
222
+ : { "aria-pressed": favorite.favorited, onClick: press };
179
223
  // A failed save, stated where the heart is. The rollback already put the
180
224
  // icon back; this says why it went back.
225
+ // (see `press` above for the toast that rides the successful half)
181
226
  const failure = (
182
227
  <ErrorAlert
183
228
  testId={`${props.testId}-error`}
@@ -185,8 +230,20 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
185
230
  variant="inline"
186
231
  />
187
232
  );
233
+ // The hit-target rules the surfaces address by class. Hoisted here rather
234
+ // than by each card, because the heart is what carries the class and a
235
+ // stylesheet that arrived only on the surfaces that remembered it is a
236
+ // 32px target on the one that forgot.
237
+ const sheet = (
238
+ <style href={LISTING_ACTIONS_STYLE_HREF} precedence="default">
239
+ {actionRowCss()}
240
+ </style>
241
+ );
242
+
188
243
  if (props.blockedReason === "popover" && reason !== undefined) {
189
244
  return (
245
+ <>
246
+ {sheet}
190
247
  <GateReasonPopover
191
248
  reason={reason}
192
249
  cta={props.signIn}
@@ -201,6 +258,9 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
201
258
  // control, and the disclosure stays as the hint beside it.
202
259
  {...(opensDoor ? {} : { "aria-disabled": true })}
203
260
  {...bind}
261
+ {...(props.className !== undefined
262
+ ? { className: props.className }
263
+ : {})}
204
264
  aria-label={label}
205
265
  data-testid={props.testId}
206
266
  data-favorited={String(favorite.favorited)}
@@ -212,9 +272,12 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
212
272
  />
213
273
  )}
214
274
  </GateReasonPopover>
275
+ </>
215
276
  );
216
277
  }
217
278
  return (
279
+ <>
280
+ {sheet}
218
281
  <GatedControl
219
282
  gate={favorite.gate}
220
283
  testId={props.gateTestId ?? `${props.testId}-gate`}
@@ -233,6 +296,9 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
233
296
  shape="circle"
234
297
  // See `<ListingCard>`: the substrate's binding, spread whole.
235
298
  {...bind}
299
+ {...(props.className !== undefined
300
+ ? { className: props.className }
301
+ : {})}
236
302
  aria-label={label}
237
303
  data-testid={props.testId}
238
304
  data-favorited={String(favorite.favorited)}
@@ -245,5 +311,6 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
245
311
  </>
246
312
  )}
247
313
  </GatedControl>
314
+ </>
248
315
  );
249
316
  }
@@ -50,6 +50,66 @@ export function HeartIcon(props: HeartIconProps): ReactElement {
50
50
  );
51
51
  }
52
52
 
53
+ /**
54
+ * The share glyph — three nodes and two edges, the platform-neutral shape
55
+ * both Android and the web draw for "send this somewhere else".
56
+ *
57
+ * `aria-hidden`, like the heart: the control it sits in carries the verb as
58
+ * its accessible name in every arm, including the phone arm where the word
59
+ * itself is not painted, and a glyph announced beside its own label reads the
60
+ * action twice.
61
+ *
62
+ * Deliberately not the iOS "box with an arrow": that shape means "share" to
63
+ * an iPhone user and "upload" or "export" to everybody else, and this control
64
+ * is drawn on a browser, not inside an app the platform styled.
65
+ */
66
+ export function ShareIcon(): ReactElement {
67
+ return (
68
+ <svg
69
+ width="16"
70
+ height="16"
71
+ viewBox="0 0 24 24"
72
+ fill="none"
73
+ stroke="currentColor"
74
+ strokeWidth="2"
75
+ strokeLinecap="round"
76
+ strokeLinejoin="round"
77
+ role="img"
78
+ aria-hidden="true"
79
+ >
80
+ <circle cx="18" cy="5" r="3" />
81
+ <circle cx="6" cy="12" r="3" />
82
+ <circle cx="18" cy="19" r="3" />
83
+ <path d="M8.6 10.5 15.4 6.6" />
84
+ <path d="M8.6 13.5 15.4 17.4" />
85
+ </svg>
86
+ );
87
+ }
88
+
89
+ /**
90
+ * A chain link — the copy row's glyph, and the one thing on the menu that is
91
+ * not a network's own brand.
92
+ */
93
+ export function LinkIcon(): ReactElement {
94
+ return (
95
+ <svg
96
+ width="16"
97
+ height="16"
98
+ viewBox="0 0 24 24"
99
+ fill="none"
100
+ stroke="currentColor"
101
+ strokeWidth="2"
102
+ strokeLinecap="round"
103
+ strokeLinejoin="round"
104
+ role="img"
105
+ aria-hidden="true"
106
+ >
107
+ <path d="M10 13a5 5 0 0 0 7.5.6l3-3a5 5 0 0 0-7-7l-1.7 1.7" />
108
+ <path d="M14 11a5 5 0 0 0-7.5-.6l-3 3a5 5 0 0 0 7 7l1.7-1.7" />
109
+ </svg>
110
+ );
111
+ }
112
+
53
113
  /**
54
114
  * Which way the asking price moved — an arrow, and a NAME for it.
55
115
  *
@@ -71,6 +71,7 @@ export {
71
71
  DETAIL_SPLIT_MEASURE,
72
72
  DETAIL_SPLIT_ASIDE,
73
73
  DETAIL_PHOTO_MIN,
74
+ DETAIL_GALLERY_GUTTER,
74
75
  } from "./ListingDetailPane.js";
75
76
  export type { ListingDetailPaneProps } from "./ListingDetailPane.js";
76
77
  export {
@@ -126,6 +127,28 @@ export {
126
127
  useCardGallery,
127
128
  } from "./cardGallery.js";
128
129
  export type { CardGallery } from "./cardGallery.js";
130
+ // ── the reader's two actions: save it, or send it to somebody ───────────────
131
+ export { ListingActions } from "./ListingActions.js";
132
+ export type {
133
+ ListingActionsConfig,
134
+ ListingActionsProps,
135
+ } from "./ListingActions.js";
136
+ export { ShareAction } from "./ShareAction.js";
137
+ export type { ShareActionProps } from "./ShareAction.js";
138
+ export {
139
+ LISTING_ACTIONS_CLASS,
140
+ LISTING_ACTIONS_OVERLAY_CLASS,
141
+ LISTING_ACTIONS_STYLE_HREF,
142
+ LISTING_ACTION_CLASS,
143
+ LISTING_ACTION_HIT,
144
+ LISTING_ACTION_LABEL_CLASS,
145
+ LISTING_CARD_ACTION_CLASS,
146
+ LISTING_CARD_ACTION_HIT,
147
+ actionRowCss,
148
+ } from "./actionRow.js";
149
+ export { NOTICE_SECONDS, useNotice } from "./notice.js";
150
+ export type { Notice } from "./notice.js";
151
+
129
152
  export { SignInLink } from "./SignInLink.js";
130
153
  export type { SignInLinkProps } from "./SignInLink.js";
131
154
  export type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
@@ -0,0 +1,68 @@
1
+ /**
2
+ * A one-line confirmation, spoken once — the message seam this skin uses and
3
+ * the reason it is not a new dependency.
4
+ *
5
+ * ── There is no fleet-wide toast primitive, and this does not invent one ──
6
+ *
7
+ * `@stapel/tokens-antd/skin` carries `ErrorAlert`, `EmptyState`,
8
+ * `GatedControl`, `SkinDialog` and `SkinConfirm` — every way a pair states a
9
+ * REFUSAL or asks a question — and nothing at all for "that worked". So the
10
+ * seam here is antd's own, which this package already renders through:
11
+ *
12
+ * 1. `App.useApp().message` when the host mounts antd's `<App>`, which is
13
+ * the arrangement antd itself asks for — the notice then inherits the
14
+ * host's `ConfigProvider` theme, its locale and its container;
15
+ * 2. antd's STATIC `message` otherwise, so a host that never mounted `<App>`
16
+ * still gets the confirmation instead of silence.
17
+ *
18
+ * Outside an `<App>`, `App.useApp()` returns `{message: {}, …}` — antd's own
19
+ * default context — which is why the arm is chosen by asking whether the
20
+ * function is there rather than by asking whether a provider is. A version
21
+ * that assumed the context would have been a confirmation that worked in
22
+ * every test and on no deployment.
23
+ *
24
+ * ── And a toast is never the ONLY copy of a confirmation ──────────────────
25
+ *
26
+ * A toast is transient by construction: it appears for three seconds
27
+ * somewhere the person may not be looking, and on a phone it can land under a
28
+ * thumb. So every caller here also paints the same sentence where the gesture
29
+ * happened — `<ShareAction>` states "Link copied" inside the open menu, and
30
+ * the heart's state is on the heart. This is the AMPLIFIER, never the record.
31
+ * A future `SkinNotice` in the substrate replaces the body of this function
32
+ * and nothing else; the callers already speak in resolved sentences.
33
+ */
34
+ import { useCallback } from "react";
35
+ import { App, message as staticMessage } from "antd";
36
+
37
+ /** Say one short sentence. Resolved copy — this is the skin, not a bag. */
38
+ export type Notice = (text: string) => void;
39
+
40
+ /**
41
+ * How long a confirmation stands, in seconds.
42
+ *
43
+ * Two, not antd's default three. Every notice this package raises confirms
44
+ * something the person can already SEE — a filled heart, a sentence standing
45
+ * in the open share menu — so it is an acknowledgement, not information, and
46
+ * an acknowledgement that outstays the gesture is a strip of chrome sitting
47
+ * over the page a thumb was about to press next.
48
+ */
49
+ export const NOTICE_SECONDS = 2;
50
+
51
+ export function useNotice(): Notice {
52
+ const app = App.useApp();
53
+ const contextual = app.message.success;
54
+ return useCallback(
55
+ (text: string): void => {
56
+ if (typeof contextual === "function") {
57
+ contextual(text, NOTICE_SECONDS);
58
+ return;
59
+ }
60
+ // No `<App>` above us. antd's static entry renders its own holder into
61
+ // the document, which is exactly right for a host that never opted in,
62
+ // and is a no-op on a server where there is no document to render into.
63
+ if (typeof document === "undefined") return;
64
+ staticMessage.success(text, NOTICE_SECONDS);
65
+ },
66
+ [contextual]
67
+ );
68
+ }
@@ -9,7 +9,11 @@ import {
9
9
  loadReady,
10
10
  } from "@stapel/core";
11
11
  import type { ActionAvailability, FlowError, LoadState } from "@stapel/core";
12
- import type { FeatureDef, FeaturesDto } from "@stapel/attributes-react";
12
+ import type {
13
+ FeatureChangeSource,
14
+ FeatureDef,
15
+ FeaturesDto,
16
+ } from "@stapel/attributes-react";
13
17
  import {
14
18
  initialFeatureValues,
15
19
  toFeaturesDto,
@@ -191,6 +195,20 @@ export interface UseListingComposerOptions {
191
195
  readonly onCategoryChange?: (categoryId: string) => void;
192
196
  /** Seed for a brand-new draft (a category preselected from the URL, say). */
193
197
  readonly initialValues?: Partial<ListingDraftValues>;
198
+ /**
199
+ * Every answer to a category feature, and WHO wrote it.
200
+ *
201
+ * The composer records provenance for itself ({@link
202
+ * ListingComposerBag.featureSources}); this is the same fact pushed to a
203
+ * host that keeps its own ledger — the storefront deciding whether a
204
+ * generated suggestion may overwrite a field, say. It fires on every
205
+ * `setFeature`, including the ones nobody chose (`"cascade"`, `"bake"`).
206
+ */
207
+ onFeatureChange?: (
208
+ slug: string,
209
+ value: unknown,
210
+ source: FeatureChangeSource
211
+ ) => void;
194
212
  onDraftCreated?: (draft: ListingDraft) => void;
195
213
  onPublished?: (response: PublishResponse, outcome: PublishOutcome) => void;
196
214
  }
@@ -214,7 +232,31 @@ export interface ListingComposerBag {
214
232
  value: ListingDraftValues[K]
215
233
  ): void;
216
234
  setLocation(location: ListingLocation): void;
217
- setFeature(slug: string, value: unknown): void;
235
+ /**
236
+ * Answer one category feature — and say WHO answered it.
237
+ *
238
+ * `source` is the third argument `<FeatureFields onChange>` has emitted
239
+ * since `@stapel/attributes-react` 0.16.4, and it was being dropped on the
240
+ * floor here: this bag's setter took two arguments, so the rendering half's
241
+ * own write-backs — a dependent field RESET because its parent moved
242
+ * (`"cascade"`) and a value the narrowed config left as the only possible
243
+ * one (`"bake"`) — arrived indistinguishable from a person typing. A host
244
+ * recording provenance stamped the reset as the seller's answer and locked
245
+ * a field holding nothing.
246
+ *
247
+ * Optional, and `"user"` when omitted: a two-argument caller keeps
248
+ * compiling and keeps meaning what it meant.
249
+ */
250
+ setFeature(slug: string, value: unknown, source?: FeatureChangeSource): void;
251
+ /**
252
+ * Who wrote each feature answer, keyed by slug — `"user"`, `"cascade"` or
253
+ * `"bake"`, and absent for an answer that arrived with the draft or from
254
+ * the catalogue's own default rather than through {@link setFeature}.
255
+ *
256
+ * Pruned with the values themselves when a category change drops a slug, so
257
+ * a slug that comes back later comes back unattributed.
258
+ */
259
+ readonly featureSources: Readonly<Record<string, FeatureChangeSource>>;
218
260
  /** Changing category keeps the answers the new schema also asks for. */
219
261
  setCategory(categoryId: string): void;
220
262
  /** Slugs cleared by the last category change — named, not silently lost. */
@@ -281,6 +323,9 @@ export function useListingComposer(
281
323
  }));
282
324
  const [showErrors, setShowErrors] = useState(false);
283
325
  const [dropped, setDropped] = useState<readonly string[]>([]);
326
+ const [featureSources, setFeatureSources] = useState<
327
+ Readonly<Record<string, FeatureChangeSource>>
328
+ >({});
284
329
  const [refusal, setRefusal] = useState<PublishRefusal | undefined>(undefined);
285
330
  const [outcome, setOutcome] = useState<PublishOutcome | undefined>(undefined);
286
331
  const [saved, setSaved] = useState(false);
@@ -354,7 +399,17 @@ export function useListingComposer(
354
399
  if (kept[slug] === undefined) seeded[slug] = value;
355
400
  }
356
401
  if (gone.length === 0 && Object.keys(seeded).length === 0) return current;
357
- if (gone.length > 0) setDropped(gone);
402
+ if (gone.length > 0) {
403
+ setDropped(gone);
404
+ // The provenance goes with the value it is about. Left behind, a slug
405
+ // the new category happens to ask for again would come back already
406
+ // stamped as somebody's answer.
407
+ setFeatureSources((sources) =>
408
+ Object.fromEntries(
409
+ Object.entries(sources).filter(([slug]) => !gone.includes(slug))
410
+ )
411
+ );
412
+ }
358
413
  return { ...current, features: { ...kept, ...seeded } };
359
414
  });
360
415
  }, [schemaSettled, features]);
@@ -632,12 +687,20 @@ export function useListingComposer(
632
687
  setSaved(false);
633
688
  setValues((current) => ({ ...current, location }));
634
689
  },
635
- setFeature: (slug, value) => {
690
+ setFeature: (slug, value, source) => {
691
+ // An omitted source is a person operating a control — the same default
692
+ // `FeatureChangeSource` states, so a two-argument host is not silently
693
+ // reclassified.
694
+ const from: FeatureChangeSource = source ?? "user";
636
695
  setSaved(false);
637
696
  setValues((current) => ({
638
697
  ...current,
639
698
  features: { ...current.features, [slug]: value },
640
699
  }));
700
+ setFeatureSources((current) =>
701
+ current[slug] === from ? current : { ...current, [slug]: from }
702
+ );
703
+ options.onFeatureChange?.(slug, value, from);
641
704
  },
642
705
  setCategory: (categoryId) => {
643
706
  setSaved(false);
@@ -659,6 +722,7 @@ export function useListingComposer(
659
722
  setRefusal(undefined);
660
723
  },
661
724
  droppedOnCategoryChange: dropped,
725
+ featureSources,
662
726
 
663
727
  mirror,
664
728
  fieldErrors,