@stapel/listings-react 0.25.1 → 0.25.3

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 (36) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/default/ListingDetailPane.d.ts +39 -4
  3. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  4. package/dist/default/ListingDetailPane.js +9 -4
  5. package/dist/default/ListingDetailPane.js.map +1 -1
  6. package/dist/default/ListingSerpCard.d.ts +16 -75
  7. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  8. package/dist/default/ListingSerpCard.js +80 -2
  9. package/dist/default/ListingSerpCard.js.map +1 -1
  10. package/dist/default/MyListingsPane.d.ts +13 -0
  11. package/dist/default/MyListingsPane.d.ts.map +1 -1
  12. package/dist/default/MyListingsPane.js +11 -3
  13. package/dist/default/MyListingsPane.js.map +1 -1
  14. package/dist/headless/MyListings.d.ts +35 -1
  15. package/dist/headless/MyListings.d.ts.map +1 -1
  16. package/dist/headless/MyListings.js +25 -1
  17. package/dist/headless/MyListings.js.map +1 -1
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/model/tabAddress.d.ts +42 -0
  23. package/dist/model/tabAddress.d.ts.map +1 -0
  24. package/dist/model/tabAddress.js +84 -0
  25. package/dist/model/tabAddress.js.map +1 -0
  26. package/llms.txt +1 -1
  27. package/manifest.json +7 -1
  28. package/nav-manifest.json +1 -1
  29. package/package.json +6 -6
  30. package/src/analytics/generated/events.json +1 -1
  31. package/src/default/ListingDetailPane.tsx +50 -8
  32. package/src/default/ListingSerpCard.tsx +38 -11
  33. package/src/default/MyListingsPane.tsx +24 -6
  34. package/src/headless/MyListings.tsx +64 -2
  35. package/src/index.ts +8 -0
  36. package/src/model/tabAddress.ts +103 -0
@@ -63,6 +63,7 @@ import { useListingActions } from "../headless/ListingActions.js";
63
63
  import { myListingTitle, neverSubmitted } from "../model/mine.js";
64
64
  import { listingStatusView } from "../model/status.js";
65
65
  import type { MyListingsTab } from "../model/status.js";
66
+ import type { MyListingsAddress } from "../model/tabAddress.js";
66
67
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
67
68
  import { ListingPhoto } from "./ListingPhoto.js";
68
69
  import { SignInLink } from "./SignInLink.js";
