@stapel/listings-react 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/dist/api/generated/schema.d.ts +34 -2
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/default/FeedGrid.d.ts +42 -0
  5. package/dist/default/FeedGrid.d.ts.map +1 -0
  6. package/dist/default/FeedGrid.js +23 -0
  7. package/dist/default/FeedGrid.js.map +1 -0
  8. package/dist/default/ListingCard.d.ts +31 -0
  9. package/dist/default/ListingCard.d.ts.map +1 -1
  10. package/dist/default/ListingCard.js +12 -4
  11. package/dist/default/ListingCard.js.map +1 -1
  12. package/dist/default/ListingFeedCard.d.ts +77 -0
  13. package/dist/default/ListingFeedCard.d.ts.map +1 -0
  14. package/dist/default/ListingFeedCard.js +75 -0
  15. package/dist/default/ListingFeedCard.js.map +1 -0
  16. package/dist/default/ListingSerpCard.d.ts +114 -0
  17. package/dist/default/ListingSerpCard.d.ts.map +1 -0
  18. package/dist/default/ListingSerpCard.js +72 -0
  19. package/dist/default/ListingSerpCard.js.map +1 -0
  20. package/dist/default/favorite.d.ts +49 -0
  21. package/dist/default/favorite.d.ts.map +1 -0
  22. package/dist/default/favorite.js +25 -0
  23. package/dist/default/favorite.js.map +1 -0
  24. package/dist/default/icons.d.ts +17 -3
  25. package/dist/default/icons.d.ts.map +1 -1
  26. package/dist/default/icons.js +13 -1
  27. package/dist/default/icons.js.map +1 -1
  28. package/dist/default/index.d.ts +17 -1
  29. package/dist/default/index.d.ts.map +1 -1
  30. package/dist/default/index.js +14 -1
  31. package/dist/default/index.js.map +1 -1
  32. package/dist/i18n/es.d.ts.map +1 -1
  33. package/dist/i18n/es.js +4 -0
  34. package/dist/i18n/es.js.map +1 -1
  35. package/dist/i18n/keys.d.ts +11 -0
  36. package/dist/i18n/keys.d.ts.map +1 -1
  37. package/dist/i18n/keys.js +15 -0
  38. package/dist/i18n/keys.js.map +1 -1
  39. package/dist/i18n/ru.d.ts.map +1 -1
  40. package/dist/i18n/ru.js +4 -0
  41. package/dist/i18n/ru.js.map +1 -1
  42. package/llms.txt +3 -1
  43. package/manifest.json +45 -1
  44. package/nav-manifest.json +1 -1
  45. package/package.json +9 -9
  46. package/src/analytics/generated/events.json +1 -1
  47. package/src/api/generated/schema.ts +34 -2
  48. package/src/default/FeedGrid.tsx +70 -0
  49. package/src/default/ListingCard.tsx +21 -5
  50. package/src/default/ListingFeedCard.tsx +232 -0
  51. package/src/default/ListingSerpCard.tsx +374 -0
  52. package/src/default/favorite.tsx +91 -0
  53. package/src/default/icons.tsx +37 -3
  54. package/src/default/index.ts +24 -1
  55. package/src/i18n/es.ts +4 -0
  56. package/src/i18n/keys.ts +15 -0
  57. package/src/i18n/ru.ts +4 -0
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The favourite heart, once — the control every card surface in this pair
3
+ * draws and none of them may draw differently.
4
+ *
5
+ * `<ListingCard>` had it inline, and the two phone cards added in the mobile
6
+ * wave (`<ListingSerpCard>`, `<ListingFeedCard>`) need exactly the same thing:
7
+ * the same hook, the same gate, the same `aria-pressed`, the same refusal
8
+ * printed as text rather than as a tooltip. Three copies of that is three
9
+ * places for the anonymous arm to drift apart in, and the anonymous arm is
10
+ * most of a storefront's traffic. So it lives here and the surfaces differ
11
+ * only in WHERE they put it.
12
+ *
13
+ * This module is deliberately NOT re-exported from `src/default/index.ts`: it
14
+ * is how this pair's own cards are built, not a control a host composes with.
15
+ * A host that wants a favourite button outside a card has `useFavoriteToggle`.
16
+ *
17
+ * ── The refusal, and why a grid needs a scope ──────────────────────────────
18
+ *
19
+ * `<GatedControl>` prints the gate's reason beside the control and wires
20
+ * `aria-describedby` to it. That is right for ONE card and wrong for forty:
21
+ * the same sentence forty times is the loudest thing on a results page. The
22
+ * substrate already answers this — `GateReasonScopeContext` (a `<PaneGate>`
23
+ * provides one) pools identical reasons and renders each once, and a
24
+ * `GatedControl` inside a scope renders no text of its own while keeping the
25
+ * `aria-describedby` pointed at the scope's single copy. A container drawing a
26
+ * list or a grid of these cards should wrap it in one.
27
+ */
28
+ import type { CSSProperties, ReactElement } from "react";
29
+ import { Button } from "antd";
30
+ import { GatedControl } from "@stapel/tokens-antd/skin";
31
+ import { useT } from "@stapel/core";
32
+ import { useFavoriteToggle } from "../headless/Favorites.js";
33
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
34
+ import { HeartIcon } from "./icons.js";
35
+
36
+ export interface FavoriteHeartProps {
37
+ readonly listingId: number;
38
+ /** `is_favorited` off the row. `null` is the third state — a search hit
39
+ * never says whether you saved it — and reads as "not saved". */
40
+ readonly favorited: boolean | null | undefined;
41
+ /** Test id of the button; the gate wrapper takes `${testId}-gate`. */
42
+ readonly testId: string;
43
+ /** `"inline"` puts the reason beside the heart, `"stack"` (default) under
44
+ * it — the choice belongs to the surface, which knows its own geometry. */
45
+ readonly layout?: "stack" | "inline";
46
+ readonly style?: CSSProperties;
47
+ }
48
+
49
+ /**
50
+ * The heart and its refusal. It is NEVER hidden from a visitor: it is blocked,
51
+ * the reason is on the page as ordinary text, and the container's sign-in door
52
+ * (`signIn`) is the surface's business, not this control's — a disabled antd
53
+ * button fires no pointer events, so a tooltip here would be a reason nobody
54
+ * could read on any device (`stapel/no-tooltip-in-skin`).
55
+ */
56
+ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
57
+ const t = useT();
58
+ const favorite = useFavoriteToggle(props.listingId, props.favorited);
59
+ const label = t(
60
+ favorite.favorited
61
+ ? LISTINGS_I18N_KEYS.cardFavoriteRemove
62
+ : LISTINGS_I18N_KEYS.cardFavoriteAdd
63
+ );
64
+ return (
65
+ <GatedControl
66
+ gate={favorite.gate}
67
+ testId={`${props.testId}-gate`}
68
+ {...(props.layout !== undefined ? { layout: props.layout } : {})}
69
+ {...(props.style !== undefined ? { style: props.style } : {})}
70
+ >
71
+ {(bind) => (
72
+ <Button
73
+ shape="circle"
74
+ disabled={bind.disabled}
75
+ data-disabled-reason="the enclosing <GatedControl> renders the gate's reason beside this button"
76
+ {...(bind["aria-describedby"] !== undefined
77
+ ? { "aria-describedby": bind["aria-describedby"] }
78
+ : {})}
79
+ aria-label={label}
80
+ aria-pressed={favorite.favorited}
81
+ data-testid={props.testId}
82
+ data-favorited={String(favorite.favorited)}
83
+ data-analytics="none"
84
+ data-analytics-reason="business action — host app wraps with its own tracked()"
85
+ onClick={favorite.toggle}
86
+ icon={<HeartIcon filled={favorite.favorited} />}
87
+ />
88
+ )}
89
+ </GatedControl>
90
+ );
91
+ }
@@ -1,13 +1,15 @@
1
1
  /**
2
- * One inline glyph: the favourite heart, filled and outlined.
2
+ * The pair's inline glyphs: the favourite heart, and the price-trend arrow.
3
3
  *
4
4
  * No `@ant-design/icons` dependency — the house convention (profiles-react's
5
5
  * `icons.tsx`, shell-react's icon registry): a plain monochrome
6
6
  * `currentColor` SVG, so it inherits the theme instead of carrying a colour
7
7
  * of its own, and the pair stays one package lighter.
8
8
  *
9
- * `aria-hidden` on both: the button that holds them carries the label, and a
10
- * glyph announced beside its own label reads the action twice.
9
+ * `aria-hidden` on the heart: the button that holds it carries the label, and
10
+ * a glyph announced beside its own label reads the action twice. The TREND
11
+ * arrow is the opposite case — it is the only thing on the line saying which
12
+ * way the price moved, so it takes a name from the caller and is announced.
11
13
  */
