@stapel/listings-react 0.9.1 → 0.11.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 (72) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/default/FeedGrid.d.ts +42 -0
  3. package/dist/default/FeedGrid.d.ts.map +1 -0
  4. package/dist/default/FeedGrid.js +23 -0
  5. package/dist/default/FeedGrid.js.map +1 -0
  6. package/dist/default/ListingCard.d.ts +31 -0
  7. package/dist/default/ListingCard.d.ts.map +1 -1
  8. package/dist/default/ListingCard.js +12 -4
  9. package/dist/default/ListingCard.js.map +1 -1
  10. package/dist/default/ListingComposerPage.d.ts.map +1 -1
  11. package/dist/default/ListingComposerPage.js +10 -4
  12. package/dist/default/ListingComposerPage.js.map +1 -1
  13. package/dist/default/ListingFeedCard.d.ts +77 -0
  14. package/dist/default/ListingFeedCard.d.ts.map +1 -0
  15. package/dist/default/ListingFeedCard.js +75 -0
  16. package/dist/default/ListingFeedCard.js.map +1 -0
  17. package/dist/default/ListingSerpCard.d.ts +114 -0
  18. package/dist/default/ListingSerpCard.d.ts.map +1 -0
  19. package/dist/default/ListingSerpCard.js +72 -0
  20. package/dist/default/ListingSerpCard.js.map +1 -0
  21. package/dist/default/favorite.d.ts +49 -0
  22. package/dist/default/favorite.d.ts.map +1 -0
  23. package/dist/default/favorite.js +25 -0
  24. package/dist/default/favorite.js.map +1 -0
  25. package/dist/default/icons.d.ts +17 -3
  26. package/dist/default/icons.d.ts.map +1 -1
  27. package/dist/default/icons.js +13 -1
  28. package/dist/default/icons.js.map +1 -1
  29. package/dist/default/index.d.ts +17 -1
  30. package/dist/default/index.d.ts.map +1 -1
  31. package/dist/default/index.js +14 -1
  32. package/dist/default/index.js.map +1 -1
  33. package/dist/headless/ListingComposer.d.ts.map +1 -1
  34. package/dist/headless/ListingComposer.js +14 -6
  35. package/dist/headless/ListingComposer.js.map +1 -1
  36. package/dist/i18n/es.d.ts.map +1 -1
  37. package/dist/i18n/es.js +4 -0
  38. package/dist/i18n/es.js.map +1 -1
  39. package/dist/i18n/keys.d.ts +11 -0
  40. package/dist/i18n/keys.d.ts.map +1 -1
  41. package/dist/i18n/keys.js +15 -0
  42. package/dist/i18n/keys.js.map +1 -1
  43. package/dist/i18n/ru.d.ts.map +1 -1
  44. package/dist/i18n/ru.js +4 -0
  45. package/dist/i18n/ru.js.map +1 -1
  46. package/dist/index.d.ts +1 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +1 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/model/validation.d.ts +20 -0
  51. package/dist/model/validation.d.ts.map +1 -1
  52. package/dist/model/validation.js +63 -1
  53. package/dist/model/validation.js.map +1 -1
  54. package/llms.txt +3 -1
  55. package/manifest.json +47 -1
  56. package/nav-manifest.json +1 -1
  57. package/package.json +10 -10
  58. package/src/analytics/generated/events.json +1 -1
  59. package/src/default/FeedGrid.tsx +70 -0
  60. package/src/default/ListingCard.tsx +21 -5
  61. package/src/default/ListingComposerPage.tsx +18 -3
  62. package/src/default/ListingFeedCard.tsx +232 -0
  63. package/src/default/ListingSerpCard.tsx +374 -0
  64. package/src/default/favorite.tsx +91 -0
  65. package/src/default/icons.tsx +37 -3
  66. package/src/default/index.ts +24 -1
  67. package/src/headless/ListingComposer.tsx +19 -6
  68. package/src/i18n/es.ts +4 -0
  69. package/src/i18n/keys.ts +15 -0
  70. package/src/i18n/ru.ts +4 -0
  71. package/src/index.ts +2 -0
  72. package/src/model/validation.ts +64 -1