@@ -358,6 +359,17 @@ export interface MyListingsPaneProps extends ThemeModeProp, SignInCtaProp {
358
359
  /** How the host gets the caller's own rows. Absent: the contract's own
359
360
  * `GET my/listings/`, which is what a storefront wants. */
360
361
  readonly source?: MyListingsSource;
362
+ /**
363
+ * Which tab to open when the ADDRESS names none — `?tab=drafts` wins over
364
+ * it. See `model/tabAddress.ts`.
365
+ */
366
+ readonly initialTab?: MyListingsTab;
367
+ /**
368
+ * Where the open tab is kept. Default: the browser's own `?tab=`, so
369
+ * `/account/listings?tab=drafts` opens drafts and a reload keeps it. A host
370
+ * with a router passes its own binding; `NO_ADDRESS` opts out.
371
+ */
372
+ readonly address?: MyListingsAddress;
361
373
  /**
362
374
  * Open the composer on one of these listings. ABSENT IS A REAL ANSWER: the
363
375
  * Edit button then states that this app has no editing screen instead of
@@ -392,9 +404,11 @@ export interface MyListingsPaneProps extends ThemeModeProp, SignInCtaProp {
392
404
  export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
393
405
  const t = useT();
394
406
  const tPlural = useTPlural();
395
- const bag = useMyListings(
396
- props.source !== undefined ? { source: props.source } : {}
397
- );
407
+ const bag = useMyListings({
408
+ ...(props.source !== undefined ? { source: props.source } : {}),
409
+ ...(props.initialTab !== undefined ? { initialTab: props.initialTab } : {}),
410
+ ...(props.address !== undefined ? { address: props.address } : {}),
411
+ });
398
412
  // ONE confirmation for the whole list, keyed by the row that asked — not one
399
413
  // mounted dialog per row.
400
414
  const [removingId, setRemovingId] = useState<number | null>(null);
@@ -483,7 +497,7 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
483
497
  label: (
484
498
  <>
485
499
  {t(TAB_LABEL[tab])}
486
- {matchLoad(bag.counters, {
500
+ {matchLoad(bag.tabCounts, {
487
501
  loading: () => null,
488
502
  // A count we could not fetch is not zero. The number is
489
503
  // simply absent and the failure is stated once, below.
@@ -492,12 +506,16 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
492
506
  // danger token and "Active 2" is not a warning. It also
493
507
  // keeps the tab short enough that three of them fit on a
494
508
  // phone instead of collapsing into an overflow menu.
495
- ready: (counters) => (
509
+ //
510
+ // `tabCounts`, not `counters`: the badge is never allowed to
511
+ // read lower than the rows underneath it (D407 — a
512
+ // moderator-rejected listing sat in Drafts under a `0`).
513
+ ready: (counts) => (
496
514
  <Typography.Text
497
515
  type="secondary"
498
516
  data-testid={`listings-mine-count-${tab}`}
499
517
  >
500
- {` ${String(counters[tab])}`}
518
+ {` ${String(counts[tab])}`}
501
519
  </Typography.Text>
502
520
  ),
503
521
  })}
@@ -22,6 +22,8 @@ import { useMyCounters } from "../model/queries.js";
22
22
  import { listingsQueryKeys, pageKey } from "../model/queryKeys.js";
23
23
  import { MY_LISTINGS_TABS, MY_LISTINGS_UNTABBED_STATUSES } from "../model/status.js";
24
24
  import type { MyListingsTab } from "../model/status.js";
25
+ import { browserAddress, tabFromSearch } from "../model/tabAddress.js";
26
+ import type { MyListingsAddress } from "../model/tabAddress.js";
25
27
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
26
28
  import { useMandateGate } from "./useMandateGate.js";
27
29
 
@@ -47,8 +49,26 @@ export interface MyListingsBag {
47
49
  readonly tab: MyListingsTab;
48
50
  readonly tabs: readonly MyListingsTab[];
49
51
  setTab(tab: MyListingsTab): void;
50
- /** The three real counts. */
52
+ /** The three real counts, as the server reports them. */
51
53
  readonly counters: LoadState<MyCounters>;
54
+ /**
55
+ * The number to DRAW on each tab — the server's counter, raised to what is
56
+ * actually on screen.
57
+ *
58
+ * D407: a moderator-rejected listing was on the Drafts tab under a badge
59
+ * reading `0`. The two sets are grouped in two places — `my/counters`
60
+ * aggregates server-side, `MY_LISTINGS_TAB_STATUSES` decides which statuses
61
+ * a tab ASKS for — and any disagreement between them (a deployment running
62
+ * an older counter, a status added upstream, a grouping changed on one side)
63
+ * lands as a badge contradicting the rows underneath it.
64
+ *
65
+ * A count smaller than what a person can see is not a count, so the loaded
66
+ * rows are treated as evidence: for the OPEN tab the number is never below
67
+ * `rows.length`. It is a floor and not a replacement — the rows are one
68
+ * keyset page and the counter is the whole set, so the counter still wins
69
+ * whenever it is the larger of the two.
70
+ */
71
+ readonly tabCounts: LoadState<Readonly<Record<MyListingsTab, number>>>;
52
72
  /** The rows for the current tab. */
53
73
  readonly rows: LoadState<readonly MyListingCard[]>;
54
74
  /**
@@ -72,8 +92,23 @@ export interface UseMyListingsOptions {
72
92
  /** Replace the contract's own `my/listings` read — a deployment that keeps
73
93
  * its sellers' rows somewhere else. Absent: {@link defaultMyListingsSource}. */
74
94
  readonly source?: MyListingsSource;
95
+ /**
96
+ * Which tab to open when the ADDRESS names none. `?tab=drafts` wins over it
97
+ * — an address is a person's own statement about what they want to see, and
98
+ * a default cannot outrank one.
99
+ */
75
100
  readonly initialTab?: MyListingsTab;
76
101
  readonly limit?: number;
102
+ /**
103
+ * Where the open tab is kept. Default: the browser's own query string
104
+ * (`?tab=`), which is what makes `/account/listings?tab=drafts` open drafts
105
+ * and survive a reload — see `model/tabAddress.ts`.
106
+ *
107
+ * A host with a router passes its own binding. `NO_ADDRESS` opts out
108
+ * entirely, for a surface that mounts this hook somewhere the address is not
109
+ * about it (two dashboards on one page, a preview inside a modal).
110
+ */
111
+ readonly address?: MyListingsAddress;
77
112
  }