12
14
  import type { ReactElement } from "react";
13
15
 
@@ -29,3 +31,35 @@ export function HeartIcon(props: { filled: boolean }): ReactElement {
29
31
  </svg>
30
32
  );
31
33
  }
34
+
35
+ /**
36
+ * Which way the asking price moved — an arrow, and a NAME for it.
37
+ *
38
+ * The arrow is the whole message ("this got cheaper"), so unlike the heart it
39
+ * is not `aria-hidden`: it carries `role="img"` and the sentence the caller
40
+ * resolved from its own key registry. A glyph that means something and is
41
+ * hidden from assistive tech means nothing to the people who need it stated.
42
+ */
43
+ export function PriceTrendIcon(props: {
44
+ direction: "down" | "up";
45
+ label: string;
46
+ }): ReactElement {
47
+ const down = props.direction === "down";
48
+ return (
49
+ <svg
50
+ width="16"
51
+ height="16"
52
+ viewBox="0 0 24 24"
53
+ fill="none"
54
+ stroke="currentColor"
55
+ strokeWidth="2"
56
+ strokeLinecap="round"
57
+ strokeLinejoin="round"
58
+ role="img"
59
+ aria-label={props.label}
60
+ >
61
+ <path d={down ? "M12 5v14" : "M12 19V5"} />
62
+ <path d={down ? "M6 13l6 6 6-6" : "M6 11l6-6 6 6"} />
63
+ </svg>
64
+ );
65
+ }
@@ -1,7 +1,17 @@
1
1
  /**
2
- * `@stapel/listings-react/default` — the antd skin: the card another pair
2
+ * `@stapel/listings-react/default` — the antd skin: the cards another pair
3
3
  * renders, the listing page, the composer and the seller's dashboard.
4
4
  *
5
+ * ── Three cards, because a classified has three shelves ────────────────────
6
+ *
7
+ * `ListingCard` is the grid card (a bordered surface, photo-led, twenty-four
8
+ * to a desktop catalogue). `ListingSerpCard` is the phone result row (one per
9
+ * line, a swipeable photo strip, PRICE first, a vertical action rail).
10
+ * `ListingFeedCard` + `FeedGrid` are the home feed (borderless, two across,
11
+ * the photo carried on the page's own ground). They are three components and
12
+ * not one with a `variant`, because they differ in READING ORDER and in what
13
+ * may live inside the card's anchor — not merely in size.
14
+ *
5
15
  * A separate entry point (the convention every pair's `/default` follows) so
6
16
  * a host rendering its own visuals over the bags never pulls `antd` into its
7
17
  * bundle.
@@ -30,6 +40,19 @@ export type {
30
40
  ListingCardOpenProps,
31
41
  ListingCardBlockedReason,
32
42
  } from "./ListingCard.js";
43
+ export { ListingSerpCard } from "./ListingSerpCard.js";
44
+ export type {
45
+ ListingSerpCardProps,
46
+ ListingSerpCardBaseProps,
47
+ ListingPriceTrend,
48
+ } from "./ListingSerpCard.js";
49
+ export { ListingFeedCard } from "./ListingFeedCard.js";
50
+ export type {
51
+ ListingFeedCardProps,
52
+ ListingFeedCardBaseProps,
53
+ } from "./ListingFeedCard.js";
54
+ export { FeedGrid, FEED_GRID_COLUMNS } from "./FeedGrid.js";
55
+ export type { FeedGridProps } from "./FeedGrid.js";
33
56
  export { ListingDetailPane, DETAIL_MEASURE, DETAIL_PHOTO_MIN } from "./ListingDetailPane.js";
34
57
  export type { ListingDetailPaneProps } from "./ListingDetailPane.js";
35
58
  export { ListingComposerPage, COMPOSER_MEASURE } from "./ListingComposerPage.js";
package/src/i18n/es.ts CHANGED
@@ -76,6 +76,10 @@ export const listingsI18nBundleEs: I18nDictionary = {
76
76
  "listings.card.favorite_remove": "Quitar de favoritos",
77
77
  "listings.card.untitled": "Anuncio sin título",
78
78
  "listings.card.sign_in": "Iniciar sesión",
79
+ "listings.card.photos": "Fotos de este anuncio",
80
+ "listings.card.price_was": "Antes",
81
+ "listings.card.price_dropped": "El precio ha bajado",
82
+ "listings.card.price_raised": "El precio ha subido",
79
83
 
80
84
  "listings.detail.loading": "Cargando el anuncio…",
81
85
  "listings.detail.load_failed": "No pudimos cargar este anuncio",
package/src/i18n/keys.ts CHANGED
@@ -75,6 +75,17 @@ export const LISTINGS_I18N_KEYS = {
75
75
  cardUntitled: "listings.card.untitled",
76
76
  /** The door beside a blocked favourite: the container supplies WHERE. */
