@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.
- package/CHANGELOG.md +76 -0
- package/dist/api/generated/schema.d.ts +34 -2
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/FeedGrid.d.ts +42 -0
- package/dist/default/FeedGrid.d.ts.map +1 -0
- package/dist/default/FeedGrid.js +23 -0
- package/dist/default/FeedGrid.js.map +1 -0
- package/dist/default/ListingCard.d.ts +31 -0
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +12 -4
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingFeedCard.d.ts +77 -0
- package/dist/default/ListingFeedCard.d.ts.map +1 -0
- package/dist/default/ListingFeedCard.js +75 -0
- package/dist/default/ListingFeedCard.js.map +1 -0
- package/dist/default/ListingSerpCard.d.ts +114 -0
- package/dist/default/ListingSerpCard.d.ts.map +1 -0
- package/dist/default/ListingSerpCard.js +72 -0
- package/dist/default/ListingSerpCard.js.map +1 -0
- package/dist/default/favorite.d.ts +49 -0
- package/dist/default/favorite.d.ts.map +1 -0
- package/dist/default/favorite.js +25 -0
- package/dist/default/favorite.js.map +1 -0
- package/dist/default/icons.d.ts +17 -3
- package/dist/default/icons.d.ts.map +1 -1
- package/dist/default/icons.js +13 -1
- package/dist/default/icons.js.map +1 -1
- package/dist/default/index.d.ts +17 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +14 -1
- package/dist/default/index.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +4 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +11 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +15 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +4 -0
- package/dist/i18n/ru.js.map +1 -1
- package/llms.txt +3 -1
- package/manifest.json +45 -1
- package/nav-manifest.json +1 -1
- package/package.json +9 -9
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +34 -2
- package/src/default/FeedGrid.tsx +70 -0
- package/src/default/ListingCard.tsx +21 -5
- package/src/default/ListingFeedCard.tsx +232 -0
- package/src/default/ListingSerpCard.tsx +374 -0
- package/src/default/favorite.tsx +91 -0
- package/src/default/icons.tsx +37 -3
- package/src/default/index.ts +24 -1
- package/src/i18n/es.ts +4 -0
- package/src/i18n/keys.ts +15 -0
- 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
|
+
}
|
package/src/default/icons.tsx
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
|
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
|
+
}
|
package/src/default/index.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@stapel/listings-react/default` — the antd skin: the
|
|
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": "Не удалось загрузить объявление",
|