78
113
 
79
114
  export function useMyListings(
@@ -81,8 +116,13 @@ export function useMyListings(
81
116
  ): MyListingsBag {
82
117
  const gate = useMandateGate();
83
118
  const sessionReady = useActiveSessionReady();
119
+ // The address is read ONCE, at mount, and written on every change: this is
120
+ // the initial value of a control the person then operates, not a controlled
121
+ // input fed by the URL. (A host that navigates its own router to a different
122
+ // `?tab=` remounts the pane, which is the same thing.)
123
+ const address = options.address ?? browserAddress();
84
124
  const [tab, setTabState] = useState<MyListingsTab>(
85
- options.initialTab ?? "active"
125
+ () => tabFromSearch(address.search) ?? options.initialTab ?? "active"
86
126
  );
87
127
  const [page, setPage] = useState<MyListingsParams>(
88
128
  options.limit !== undefined ? { limit: options.limit } : {}
@@ -130,6 +170,24 @@ export function useMyListings(
130
170
 
131
171
  const envelope = rowsQuery.data;
132
172
 
173
+ // D407, the floor: never a number smaller than the rows on screen. See
174
+ // `MyListingsBag.tabCounts`.
175
+ const tabCounts: LoadState<Readonly<Record<MyListingsTab, number>>> =
176
+ useMemo(() => {
177
+ if (counters.status === "error") return loadFailed(counters.error);
178
+ if (counters.data === undefined) return loadLoading();
179
+ const server = counters.data;
180
+ const visible = rows.status === "ready" ? rows.data.length : 0;
181
+ return loadReady(
182
+ Object.fromEntries(
183
+ MY_LISTINGS_TABS.map((one) => [
184
+ one,
185
+ one === tab ? Math.max(server[one], visible) : server[one],
186
+ ])
187
+ ) as Readonly<Record<MyListingsTab, number>>
188
+ );
189
+ }, [counters.status, counters.error, counters.data, rows, tab]);
190
+
133
191
  return {
134
192
  tab,
135
193
  tabs: MY_LISTINGS_TABS,
@@ -140,6 +198,9 @@ export function useMyListings(
140
198
  // down for its own keyset state.
141
199
  setPage(options.limit !== undefined ? { limit: options.limit } : {});
142
200
  setTabState(next);
201
+ // …and the address says which list is on screen, so a reload, a
202
+ // bookmark and a shared link all land on it.
203
+ (options.address ?? browserAddress()).setTab(next);
143
204
  },
144
205
  counters:
145
206
  counters.status === "error"
@@ -147,6 +208,7 @@ export function useMyListings(
147
208
  : counters.data !== undefined
148
209
  ? loadReady(counters.data)
149
210
  : loadLoading(),
211
+ tabCounts,
150
212
  rows,
151
213
  blockedRows,
152
214
  page,
package/src/index.ts CHANGED
@@ -132,6 +132,14 @@ export {
132
132
  // ── model: the owner's own rows ──────────────────────────────────────────────
133
133
  export { defaultMyListingsSource } from "./model/mineSource.js";
134
134
  export type { MyListingsSource } from "./model/mineSource.js";
135
+ export {
136
+ MY_LISTINGS_TAB_PARAM,
137
+ NO_ADDRESS,
138
+ browserAddress,
139
+ searchWithTab,
140
+ tabFromSearch,
141
+ } from "./model/tabAddress.js";
142
+ export type { MyListingsAddress } from "./model/tabAddress.js";
135
143
  export {
136
144
  myListingImages,
137
145
  myListingPrice,
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The dashboard's open tab, IN THE ADDRESS.
3
+ *
4
+ * `<MyListingsPane>` kept its tab in component state, which is the same as
5
+ * keeping it nowhere: `/account/listings?tab=drafts` opened Active, a reload
6
+ * threw the tab away, and a seller who wanted to send somebody (or themselves,
7
+ * tomorrow) to their drafts had no address that meant "drafts". "Which of my
8
+ * three lists am I looking at" is exactly the kind of state a URL is for —
9
+ * `@stapel/search-react` says the same thing at length about its filters.
10
+ *
11
+ * ── Why this module and not a router ─────────────────────────────────────
12
+ *
13
+ * This pair carries no router and must not grow one: a package that reached
14
+ * for `react-router` would be unusable in a Next.js app and vice versa. So the
15
+ * address is read and written through the two DOM APIs every browser has, and
16
+ * the whole of that contact is here — three pure functions plus one guarded
17
+ * read/write pair, so a test can exercise the rules without a window and a
18
+ * host with its own router can pass {@link MyListingsAddress} instead.
19
+ *
20
+ * ── replace, not push ────────────────────────────────────────────────────
21
+ *
22
+ * Switching tab is a READ of your own dashboard, not a step in a flow. A
23
+ * `pushState` per tab would make Back walk Archive → Drafts → Active before it
24
+ * left the dashboard at all, which is the history churn `@stapel/search-react`
25
+ * documents for its own "a filter is a read" decision. `replaceState` keeps
26
+ * the address shareable and reloadable, which is the whole point, and leaves
27
+ * Back meaning "the page before this one".
28
+ */
29
+ import { MY_LISTINGS_TABS } from "./status.js";
30
+ import type { MyListingsTab } from "./status.js";
31
+
32
+ /** The query parameter the tab lives in. */
33
+ export const MY_LISTINGS_TAB_PARAM = "tab";
34
+
35
+ /**
36
+ * The tab a query string names, or `undefined`.
37
+ *
38
+ * An unknown value is `undefined` and NOT an error: a link written by hand, a
39
+ * tab this build has dropped, or a `?tab=` some other component on the page
40
+ * owns must all fall back to the host's `initialTab` rather than throwing or
41
+ * opening an empty list.
42
+ */
43
+ export function tabFromSearch(search: string): MyListingsTab | undefined {
44
+ const value = new URLSearchParams(search).get(MY_LISTINGS_TAB_PARAM);
45
+ if (value === null) return undefined;
46
+ return MY_LISTINGS_TABS.find((tab) => tab === value);
47
+ }
48
+
49
+ /**
50
+ * The same query string with the tab written into it.
51
+ *
52
+ * Every other parameter is preserved verbatim and in place: the dashboard is
53
+ * one component on somebody's page, and rewriting an address it does not own
54
+ * would drop whatever else is in it.
55
+ */
56
+ export function searchWithTab(search: string, tab: MyListingsTab): string {
57
+ const params = new URLSearchParams(search);
58
+ params.set(MY_LISTINGS_TAB_PARAM, tab);
59
+ const next = params.toString();
60
+ return next.length > 0 ? `?${next}` : "";
61
+ }
62
+
63
+ /**
64
+ * The address binding. A host with a router passes its own; absent, the
65
+ * browser's is used and a non-DOM environment gets one that does nothing.
66
+ */
67
+ export interface MyListingsAddress {
68
+ /** The current query string, `?`-prefixed or not — both parse. */
69
+ readonly search: string;
70
+ /** Write the tab. See the module note for why this replaces. */
71
+ setTab(tab: MyListingsTab): void;
72
+ }
73
+
74
+ /** An address that carries nothing and remembers nothing — SSR, and a host
75
+ * that opts out. */
76
+ export const NO_ADDRESS: MyListingsAddress = {
77
+ search: "",
78
+ setTab: () => undefined,
79
+ };
80
+
81
+ /**
82
+ * The browser's own address, or {@link NO_ADDRESS} where there is no document.
83
+ *
84
+ * Read fresh on every call rather than captured: the host's router may have
85
+ * navigated since this component mounted, and a stale copy would write back
86
+ * an address that is no longer on screen.
87
+ */
88
+ export function browserAddress(): MyListingsAddress {
89
+ if (typeof window === "undefined" || typeof window.history === "undefined") {
90
+ return NO_ADDRESS;
91
+ }
92
+ return {
93
+ search: window.location.search,
94
+ setTab: (tab) => {
95
+ const next = searchWithTab(window.location.search, tab);
96
+ window.history.replaceState(
97
+ window.history.state,
98
+ "",
99
+ `${window.location.pathname}${next}${window.location.hash}`
100
+ );
101
+ },
102
+ };
103
+ }