77
77
  cardSignIn: "listings.card.sign_in",
78
+ /** The accessible name of `<ListingSerpCard>`'s photo strip. `SkinCarousel`
79
+ * requires one — an unnamed scrollable region is announced as nothing — and
80
+ * the token bridge owns no i18n engine, so the copy is this pair's. */
81
+ cardPhotos: "listings.card.photos",
82
+ /** The label on the struck-through previous price. The strike is styling and
83
+ * a screen reader announces none of it, so the word is on the line. */
84
+ cardPriceWas: "listings.card.price_was",
85
+ /** The name of the trend arrow — the whole message of a glyph that would
86
+ * otherwise be a decoration nobody can read. */
87
+ cardPriceDropped: "listings.card.price_dropped",
88
+ cardPriceRaised: "listings.card.price_raised",
78
89
 
79
90
  // ── detail ───────────────────────────────────────────────────────────────
80
91
  detailLoading: "listings.detail.loading",
@@ -258,6 +269,10 @@ export const listingsI18nBundleEn: Record<string, string> = {
258
269
  "listings.card.favorite_remove": "Remove from favourites",
259
270
  "listings.card.untitled": "Untitled listing",
260
271
  "listings.card.sign_in": "Sign in",
272
+ "listings.card.photos": "Photos of this listing",
273
+ "listings.card.price_was": "Was",
274
+ "listings.card.price_dropped": "The price went down",
275
+ "listings.card.price_raised": "The price went up",
261
276
 
262
277
  "listings.detail.loading": "Loading the listing…",
263
278
  "listings.detail.load_failed": "We could not load this listing",
package/src/i18n/ru.ts CHANGED
@@ -92,6 +92,10 @@ export const listingsI18nBundleRu: I18nDictionary = {
92
92
  "listings.card.favorite_remove": "Убрать из избранного",
93
93
  "listings.card.untitled": "Объявление без названия",
94
94
  "listings.card.sign_in": "Войти",
95
+ "listings.card.photos": "Фотографии объявления",
96
+ "listings.card.price_was": "Было",
97
+ "listings.card.price_dropped": "Цена снизилась",
98
+ "listings.card.price_raised": "Цена выросла",
95
99
 
96
100
  "listings.detail.loading": "Загружаем объявление…",
97
101
  "listings.detail.load_failed": "Не удалось загрузить объявление",