@stapel/listings-react 0.29.1 → 0.30.1
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 +142 -0
- package/dist/default/CardBadges.d.ts +32 -0
- package/dist/default/CardBadges.d.ts.map +1 -1
- package/dist/default/CardBadges.js +41 -3
- package/dist/default/CardBadges.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +115 -8
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +366 -177
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingRelated.d.ts +88 -0
- package/dist/default/ListingRelated.d.ts.map +1 -0
- package/dist/default/ListingRelated.js +41 -0
- package/dist/default/ListingRelated.js.map +1 -0
- package/dist/default/ListingSpecList.d.ts +29 -34
- package/dist/default/ListingSpecList.d.ts.map +1 -1
- package/dist/default/ListingSpecList.js +67 -12
- package/dist/default/ListingSpecList.js.map +1 -1
- package/dist/default/condensedBar.d.ts +17 -0
- package/dist/default/condensedBar.d.ts.map +1 -0
- package/dist/default/condensedBar.js +73 -0
- package/dist/default/condensedBar.js.map +1 -0
- package/dist/default/detailGallery.d.ts +40 -40
- package/dist/default/detailGallery.d.ts.map +1 -1
- package/dist/default/detailGallery.js +129 -0
- package/dist/default/detailGallery.js.map +1 -1
- package/dist/default/detailRhythm.d.ts +90 -0
- package/dist/default/detailRhythm.d.ts.map +1 -0
- package/dist/default/detailRhythm.js +51 -0
- package/dist/default/detailRhythm.js.map +1 -0
- package/dist/default/icons.d.ts +13 -0
- package/dist/default/icons.d.ts.map +1 -1
- package/dist/default/icons.js +15 -0
- package/dist/default/icons.js.map +1 -1
- package/dist/default/index.d.ts +9 -5
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +13 -4
- package/dist/default/index.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +9 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +23 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +32 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +9 -0
- package/dist/i18n/ru.js.map +1 -1
- package/llms.txt +3 -2
- package/manifest.json +28 -1
- package/nav-manifest.json +1 -1
- package/package.json +4 -4
- package/src/analytics/generated/events.json +1 -1
- package/src/default/CardBadges.tsx +65 -4
- package/src/default/ListingDetailPane.tsx +501 -55
- package/src/default/ListingRelated.tsx +154 -0
- package/src/default/ListingSpecList.tsx +64 -1
- package/src/default/condensedBar.ts +74 -0
- package/src/default/detailGallery.ts +141 -0
- package/src/default/detailRhythm.ts +107 -0
- package/src/default/icons.tsx +32 -0
- package/src/default/index.ts +46 -2
- package/src/i18n/es.ts +9 -0
- package/src/i18n/keys.ts +32 -0
- package/src/i18n/ru.ts +9 -0
|
@@ -75,8 +75,43 @@
|
|
|
75
75
|
* `<h1>` through its published test id and a `MutationObserver`, for a
|
|
76
76
|
* boolean the pane already knows. It is an `IntersectionObserver` on the
|
|
77
77
|
* title, never a scroll listener.
|
|
78
|
+
*
|
|
79
|
+
* `actionsPlacement="condensed-top"` is the round after that: the pane draws
|
|
80
|
+
* the bar ITSELF — back, the title on one line, the travelling cluster — for
|
|
81
|
+
* the four things every container was writing identically around the render
|
|
82
|
+
* prop. See `condensedBar.ts`; the render prop is untouched and still wins.
|
|
83
|
+
*
|
|
84
|
+
* ── What else the reference has on this page, and now so does this ────────
|
|
85
|
+
*
|
|
86
|
+
* Three absences the closing-wave comparison measured against the reference
|
|
87
|
+
* classified, each closed as a SEAM rather than as a feature this pair
|
|
88
|
+
* invented data for:
|
|
89
|
+
*
|
|
90
|
+
* the strip did not say where it was
|
|
91
|
+
* `galleryLayout="strip"` is a native scroll container and the page had
|
|
92
|
+
* no position indicator at all (REPORT §20b). `useGalleryPosition` reads
|
|
93
|
+
* the strip's own scroll — never a tap, because the gesture that changes
|
|
94
|
+
* the photograph is not one.
|
|
95
|
+
*
|
|
96
|
+
* nothing on the page started a sentence
|
|
97
|
+
* The reference puts four canned questions above its contact control on
|
|
98
|
+
* every listing (§16). `quickQuestions` draws them and
|
|
99
|
+
* `onQuickQuestion` reports the press, because chat-react's door takes
|
|
100
|
+
* no initial message and a pane that claimed to prefill one would be
|
|
101
|
+
* inventing a seam the other pair does not have.
|
|
102
|
+
*
|
|
103
|
+
* the page ended
|
|
104
|
+
* `similar` / `fromSeller` (rows) and `renderSimilar` /
|
|
105
|
+
* `renderFromSeller` (slots) are the reference's two distinct "find
|
|
106
|
+
* more" mechanisms (§16 comparison 4). The rows come from the HOST's
|
|
107
|
+
* search, because this pair does not read search.
|
|
108
|
+
*
|
|
109
|
+
* And one that was NOT a defect in this component: the characteristics table
|
|
110
|
+
* draws every stored row it can key and counts the ones it cannot. Measured
|
|
111
|
+
* against a live answer, the shortfall the comparison saw was in the DATA —
|
|
112
|
+
* `characteristicsLimit` adds the reference's FOLD, not rows.
|
|
78
113
|
*/
|
|
79
|
-
import { isValidElement, useCallback, useEffect, useRef } from "react";
|
|
114
|
+
import { isValidElement, useCallback, useEffect, useRef, useState } from "react";
|
|
80
115
|
import type { ReactElement, ReactNode } from "react";
|
|
81
116
|
import { Descriptions, Divider, Flex, Typography, theme as antdTheme } from "antd";
|
|
82
117
|
import { SkinButton as Button } from "@stapel/tokens-antd/skin";
|
|
@@ -94,7 +129,7 @@ import {
|
|
|
94
129
|
useI18n,
|
|
95
130
|
useT,
|
|
96
131
|
} from "@stapel/core";
|
|
97
|
-
import type { SignInCta } from "@stapel/core";
|
|
132
|
+
import type { LinkComponent, SignInCta } from "@stapel/core";
|
|
98
133
|
import { cssVar, spacing } from "@stapel/tokens";
|
|
99
134
|
import { isRedactedValue } from "@stapel/attributes-react";
|
|
100
135
|
import { useListingDetail } from "../headless/ListingDetail.js";
|
|
@@ -109,18 +144,37 @@ import type { ListingActionsConfig } from "./ListingActions.js";
|
|
|
109
144
|
import { LISTING_ACTION_CLASS } from "./actionRow.js";
|
|
110
145
|
import {
|
|
111
146
|
LISTINGS_GALLERY_CLASS,
|
|
147
|
+
LISTINGS_GALLERY_COUNTER_CLASS,
|
|
148
|
+
LISTINGS_GALLERY_FRAME_CLASS,
|
|
112
149
|
LISTINGS_GALLERY_STYLE_HREF,
|
|
113
150
|
detailGalleryCss,
|
|
151
|
+
useGalleryPosition,
|
|
114
152
|
} from "./detailGallery.js";
|
|
115
153
|
import type { ListingGalleryLayout } from "./detailGallery.js";
|
|
116
154
|
import { useMovableCluster } from "./movableCluster.js";
|
|
155
|
+
import {
|
|
156
|
+
DETAIL_RHYTHM_CLASS,
|
|
157
|
+
DETAIL_RHYTHM_STYLE_HREF,
|
|
158
|
+
DETAIL_RULE_CLASS,
|
|
159
|
+
DETAIL_TITLE_RESET,
|
|
160
|
+
detailRhythmCss,
|
|
161
|
+
} from "./detailRhythm.js";
|
|
162
|
+
import {
|
|
163
|
+
CONDENSED_BAR_CLASS,
|
|
164
|
+
CONDENSED_BAR_STYLE_HREF,
|
|
165
|
+
CONDENSED_TITLE_CLASS,
|
|
166
|
+
condensedBarCss,
|
|
167
|
+
} from "./condensedBar.js";
|
|
168
|
+
import { ListingRelatedStrip } from "./ListingRelated.js";
|
|
169
|
+
import type { ListingRelatedContext } from "./ListingRelated.js";
|
|
117
170
|
import { useNotice } from "./notice.js";
|
|
118
171
|
import { ListingSpecColumns, ListingSpecList } from "./ListingSpecList.js";
|
|
119
172
|
import { SignInLink } from "./SignInLink.js";
|
|
120
|
-
import { HeartIcon } from "./icons.js";
|
|
173
|
+
import { BackIcon, HeartIcon } from "./icons.js";
|
|
121
174
|
import { ListingPhoto } from "./ListingPhoto.js";
|
|
122
175
|
import { ListingPrice } from "./ListingPrice.js";
|
|
123
176
|
import { ListingStatusBlock } from "./StatusTags.js";
|
|
177
|
+
import type { ListingCard as ListingCardData } from "../api/types.js";
|
|
124
178
|
import type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
|
|
125
179
|
|
|
126
180
|
/** The reading measure of the page body. A detail page is prose plus a spec
|
|
@@ -304,13 +358,21 @@ export interface ListingDetailPaneProps
|
|
|
304
358
|
* - `"bar"` — the condensed top bar the host draws through
|
|
305
359
|
* {@link renderActionsBar}. Only meaningful in a LIST beside one of the
|
|
306
360
|
* three above, and only with that render prop: it names a second place
|
|
307
|
-
* the one cluster may travel to, never a home of its own
|
|
361
|
+
* the one cluster may travel to, never a home of its own;
|
|
362
|
+
* - `"condensed-top"` — the same second place, drawn by the PANE: a fixed
|
|
363
|
+
* strip carrying {@link onBack}, the title on one line and the cluster,
|
|
364
|
+
* on screen exactly while the title is off it. Also a loan and never a
|
|
365
|
+
* home. See `condensedBar.ts` for what it is and why the pane draws it
|
|
366
|
+
* rather than leaving every container to write the same four things.
|
|
308
367
|
*
|
|
309
|
-
* A LIST is how a host says "both": `["header", "
|
|
310
|
-
* beside the title and lends it to the bar for as long as the bar is
|
|
311
|
-
* screen. Exactly one home placement is honoured — the first
|
|
312
|
-
*
|
|
313
|
-
* this closes rather than the feature it adds.
|
|
368
|
+
* A LIST is how a host says "both": `["header", "condensed-top"]` keeps the
|
|
369
|
+
* cluster beside the title and lends it to the bar for as long as the bar is
|
|
370
|
+
* on screen. Exactly one home placement is honoured — the first entry that
|
|
371
|
+
* is neither loan — because two homes would need two instances, which is the
|
|
372
|
+
* defect this closes rather than the feature it adds.
|
|
373
|
+
*
|
|
374
|
+
* Asking for BOTH loans is a host that said something specific and a pair
|
|
375
|
+
* that has a default: `renderActionsBar` wins, and the pane draws nothing.
|
|
314
376
|
*/
|
|
315
377
|
readonly actionsPlacement?:
|
|
316
378
|
| ListingActionsPlacement
|
|
@@ -345,6 +407,94 @@ export interface ListingDetailPaneProps
|
|
|
345
407
|
* pane's own single are all what this prop exists to end.
|
|
346
408
|
*/
|
|
347
409
|
readonly renderActionsBar?: (cluster: ReactNode) => ReactNode;
|
|
410
|
+
/**
|
|
411
|
+
* LEAVE THIS PAGE — the back arrow of the pane's own condensed bar.
|
|
412
|
+
*
|
|
413
|
+
* A callback and not an `href`, for the reason the cards' open arm states:
|
|
414
|
+
* "back" is the HISTORY's, not an address, and a pair that called
|
|
415
|
+
* `history.back()` itself would move a router it does not own. Absent, the
|
|
416
|
+
* bar draws no arrow at all — a listing opened in a new tab has nowhere to
|
|
417
|
+
* go back to, and an arrow that reloads the home page is worse than none.
|
|
418
|
+
*
|
|
419
|
+
* Only read with `"condensed-top"` in {@link actionsPlacement}.
|
|
420
|
+
*/
|
|
421
|
+
readonly onBack?: () => void;
|
|
422
|
+
/**
|
|
423
|
+
* THE FOUR QUESTIONS EVERY BUYER ASKS, as chips above the contact control.
|
|
424
|
+
*
|
|
425
|
+
* The reference classified puts a "ask the seller" block on every listing —
|
|
426
|
+
* four canned questions and a free-text box — and it is the one thing on
|
|
427
|
+
* that page that turns a reader into a conversation (§16, all four
|
|
428
|
+
* comparisons). This pair ships the four in its own three locales; a host
|
|
429
|
+
* with a category that wants different ones passes its own, and `[]`
|
|
430
|
+
* switches the block off.
|
|
431
|
+
*
|
|
432
|
+
* At most four are drawn. A fifth is not a longer row, it is a wrapped
|
|
433
|
+
* second row of chips above the primary action.
|
|
434
|
+
*
|
|
435
|
+
* Requires {@link onQuickQuestion} — see there for why the pair cannot wire
|
|
436
|
+
* the press itself.
|
|
437
|
+
*/
|
|
438
|
+
readonly quickQuestions?: readonly string[];
|
|
439
|
+
/**
|
|
440
|
+
* WHAT A PRESSED CHIP DOES, and why it is the host's.
|
|
441
|
+
*
|
|
442
|
+
* The chip's whole job is to put its text in the composer of the thread with
|
|
443
|
+
* this seller, and neither half of that is this pair's: the thread belongs to
|
|
444
|
+
* `@stapel/chat-react` (it arrives here as `contactSlot`, because L2 pairs do
|
|
445
|
+
* not import each other), and as of chat-react 0.12.1 its own door takes no
|
|
446
|
+
* initial message — `<StartDirectChat>` takes `sellerId` and a subject,
|
|
447
|
+
* `useStartDirectChat` posts `{userId, subject}`, and `<MessageComposer>`
|
|
448
|
+
* opens on an empty string with no seed. So there is nothing for this pane to
|
|
449
|
+
* hand a text to.
|
|
450
|
+
*
|
|
451
|
+
* The honest seam is therefore the callback: the pane draws the chips and
|
|
452
|
+
* reports the press, the container opens the thread and seeds the composer
|
|
453
|
+
* the way it already navigates to one. Without this prop the block is not
|
|
454
|
+
* drawn — a chip that does nothing is worse than no chip.
|
|
455
|
+
*/
|
|
456
|
+
readonly onQuickQuestion?: (text: string) => void;
|
|
457
|
+
/**
|
|
458
|
+
* MORE LIKE THIS ONE — rows the host fetched from search
|
|
459
|
+
* (`category=` plus the main axes), drawn as a strip under the description.
|
|
460
|
+
*
|
|
461
|
+
* Empty or absent: no section at all. See `<ListingRelatedStrip>` for why
|
|
462
|
+
* the rows arrive rather than being read here.
|
|
463
|
+
*/
|
|
464
|
+
readonly similar?: readonly ListingCardData[];
|
|
465
|
+
/** More from the same seller — the `owner=` search, same contract as
|
|
466
|
+
* {@link similar}. */
|
|
467
|
+
readonly fromSeller?: readonly ListingCardData[];
|
|
468
|
+
/** Where the "show all" link of each strip goes — the search the strip is a
|
|
469
|
+
* sample of. Absent: the strip has no link. */
|
|
470
|
+
readonly similarHref?: string;
|
|
471
|
+
readonly fromSellerHref?: string;
|
|
472
|
+
/**
|
|
473
|
+
* The whole "more like this" section, built by the host — handed everything
|
|
474
|
+
* the pane knows about this listing. Overrides {@link similar}: a host with
|
|
475
|
+
* a renderer has already decided what the section is.
|
|
476
|
+
*/
|
|
477
|
+
readonly renderSimilar?: (context: ListingRelatedContext) => ReactNode;
|
|
478
|
+
/** The same, for the seller's other listings. Overrides {@link fromSeller}. */
|
|
479
|
+
readonly renderFromSeller?: (context: ListingRelatedContext) => ReactNode;
|
|
480
|
+
/** Where one card of either strip leads. Absent, the cards are inert. */
|
|
481
|
+
readonly listingHref?: (id: number) => string;
|
|
482
|
+
/** The host's `<Link>`, for those cards and the two "show all" links. */
|
|
483
|
+
readonly linkComponent?: LinkComponent;
|
|
484
|
+
/**
|
|
485
|
+
* HOW MANY SPEC ROWS STAND BEFORE THE FOLD — handed to
|
|
486
|
+
* `<ListingSpecList limit>`, which is where the argument for a fold and the
|
|
487
|
+
* rule about when it is worth one are written.
|
|
488
|
+
*
|
|
489
|
+
* Default: no fold, which is every existing mount unchanged. `10` is the
|
|
490
|
+
* phone answer.
|
|
491
|
+
*
|
|
492
|
+
* It does NOT change which rows exist. A live read was measured against the
|
|
493
|
+
* reference here (§16 comparison 1) and the shortfall was in the DATA, not
|
|
494
|
+
* in this component: the pane draws every stored row the build can key, and
|
|
495
|
+
* says how many it could not (`listings-detail-unreadable`).
|
|
496
|
+
*/
|
|
497
|
+
readonly characteristicsLimit?: number;
|
|
348
498
|
/**
|
|
349
499
|
* IS THE TITLE STILL IN THE FOLD?
|
|
350
500
|
*
|
|
@@ -474,37 +624,57 @@ export type ListingActionsPlacement =
|
|
|
474
624
|
| "header"
|
|
475
625
|
| "gallery"
|
|
476
626
|
| "buy-box"
|
|
477
|
-
| "bar"
|
|
627
|
+
| "bar"
|
|
628
|
+
| "condensed-top";
|
|
478
629
|
|
|
479
|
-
/** The
|
|
630
|
+
/** The two LOANS: places the one cluster may travel to, neither of them a
|
|
631
|
+
* home. See {@link ListingDetailPaneProps.actionsPlacement}. */
|
|
632
|
+
const LOANS: readonly ListingActionsPlacement[] = ["bar", "condensed-top"];
|
|
633
|
+
|
|
634
|
+
/** The cluster's HOME: the first entry that is not one of the two loans. */
|
|
480
635
|
function homePlacement(
|
|
481
636
|
placement: ListingDetailPaneProps["actionsPlacement"]
|
|
482
|
-
): Exclude<ListingActionsPlacement, "bar"> {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
637
|
+
): Exclude<ListingActionsPlacement, "bar" | "condensed-top"> {
|
|
638
|
+
const all =
|
|
639
|
+
placement === undefined
|
|
640
|
+
? []
|
|
641
|
+
: typeof placement === "string"
|
|
642
|
+
? [placement]
|
|
643
|
+
: placement;
|
|
644
|
+
for (const one of all) {
|
|
645
|
+
// A loan alone names no home — the cluster still has to live somewhere
|
|
486
646
|
// while the bar is off screen, and that somewhere is the default.
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
if (one !== "bar") return one;
|
|
647
|
+
if (!LOANS.includes(one)) {
|
|
648
|
+
return one as Exclude<ListingActionsPlacement, "bar" | "condensed-top">;
|
|
649
|
+
}
|
|
491
650
|
}
|
|
492
651
|
return "header";
|
|
493
652
|
}
|
|
494
653
|
|
|
495
|
-
/** Did the host ask for
|
|
496
|
-
function
|
|
497
|
-
placement: ListingDetailPaneProps["actionsPlacement"]
|
|
654
|
+
/** Did the host ask for this borrowed placement at all? */
|
|
655
|
+
function wants(
|
|
656
|
+
placement: ListingDetailPaneProps["actionsPlacement"],
|
|
657
|
+
one: ListingActionsPlacement
|
|
498
658
|
): boolean {
|
|
499
659
|
if (placement === undefined) return false;
|
|
500
|
-
if (typeof placement === "string") return placement ===
|
|
501
|
-
return placement.includes(
|
|
660
|
+
if (typeof placement === "string") return placement === one;
|
|
661
|
+
return placement.includes(one);
|
|
502
662
|
}
|
|
503
663
|
|
|
504
664
|
/** Priorities for the two mount points: the bar wins while it is on screen. */
|
|
505
665
|
const CLUSTER_HOME = 0;
|
|
506
666
|
const CLUSTER_BAR = 1;
|
|
507
667
|
|
|
668
|
+
/**
|
|
669
|
+
* How many canned questions the page draws, whoever supplies them.
|
|
670
|
+
*
|
|
671
|
+
* Four is the reference's own count and it is not arbitrary: the chips stand
|
|
672
|
+
* between the price and the one primary action on the page, and a fifth does
|
|
673
|
+
* not lengthen the row on a 390px phone — it wraps, and pushes "message the
|
|
674
|
+
* seller" a line further down.
|
|
675
|
+
*/
|
|
676
|
+
export const QUICK_QUESTIONS_MAX = 4;
|
|
677
|
+
|
|
508
678
|
/**
|
|
509
679
|
* The pane's own title, watched — see
|
|
510
680
|
* {@link ListingDetailPaneProps.onTitleVisible}.
|
|
@@ -522,23 +692,55 @@ function useTitleVisibility(
|
|
|
522
692
|
latest.current = onTitleVisible;
|
|
523
693
|
});
|
|
524
694
|
const wanted = onTitleVisible !== undefined;
|
|
695
|
+
/**
|
|
696
|
+
* THE ONE LIVE OBSERVER, held by the node it watches.
|
|
697
|
+
*
|
|
698
|
+
* React 19's ref cleanup is the normal way this is disconnected, and it is
|
|
699
|
+
* not the only caller: `<Typography.Title ref>` is antd's, which merges refs
|
|
700
|
+
* and invokes them itself — a callback's RETURN VALUE means nothing to a
|
|
701
|
+
* caller that is not React, so the cleanup was simply dropped and a fresh
|
|
702
|
+
* `IntersectionObserver` was attached to the same `<h1>` on every render of
|
|
703
|
+
* a page that re-renders on every query settle. Measured: three live
|
|
704
|
+
* observers on one heading after two crossings, each firing the host's
|
|
705
|
+
* callback again, so a subscriber counting crossings counted three where
|
|
706
|
+
* there were two.
|
|
707
|
+
*
|
|
708
|
+
* Holding the pair here makes the hook idempotent for the node it is already
|
|
709
|
+
* watching, whoever calls it and however many times, and disconnecting from
|
|
710
|
+
* an effect closes the case a dropped cleanup leaves open.
|
|
711
|
+
*/
|
|
712
|
+
const held = useRef<{ node: Element; observer: IntersectionObserver } | null>(
|
|
713
|
+
null
|
|
714
|
+
);
|
|
715
|
+
const stop = useCallback((): void => {
|
|
716
|
+
held.current?.observer.disconnect();
|
|
717
|
+
held.current = null;
|
|
718
|
+
}, []);
|
|
719
|
+
// The belt, for the caller that discards the cleanup above.
|
|
720
|
+
useEffect(() => stop, [stop]);
|
|
525
721
|
return useCallback(
|
|
526
722
|
(node: HTMLElement | null): (() => void) | undefined => {
|
|
527
|
-
if (node === null || !wanted)
|
|
723
|
+
if (node === null || !wanted) {
|
|
724
|
+
stop();
|
|
725
|
+
return undefined;
|
|
726
|
+
}
|
|
528
727
|
// No observer, no answer. A fabricated `true` would wedge a host's bar
|
|
529
728
|
// open on an arm that has no scrolling to close it with.
|
|
530
729
|
if (typeof IntersectionObserver === "undefined") return undefined;
|
|
730
|
+
// Already watching this very element: a second observer on it would
|
|
731
|
+
// report every crossing twice.
|
|
732
|
+
if (held.current?.node === node) return stop;
|
|
733
|
+
stop();
|
|
531
734
|
const observer = new IntersectionObserver((entries) => {
|
|
532
735
|
const entry = entries[entries.length - 1];
|
|
533
736
|
if (entry === undefined) return;
|
|
534
737
|
latest.current?.(entry.isIntersecting);
|
|
535
738
|
});
|
|
536
739
|
observer.observe(node);
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
};
|
|
740
|
+
held.current = { node, observer };
|
|
741
|
+
return stop;
|
|
540
742
|
},
|
|
541
|
-
[wanted]
|
|
743
|
+
[wanted, stop]
|
|
542
744
|
);
|
|
543
745
|
}
|
|
544
746
|
|
|
@@ -587,10 +789,53 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
587
789
|
rendered inline where it always was, no portal and no slot divs. */
|
|
588
790
|
const galleryLayout: ListingGalleryLayout = props.galleryLayout ?? "grid";
|
|
589
791
|
const renderBar = props.renderActionsBar;
|
|
590
|
-
|
|
792
|
+
/* TWO LOANS, ONE WINNER. The host's own render prop is the specific answer
|
|
793
|
+
and the pane's condensed bar is the default one, so a page that asked for
|
|
794
|
+
both gets the host's — and never two bars competing for the top of one
|
|
795
|
+
viewport, each holding half a cluster. */
|
|
796
|
+
const hostBar = wants(props.actionsPlacement, "bar") && renderBar !== undefined;
|
|
797
|
+
const ownBar = !hostBar && wants(props.actionsPlacement, "condensed-top");
|
|
798
|
+
const barred = hostBar || ownBar;
|
|
591
799
|
const movable = useMovableCluster(barred);
|
|
592
800
|
const moving = barred && movable.portable;
|
|
593
|
-
|
|
801
|
+
/* IS THE TITLE ON SCREEN — the pane's own copy of the answer it already
|
|
802
|
+
publishes. `false` to start, because the page opens AT the title and a bar
|
|
803
|
+
that flashed on the first frame is the defect the observer exists to
|
|
804
|
+
avoid. */
|
|
805
|
+
const [titleGone, setTitleGone] = useState(false);
|
|
806
|
+
const onTitleVisible = props.onTitleVisible;
|
|
807
|
+
/* One observer for both readers. The hook holds the latest callback in a
|
|
808
|
+
ref, so this inline arrow costs no re-observation — and where the host
|
|
809
|
+
asked for neither, `undefined` keeps the whole thing unarmed. */
|
|
810
|
+
const titleRef = useTitleVisibility(
|
|
811
|
+
onTitleVisible === undefined && !ownBar
|
|
812
|
+
? undefined
|
|
813
|
+
: (visible: boolean): void => {
|
|
814
|
+
// Only the arm that draws a bar keeps state. A host that merely
|
|
815
|
+
// subscribed does not get a re-render of this page per crossing.
|
|
816
|
+
if (ownBar) setTitleGone(!visible);
|
|
817
|
+
onTitleVisible?.(visible);
|
|
818
|
+
}
|
|
819
|
+
);
|
|
820
|
+
/* WHERE THE STRIP IS. Armed only for the arm that scrolls and only for a
|
|
821
|
+
listing with something to scroll THROUGH — see `useGalleryPosition`. */
|
|
822
|
+
const strip = galleryLayout === "strip" && bag.images.length > 1;
|
|
823
|
+
const photo = useGalleryPosition(strip);
|
|
824
|
+
/* THE FOUR QUESTIONS. Defaults from this pair's own catalogue, a host's own
|
|
825
|
+
list when it has one, and NOTHING at all when nobody can act on a press —
|
|
826
|
+
see `onQuickQuestion`. Capped at four: a fifth is a second row of chips
|
|
827
|
+
standing between a reader and the one primary action on the page. */
|
|
828
|
+
const quickQuestions: readonly string[] =
|
|
829
|
+
props.onQuickQuestion === undefined
|
|
830
|
+
? []
|
|
831
|
+
: (
|
|
832
|
+
props.quickQuestions ?? [
|
|
833
|
+
t(LISTINGS_I18N_KEYS.detailQuestionAvailable),
|
|
834
|
+
t(LISTINGS_I18N_KEYS.detailQuestionPrice),
|
|
835
|
+
t(LISTINGS_I18N_KEYS.detailQuestionViewing),
|
|
836
|
+
t(LISTINGS_I18N_KEYS.detailQuestionDelivery),
|
|
837
|
+
]
|
|
838
|
+
).slice(0, QUICK_QUESTIONS_MAX);
|
|
594
839
|
// The two arms of `actions` — see `isActionsConfig`.
|
|
595
840
|
const actionsConfig: ListingActionsConfig | undefined = isActionsConfig(
|
|
596
841
|
props.actions
|
|
@@ -650,7 +895,19 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
650
895
|
}}
|
|
651
896
|
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
652
897
|
>
|
|
653
|
-
|
|
898
|
+
{/* THE PAGE'S RHYTHM IS THE COLUMN'S — see `detailRhythm.ts`. Half the
|
|
899
|
+
children here are antd components carrying outer margins of their
|
|
900
|
+
own, and a margin and a gap ADD: the stand measured 111, 53, 42, 29
|
|
901
|
+
and 27 pixels inside a column that declares 16. */}
|
|
902
|
+
<style href={DETAIL_RHYTHM_STYLE_HREF} precedence="default">
|
|
903
|
+
{detailRhythmCss()}
|
|
904
|
+
</style>
|
|
905
|
+
<Flex
|
|
906
|
+
vertical
|
|
907
|
+
gap={spacing[4]}
|
|
908
|
+
className={DETAIL_RHYTHM_CLASS}
|
|
909
|
+
data-testid="listings-detail"
|
|
910
|
+
>
|
|
654
911
|
{bag.removed ? (
|
|
655
912
|
<ErrorAlert
|
|
656
913
|
testId="listings-detail-removed"
|
|
@@ -844,25 +1101,74 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
844
1101
|
portal has no position of its own (its content is wherever the
|
|
845
1102
|
winning slot is), and a bar is `position: fixed` chrome whose
|
|
846
1103
|
place in the document order is not its place on the screen. */
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1104
|
+
/* THE PANE'S OWN CONDENSED BAR (`"condensed-top"`).
|
|
1105
|
+
|
|
1106
|
+
Mounted only while the title is off screen, which is what makes
|
|
1107
|
+
the cluster travel at all: the slot inside it is the higher
|
|
1108
|
+
priority, so appearing borrows the cluster and disappearing
|
|
1109
|
+
hands it straight back to the heading — the same DOM node, the
|
|
1110
|
+
same optimistic favourite, no second `useFavoriteToggle`.
|
|
1111
|
+
|
|
1112
|
+
The back arrow is absent when the host gave no `onBack`: a
|
|
1113
|
+
listing opened in a new tab has nothing to go back to. */
|
|
1114
|
+
const condensedBar =
|
|
1115
|
+
!moving || !ownBar || !titleGone ? null : (
|
|
1116
|
+
<div
|
|
1117
|
+
className={CONDENSED_BAR_CLASS}
|
|
1118
|
+
data-testid="listings-detail-condensed-bar"
|
|
1119
|
+
>
|
|
1120
|
+
<style href={CONDENSED_BAR_STYLE_HREF} precedence="default">
|
|
1121
|
+
{condensedBarCss()}
|
|
1122
|
+
</style>
|
|
1123
|
+
{props.onBack === undefined ? null : (
|
|
1124
|
+
<Button
|
|
1125
|
+
type="text"
|
|
1126
|
+
shape="circle"
|
|
1127
|
+
className={LISTING_ACTION_CLASS}
|
|
1128
|
+
aria-label={t(LISTINGS_I18N_KEYS.detailBack)}
|
|
1129
|
+
icon={<BackIcon />}
|
|
1130
|
+
data-testid="listings-detail-back"
|
|
1131
|
+
data-analytics="none"
|
|
1132
|
+
data-analytics-reason="navigation — the host owns its own history"
|
|
1133
|
+
onClick={props.onBack}
|
|
1134
|
+
/>
|
|
1135
|
+
)}
|
|
1136
|
+
{/* NOT a heading: the page already has exactly one, and a
|
|
1137
|
+
second copy of the same words at a heading level would
|
|
1138
|
+
put the listing into the document outline twice. */}
|
|
1139
|
+
<span
|
|
1140
|
+
className={CONDENSED_TITLE_CLASS}
|
|
1141
|
+
data-testid="listings-detail-condensed-title"
|
|
1142
|
+
>
|
|
1143
|
+
{listing.title ?? ""}
|
|
1144
|
+
</span>
|
|
1145
|
+
{movable.slot(CLUSTER_BAR, "condensed-top")}
|
|
1146
|
+
</div>
|
|
859
1147
|
);
|
|
860
1148
|
|
|
1149
|
+
const clusterLayer = !moving ? null : (
|
|
1150
|
+
<>
|
|
1151
|
+
{movable.render(readerActions)}
|
|
1152
|
+
{/* NOT a slot with a silent absence: one of the two arms is
|
|
1153
|
+
the only thing that makes `moving` true, so this is
|
|
1154
|
+
unreachable without one, and an absent bar (the host's own
|
|
1155
|
+
`null`, or the title back on screen) is the answer that
|
|
1156
|
+
sends the cluster home. There is no hole to place a
|
|
1157
|
+
`<SlotPlaceholder>` in — the cluster is at its primary
|
|
1158
|
+
placement instead. */}
|
|
1159
|
+
{hostBar && renderBar !== undefined
|
|
1160
|
+
? renderBar(movable.slot(CLUSTER_BAR, "bar"))
|
|
1161
|
+
: condensedBar}
|
|
1162
|
+
</>
|
|
1163
|
+
);
|
|
1164
|
+
|
|
861
1165
|
/* Element-width tiles: the grid decides how many fit, the
|
|
862
1166
|
photos fill them. */
|
|
863
|
-
const
|
|
1167
|
+
const galleryBox = (
|
|
864
1168
|
<div
|
|
1169
|
+
ref={photo.ref}
|
|
865
1170
|
data-testid="listings-detail-gallery"
|
|
1171
|
+
data-gallery-active={String(photo.active)}
|
|
866
1172
|
className={LISTINGS_GALLERY_CLASS}
|
|
867
1173
|
// The layout is a CLASS and an attribute, not an inline
|
|
868
1174
|
// `display`: a host with a shape neither arm offers can then
|
|
@@ -903,6 +1209,36 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
903
1209
|
</div>
|
|
904
1210
|
);
|
|
905
1211
|
|
|
1212
|
+
/* "3 of 16" OVER THE STRIP, and it moves with the strip.
|
|
1213
|
+
`aria-live="polite"` because it changes with no gesture a screen
|
|
1214
|
+
reader would otherwise report — a finger scrolls the strip
|
|
1215
|
+
natively and this line is the only announcement of the move.
|
|
1216
|
+
The grid arm gets none of it: every photograph is on screen at
|
|
1217
|
+
once there, and "1 of 3" over a grid of three is a control
|
|
1218
|
+
panel for a picture that needs none.
|
|
1219
|
+
|
|
1220
|
+
It is a SIBLING of the strip inside a frame, never a child: in
|
|
1221
|
+
this arm the strip is the scroll container, and an absolutely
|
|
1222
|
+
positioned child of a scroller scrolls away with the content it
|
|
1223
|
+
is supposed to be counting. See `detailGallery.ts`. */
|
|
1224
|
+
const gallery = !strip ? (
|
|
1225
|
+
galleryBox
|
|
1226
|
+
) : (
|
|
1227
|
+
<div className={LISTINGS_GALLERY_FRAME_CLASS}>
|
|
1228
|
+
{galleryBox}
|
|
1229
|
+
<span
|
|
1230
|
+
className={LISTINGS_GALLERY_COUNTER_CLASS}
|
|
1231
|
+
data-testid="listings-detail-photo-counter"
|
|
1232
|
+
aria-live="polite"
|
|
1233
|
+
>
|
|
1234
|
+
{t(LISTINGS_I18N_KEYS.cardPhotoCounter, {
|
|
1235
|
+
index: photo.active + 1,
|
|
1236
|
+
total: bag.images.length,
|
|
1237
|
+
})}
|
|
1238
|
+
</span>
|
|
1239
|
+
</div>
|
|
1240
|
+
);
|
|
1241
|
+
|
|
906
1242
|
const heading = (
|
|
907
1243
|
<>
|
|
908
1244
|
{/* THE TITLE AND THE TWO ACTIONS SHARE A LINE (§23).
|
|
@@ -918,7 +1254,9 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
918
1254
|
level={props.headingLevel ?? 3}
|
|
919
1255
|
ref={titleRef}
|
|
920
1256
|
data-testid="listings-detail-title"
|
|
921
|
-
|
|
1257
|
+
/* …and the same reset the price carries: the heading's own
|
|
1258
|
+
margin is a second opinion about the column's gap. */
|
|
1259
|
+
style={{ minWidth: 0, flex: "1 1 auto", ...DETAIL_TITLE_RESET }}
|
|
922
1260
|
>
|
|
923
1261
|
{listing.title ?? ""}
|
|
924
1262
|
</Typography.Title>
|
|
@@ -984,6 +1322,11 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
984
1322
|
<Typography.Title
|
|
985
1323
|
level={split ? 2 : 4}
|
|
986
1324
|
data-testid="listings-detail-price"
|
|
1325
|
+
/* The column's declared gap, and nothing else, between the
|
|
1326
|
+
price and the button under it — antd's heading margin
|
|
1327
|
+
outranks the column's reset, so it is answered here. See
|
|
1328
|
+
{@link DETAIL_TITLE_RESET}. */
|
|
1329
|
+
style={DETAIL_TITLE_RESET}
|
|
987
1330
|
>
|
|
988
1331
|
<ListingPrice
|
|
989
1332
|
amount={listing.price}
|
|
@@ -1029,9 +1372,42 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
1029
1372
|
</GatedButton>
|
|
1030
1373
|
</>
|
|
1031
1374
|
) : (
|
|
1032
|
-
<
|
|
1033
|
-
{
|
|
1034
|
-
|
|
1375
|
+
<Flex vertical gap={spacing[2]} style={{ minWidth: 0 }}>
|
|
1376
|
+
{/* ASK THE SELLER — above the door into the conversation,
|
|
1377
|
+
because that is what a pressed chip opens. Never on the
|
|
1378
|
+
owner's own page: the owner is the person being asked.
|
|
1379
|
+
|
|
1380
|
+
See `onQuickQuestion` for why the press leaves this
|
|
1381
|
+
pair: chat-react's door takes no initial message, so a
|
|
1382
|
+
pane that "prefilled" one would be inventing a seam
|
|
1383
|
+
that does not exist on the other side. */}
|
|
1384
|
+
{quickQuestions.length === 0 ? null : (
|
|
1385
|
+
<Flex vertical gap={spacing[1]} data-testid="listings-detail-questions">
|
|
1386
|
+
<Typography.Text type="secondary">
|
|
1387
|
+
{t(LISTINGS_I18N_KEYS.detailAskSeller)}
|
|
1388
|
+
</Typography.Text>
|
|
1389
|
+
<Flex wrap gap={spacing[2]}>
|
|
1390
|
+
{quickQuestions.map((question) => (
|
|
1391
|
+
<Button
|
|
1392
|
+
key={question}
|
|
1393
|
+
size="small"
|
|
1394
|
+
data-testid="listings-detail-question"
|
|
1395
|
+
data-analytics="none"
|
|
1396
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
1397
|
+
onClick={() => {
|
|
1398
|
+
props.onQuickQuestion?.(question);
|
|
1399
|
+
}}
|
|
1400
|
+
>
|
|
1401
|
+
{question}
|
|
1402
|
+
</Button>
|
|
1403
|
+
))}
|
|
1404
|
+
</Flex>
|
|
1405
|
+
</Flex>
|
|
1406
|
+
)}
|
|
1407
|
+
<div data-testid="listings-detail-contact">
|
|
1408
|
+
{props.contactSlot ?? <SlotPlaceholder name="contactSlot" />}
|
|
1409
|
+
</div>
|
|
1410
|
+
</Flex>
|
|
1035
1411
|
)}
|
|
1036
1412
|
|
|
1037
1413
|
{/* The reader's two actions live in the cluster now (see
|
|
@@ -1066,7 +1442,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
1066
1442
|
|
|
1067
1443
|
const description = (
|
|
1068
1444
|
<>
|
|
1069
|
-
<Typography.Title level={5}>
|
|
1445
|
+
<Typography.Title level={5} style={DETAIL_TITLE_RESET}>
|
|
1070
1446
|
{t(LISTINGS_I18N_KEYS.detailDescription)}
|
|
1071
1447
|
</Typography.Title>
|
|
1072
1448
|
<Typography.Paragraph data-testid="listings-detail-description">
|
|
@@ -1100,12 +1476,18 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
1100
1476
|
) : split ? (
|
|
1101
1477
|
<ListingSpecColumns features={specFeatures} values={specValues} />
|
|
1102
1478
|
) : (
|
|
1103
|
-
<ListingSpecList
|
|
1479
|
+
<ListingSpecList
|
|
1480
|
+
features={specFeatures}
|
|
1481
|
+
values={specValues}
|
|
1482
|
+
{...(props.characteristicsLimit !== undefined
|
|
1483
|
+
? { limit: props.characteristicsLimit }
|
|
1484
|
+
: {})}
|
|
1485
|
+
/>
|
|
1104
1486
|
);
|
|
1105
1487
|
|
|
1106
1488
|
const specsSection = (
|
|
1107
1489
|
<>
|
|
1108
|
-
<Typography.Title level={5}>
|
|
1490
|
+
<Typography.Title level={5} style={DETAIL_TITLE_RESET}>
|
|
1109
1491
|
{t(LISTINGS_I18N_KEYS.detailSpecs)}
|
|
1110
1492
|
</Typography.Title>
|
|
1111
1493
|
{specs}
|
|
@@ -1162,6 +1544,62 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
1162
1544
|
<div data-testid="listings-detail-aside">{props.aside}</div>
|
|
1163
1545
|
) : null;
|
|
1164
1546
|
|
|
1547
|
+
/* THE TWO "FIND MORE" SECTIONS — see `<ListingRelatedStrip>` for
|
|
1548
|
+
why the rows arrive from the host and are not read here.
|
|
1549
|
+
|
|
1550
|
+
The render prop wins over the rows for each strip separately: a
|
|
1551
|
+
host may have a catalogue-link widget for "similar" and a plain
|
|
1552
|
+
list of the seller's other listings, which is exactly the pair
|
|
1553
|
+
the reference draws. Neither is rendered empty. */
|
|
1554
|
+
const related: ListingRelatedContext = {
|
|
1555
|
+
listingId: props.id,
|
|
1556
|
+
...(listing.category_id !== undefined && listing.category_id !== null
|
|
1557
|
+
? { categoryId: listing.category_id }
|
|
1558
|
+
: { categoryId: undefined }),
|
|
1559
|
+
ownerKey: listing.owner,
|
|
1560
|
+
axes: bag.titleFeatures,
|
|
1561
|
+
};
|
|
1562
|
+
const strips = (
|
|
1563
|
+
<>
|
|
1564
|
+
{props.renderSimilar !== undefined ? (
|
|
1565
|
+
props.renderSimilar(related)
|
|
1566
|
+
) : (
|
|
1567
|
+
<ListingRelatedStrip
|
|
1568
|
+
heading={t(LISTINGS_I18N_KEYS.detailSimilar)}
|
|
1569
|
+
items={props.similar ?? []}
|
|
1570
|
+
testId="listings-detail-similar"
|
|
1571
|
+
{...(props.similarHref !== undefined
|
|
1572
|
+
? { showAllHref: props.similarHref }
|
|
1573
|
+
: {})}
|
|
1574
|
+
{...(props.listingHref !== undefined
|
|
1575
|
+
? { listingHref: props.listingHref }
|
|
1576
|
+
: {})}
|
|
1577
|
+
{...(props.linkComponent !== undefined
|
|
1578
|
+
? { linkComponent: props.linkComponent }
|
|
1579
|
+
: {})}
|
|
1580
|
+
/>
|
|
1581
|
+
)}
|
|
1582
|
+
{props.renderFromSeller !== undefined ? (
|
|
1583
|
+
props.renderFromSeller(related)
|
|
1584
|
+
) : (
|
|
1585
|
+
<ListingRelatedStrip
|
|
1586
|
+
heading={t(LISTINGS_I18N_KEYS.detailFromSeller)}
|
|
1587
|
+
items={props.fromSeller ?? []}
|
|
1588
|
+
testId="listings-detail-from-seller"
|
|
1589
|
+
{...(props.fromSellerHref !== undefined
|
|
1590
|
+
? { showAllHref: props.fromSellerHref }
|
|
1591
|
+
: {})}
|
|
1592
|
+
{...(props.listingHref !== undefined
|
|
1593
|
+
? { listingHref: props.listingHref }
|
|
1594
|
+
: {})}
|
|
1595
|
+
{...(props.linkComponent !== undefined
|
|
1596
|
+
? { linkComponent: props.linkComponent }
|
|
1597
|
+
: {})}
|
|
1598
|
+
/>
|
|
1599
|
+
)}
|
|
1600
|
+
</>
|
|
1601
|
+
);
|
|
1602
|
+
|
|
1165
1603
|
if (!split) {
|
|
1166
1604
|
// The single column. `"end"` is the order it has always read —
|
|
1167
1605
|
// the host's aside joins where the footer's flow already is;
|
|
@@ -1178,10 +1616,11 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
1178
1616
|
{buyBox}
|
|
1179
1617
|
{actionError}
|
|
1180
1618
|
{asideAfterActions ? aside : null}
|
|
1181
|
-
<Divider />
|
|
1619
|
+
<Divider className={DETAIL_RULE_CLASS} />
|
|
1182
1620
|
{description}
|
|
1183
1621
|
{specsSection}
|
|
1184
1622
|
{meta}
|
|
1623
|
+
{strips}
|
|
1185
1624
|
{asideAfterActions ? null : aside}
|
|
1186
1625
|
{props.footer}
|
|
1187
1626
|
{clusterLayer}
|
|
@@ -1205,13 +1644,19 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
1205
1644
|
alignItems: "start",
|
|
1206
1645
|
}}
|
|
1207
1646
|
>
|
|
1208
|
-
<Flex
|
|
1647
|
+
<Flex
|
|
1648
|
+
vertical
|
|
1649
|
+
gap={spacing[4]}
|
|
1650
|
+
className={DETAIL_RHYTHM_CLASS}
|
|
1651
|
+
data-testid="listings-detail-reading-column"
|
|
1652
|
+
>
|
|
1209
1653
|
{gallery}
|
|
1210
1654
|
{heading}
|
|
1211
|
-
<Divider />
|
|
1655
|
+
<Divider className={DETAIL_RULE_CLASS} />
|
|
1212
1656
|
{description}
|
|
1213
1657
|
{specsSection}
|
|
1214
1658
|
{meta}
|
|
1659
|
+
{strips}
|
|
1215
1660
|
{props.footer}
|
|
1216
1661
|
</Flex>
|
|
1217
1662
|
{/* Sticky, so the actions ride along a page whose left
|
|
@@ -1222,6 +1667,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
1222
1667
|
<Flex
|
|
1223
1668
|
vertical
|
|
1224
1669
|
gap={spacing[3]}
|
|
1670
|
+
className={DETAIL_RHYTHM_CLASS}
|
|
1225
1671
|
data-testid="listings-detail-buy-column"
|
|
1226
1672
|
style={{
|
|
1227
1673
|
position: "sticky",
|