@@ -221,15 +221,29 @@ export type ListingCardProps = ListingCardBaseProps & ListingCardOpenProps;
221
221
  * same content in a `<button>` reset to look like nothing, rather than drawing
222
222
  * a separate captioned control: a card that is a target on one deployment and
223
223
  * a card-plus-a-button on another would be two different products.
224
+ *
225
+ * Exported for this pair's OTHER card surfaces (`<ListingSerpCard>`,
226
+ * `<ListingFeedCard>`) and for no one else — it is deliberately absent from
227
+ * `src/default/index.ts`. Three cards each re-deriving "which of the three
228
+ * arms is this" is three places for the double-navigation defect to come back;
229
+ * one function is one place. The card surfaces differ in what they PUT inside
230
+ * the target, never in how the target is made.
224
231
  */
225
- function CardTarget(
232
+ export function CardTarget(
226
233
  props: ListingCardOpenProps & {
227
234
  readonly listingId: number;
228
235
  readonly label: string;
236
+ /** The target's own test id. Each card surface names its own, so a screen
237
+ * holding two kinds of card does not hand a test two elements under one
238
+ * name. Default: the original card's. */
239
+ readonly testId?: string;
240
+ /** The body's test id on the arm where nothing opens. */
241
+ readonly bodyTestId?: string;
229
242
  readonly children: ReactNode;
230
243
  }
231
244
  ): ReactElement {
232
245
  const { label, children } = props;
246
+ const testId = props.testId ?? "listings-card-open";
233
247
 
234
248
  if (props.href !== undefined) {
235
249
  const Link = props.linkComponent;
@@ -241,7 +255,7 @@ function CardTarget(
241
255
  href={props.href}
242
256
  aria-label={label}
243
257
  className={CARD_TARGET_CLASS}
244
- data-testid="listings-card-open"
258
+ data-testid={testId}
245
259
  data-analytics="none"
246
260
  data-analytics-reason="business action — host app wraps with its own tracked()"
247
261
  >
@@ -253,7 +267,7 @@ function CardTarget(
253
267
  aria-label={label}
254
268
  className={CARD_TARGET_CLASS}
255
269
  style={TARGET_STYLE}
256
- data-testid="listings-card-open"
270
+ data-testid={testId}
257
271
  data-analytics="none"
258
272
  data-analytics-reason="business action — host app wraps with its own tracked()"
259
273
  >
@@ -270,7 +284,7 @@ function CardTarget(
270
284
  aria-label={label}
271
285
  className={CARD_TARGET_CLASS}
272
286
  style={BUTTON_TARGET_STYLE}
273
- data-testid="listings-card-open"
287
+ data-testid={testId}
274
288
  data-analytics="none"
275
289
  data-analytics-reason="business action — host app wraps with its own tracked()"
276
290
  onClick={() => {
@@ -283,7 +297,9 @@ function CardTarget(
283
297
  }
284
298
 
285
299
  // No open control at all — a card inside a screen that IS the listing.
286
- return <div data-testid="listings-card-body">{children}</div>;
300
+ return (
301
+ <div data-testid={props.bodyTestId ?? "listings-card-body"}>{children}</div>
302
+ );
287
303
  }
288
304
 
289
305
  export function ListingCard(props: ListingCardProps): ReactElement {
@@ -98,8 +98,10 @@ import {
98
98
  CATEGORY_FIELD,
99
99
  DESCRIPTION_FIELD,
100
100
  IMAGES_FIELD,
101
+ LOCATION_FIELD,
101
102
  PRICE_FIELD,
102
103
  TITLE_FIELD,
104
+ envelopeFieldErrors,
103
105
  failedResults,
104
106
  } from "../model/validation.js";
105
107
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
@@ -332,6 +334,11 @@ function SlotField(props: {
332
334
  );
333
335
  }
334
336
 
337
+ /** Did this refusal land on a control? Then the control is where it is read. */
338
+ function routedToAControl(thrown: unknown): boolean {
339
+ return Object.keys(envelopeFieldErrors(thrown)).length > 0;
340
+ }
341
+
335
342
  /** A decimal string → the number a picker works in, or `null`. */
336
343
  function toNumber(value: string | null): number | null {
337
344
  if (value === null || value.length === 0) return null;
@@ -553,7 +560,7 @@ export function ListingComposerPage(
553
560
  extra={t(LISTINGS_I18N_KEYS.composeLocationHelp)}
554
561
  slot="locationPicker"
555
562
  named={namedSlots}
556
- status={errorOf("location")}
563
+ status={errorOf(LOCATION_FIELD)}
557
564
  testId="listings-composer-location"
558
565
  control={
559
566
  props.renderLocationPicker !== undefined ? (
@@ -690,7 +697,13 @@ export function ListingComposerPage(
690
697
  />
691
698
  ) : null}
692
699
 
693
- {bag.refusal?.kind === "error" ? (
700
+ {/* A refusal that reached a control is READ there, under the field
701
+ the person can change. Repeating it as a banner — twice, since a
702
+ publish saves the draft first and both failures are the same 400 —
703
+ is how one refused coordinate painted two identical "Validation
704
+ error" plaques and named nothing (blocker C2). The banner is for
705
+ what has nowhere else to go. */}
706
+ {bag.refusal?.kind === "error" && !routedToAControl(bag.refusal.error) ? (
694
707
  <ErrorAlert
695
708
  testId="listings-composer-error"
696
709
  error={describe({
@@ -703,7 +716,9 @@ export function ListingComposerPage(
703
716
  />
704
717
  ) : null}
705
718
 
706
- {bag.saveError !== undefined && bag.saveError !== null ? (
719
+ {bag.saveError !== undefined &&
720
+ bag.saveError !== null &&
721
+ !routedToAControl(bag.saveError) ? (
707
722
  <ErrorAlert
708
723
  testId="listings-composer-save-error"
709
724
  thrown={bag.saveError}
@@ -0,0 +1,232 @@
1
+ /**
2
+ * `<ListingFeedCard>` — the borderless card of a phone home feed (refs §1).
3
+ *
4
+ * ── What "borderless" is actually doing ───────────────────────────────────
5
+ *
6
+ * `<ListingCard>` and `<ListingSerpCard>` are antd `Card`s: a painted surface
7
+ * with a border, which is what makes one result read as one object next to
8
+ * another. A two-column feed is a different picture. Twenty bordered boxes on
9
+ * a 390px screen is twenty frames and forty vertical lines, and the photos —
10
+ * the only thing anyone is actually looking at — end up as small pictures
11
+ * inside chrome. So this card has no surface of its own: the photo is the
12
+ * card, the three lines under it sit on the page's own ground, and the RHYTHM
13
+ * of the grid is what separates one from the next. That is the ref, and it is
14
+ * also why this is a third component rather than a `bordered={false}` on the
15
+ * first: nothing else about the layout survives the change either.
16
+ *
17
+ * ── The heart is over the photo here, and only here ───────────────────────
18
+ *
19
+ * `<ListingCard>` argues at length that the heart belongs in a row UNDER the
20
+ * card rather than floating on the photograph, because a blocked favourite
21
+ * states its reason as text and there is nowhere to put a sentence on top of a
22
+ * picture. That argument is correct and this card does not repeat its
23
+ * conclusion, for a reason it states rather than hides: a 2-column feed tile
24
+ * has no line to spare. A full row of "Sign in to save this" under every one
25
+ * of twenty tiles is not twenty pieces of help — it is the feed.
26
+ *
27
+ * The fleet already has the mechanism for exactly this and it is not "hide the
28
+ * reason": `GateReasonScopeContext` / `<PaneGate>` pool identical reasons and
29
+ * render each ONCE for everything inside the scope, with every control's
30
+ * `aria-describedby` still pointing at that single copy. **A container drawing
31
+ * a feed should wrap `<FeedGrid>` in a `<PaneGate>`.** Unscoped, the reason
32
+ * still renders — over the photo, visible, never behind a hover — because a
33
+ * reason a person cannot read is the one outcome the doctrine forbids.
34
+ *
35
+ * ── Two lines of title, and then it stops ─────────────────────────────────
36
+ *
37
+ * A feed tile is roughly 170px wide. A three-line title pushes the price below
38
+ * the fold of the row and makes the grid ragged; a one-line ellipsis throws
39
+ * away the half of a listing's name that distinguishes it from the one beside
40
+ * it. Two lines, clamped, is the ref's answer and the only one that keeps the
41
+ * price on the same y as its neighbour's.
42
+ */
43
+ import type { CSSProperties, ReactElement, ReactNode } from "react";
44
+ import { Flex, Typography } from "antd";
45
+ import { SkinTheme } from "@stapel/tokens-antd/skin";
46
+ import { useT } from "@stapel/core";
47
+ import { radii, spacing } from "@stapel/tokens";
48
+ import type { ListingCard as ListingCardData } from "../api/types.js";
49
+ import { lifecycleCaption } from "../model/status.js";
50
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
51
+ import { FavoriteHeart } from "./favorite.js";
52
+ import {
53
+ CARD_TARGET_STYLE_HREF,
54
+ CardTarget,
55
+ cardTargetCss,
56
+ } from "./ListingCard.js";
57
+ import type { ListingCardOpenProps } from "./ListingCard.js";
58
+ import { ListingPhoto } from "./ListingPhoto.js";
59
+ import { ListingPrice } from "./ListingPrice.js";
60
+ import type { ThemeModeProp } from "./types.js";
61
+
62
+ /** Lines of title a tile draws before it clips. See the file header. */
63
+ const TITLE_LINES = 2;
64
+
65
+ /** The class the clamped title carries, for {@link feedCardCss}. */
66
+ export const FEED_TITLE_CLASS = "stapel-listing-feed-title";
67
+
68
+ /** The `href` the hoisted feed stylesheet is deduplicated by. */
69
+ export const FEED_CARD_STYLE_HREF = "stapel-listings-feed-card";
70
+
71
+ /**
72
+ * The clamp, as a real CSS rule rather than an inline style.
73
+ *
74
+ * `-webkit-line-clamp` and `-webkit-box-orient` are the two declarations that
75
+ * do not survive the trip through an inline style object: React's serializer
76
+ * and every DOM implementation that is not a browser drop them silently, so a
77
+ * tile written that way clamps in Chrome, does not clamp in a test, and
78
+ * nothing anywhere says which. A hoisted sheet keeps one copy for the document
79
+ * and makes the rule something a test can read.
80
+ */
81
+ export function feedCardCss(): string {
82
+ return (
83
+ `.${FEED_TITLE_CLASS}{display:-webkit-box;-webkit-box-orient:vertical;` +
84
+ `-webkit-line-clamp:${String(TITLE_LINES)};overflow:hidden}`
85
+ );
86
+ }
87
+
88
+ /** The tile. `position: relative` is what the heart and the badge overlay are
89
+ * pinned to; `minWidth: 0` keeps a long word inside its grid track. */
90
+ const TILE: CSSProperties = { position: "relative", minWidth: 0 };
91
+
92
+ /** Pinned to the photo's leading corner: the container's own marking. */
93
+ const BADGE: CSSProperties = {
94
+ position: "absolute",
95
+ insetBlockStart: spacing[2],
96
+ insetInlineStart: spacing[2],
97
+ };
98
+
99
+ /** Pinned to the photo's trailing corner. `alignItems: flex-end` so the
100
+ * pooled-or-not reason, when there is one, stacks under the heart against the
101
+ * same edge rather than pushing it inwards. */
102
+ const HEART: CSSProperties = {
103
+ position: "absolute",
104
+ insetBlockStart: spacing[2],
105
+ insetInlineEnd: spacing[2],
106
+ alignItems: "flex-end",
107
+ };
108
+
109
+ export interface ListingFeedCardBaseProps extends ThemeModeProp {
110
+ readonly listing: ListingCardData;
111
+ /**
112
+ * Drawn over the photo's leading corner — "New", "In stock", a promotion
113
+ * marking. A SLOT rather than a string: what earns an overlay on a feed is a
114
+ * deployment's decision, and DSA Art. 26 marking belongs to whichever pair
115
+ * received the fact.
116
+ */
117
+ readonly badgeOverlay?: ReactNode;
118
+ /** Hide the favourite entirely — for a surface where it makes no sense.
119
+ * NOT a way to hide it from visitors. */
120
+ readonly showFavorite?: boolean;
121
+ }
122
+
123
+ export type ListingFeedCardProps = ListingFeedCardBaseProps & ListingCardOpenProps;
124
+
125
+ export function ListingFeedCard(props: ListingFeedCardProps): ReactElement {
126
+ const t = useT();
127
+ const { listing } = props;
128
+ const status =
129
+ listing.status === undefined ? undefined : lifecycleCaption(listing.status);
130
+ const title = listing.title ?? "";
131
+ const targetLabel =
132
+ title.length > 0 ? title : t(LISTINGS_I18N_KEYS.cardUntitled);
133
+
134
+ return (
135
+ <SkinTheme
136
+ surface="bare"
137
+ {...(props.mode !== undefined ? { mode: props.mode } : {})}
138
+ >
139
+ <style href={CARD_TARGET_STYLE_HREF} precedence="default">
140
+ {cardTargetCss()}
141
+ </style>
142
+ <style href={FEED_CARD_STYLE_HREF} precedence="default">
143
+ {feedCardCss()}
144
+ </style>
145
+ <div
146
+ style={TILE}
147
+ data-testid="listings-feed-card"
148
+ data-listing-id={listing.id}
149
+ {...(status !== undefined
150
+ ? { "data-listing-status": status.status }
151
+ : {})}
152
+ >
153
+ {/* One anchor over the whole tile. The photo is a still `<img>`, so
154
+ unlike the SERP card's swipeable strip it is safe inside a link —
155
+ and on a feed the picture IS the click. */}
156
+ <CardTarget
157
+ {...openProps(props)}
158
+ listingId={listing.id}
159
+ label={targetLabel}
160
+ testId="listings-feed-open"
161
+ bodyTestId="listings-feed-body"
162
+ >
163
+ <Flex vertical gap={spacing[1]}>
164
+ <ListingPhoto
165
+ imageRef={listing.images?.[0]}
166
+ alt={title.length > 0 ? title : String(listing.id)}
167
+ style={{ borderRadius: radii.lg }}
168
+ />
169
+
170
+ {/* Title before price: on a feed a person is browsing, not
171
+ comparing — the ref's order, and the reverse of the SERP's. */}
172
+ <Typography.Text
173
+ className={FEED_TITLE_CLASS}
174
+ data-testid="listings-feed-title"
175
+ >
176
+ {title}
177
+ </Typography.Text>
178
+
179
+ <Typography.Text strong data-testid="listings-feed-price">
180
+ <ListingPrice
181
+ amount={listing.price}
182
+ {...(listing.currency !== undefined
183
+ ? { currency: listing.currency }
184
+ : {})}
185
+ />
186
+ </Typography.Text>
187
+
188
+ {listing.location_label !== undefined &&
189
+ listing.location_label.length > 0 ? (
190
+ <Typography.Text
191
+ type="secondary"
192
+ ellipsis
193
+ data-testid="listings-feed-location"
194
+ >
195
+ {listing.location_label}
196
+ </Typography.Text>
197
+ ) : null}
198
+ </Flex>
199
+ </CardTarget>
200
+
201
+ {props.badgeOverlay !== undefined && (
202
+ <div style={BADGE} data-testid="listings-feed-badge">
203
+ {props.badgeOverlay}
204
+ </div>
205
+ )}
206
+
207
+ {/* Outside the anchor — a button inside a link is neither valid HTML
208
+ nor operable — and pinned rather than stacked. See the header. */}
209
+ {props.showFavorite === false ? null : (
210
+ <FavoriteHeart
211
+ listingId={listing.id}
212
+ favorited={listing.is_favorited}
213
+ testId="listings-feed-favorite"
214
+ style={HEART}
215
+ />
216
+ )}
217
+ </div>
218
+ </SkinTheme>
219
+ );
220
+ }
221
+
222
+ /** The three-armed open union, narrowed out of this card's own props — see
223
+ * `<ListingSerpCard>`'s copy for why it is picked rather than spread. */
224
+ function openProps(props: ListingFeedCardProps): ListingCardOpenProps {
225
+ if (props.href !== undefined) {
226
+ return props.linkComponent !== undefined
227
+ ? { href: props.href, linkComponent: props.linkComponent }
228
+ : { href: props.href };
229
+ }
230
+ if (props.onOpen !== undefined) return { onOpen: props.onOpen };
231
+ return {};
232
+ }