@stapel/listings-react 0.25.1 → 0.25.2
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 +47 -0
- package/dist/default/ListingSerpCard.d.ts +16 -75
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +80 -2
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/MyListingsPane.d.ts +13 -0
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +11 -3
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/headless/MyListings.d.ts +35 -1
- package/dist/headless/MyListings.d.ts.map +1 -1
- package/dist/headless/MyListings.js +25 -1
- package/dist/headless/MyListings.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/model/tabAddress.d.ts +42 -0
- package/dist/model/tabAddress.d.ts.map +1 -0
- package/dist/model/tabAddress.js +84 -0
- package/dist/model/tabAddress.js.map +1 -0
- package/llms.txt +1 -1
- package/manifest.json +7 -1
- package/nav-manifest.json +1 -1
- package/package.json +6 -6
- package/src/analytics/generated/events.json +1 -1
- package/src/default/ListingSerpCard.tsx +38 -11
- package/src/default/MyListingsPane.tsx +24 -6
- package/src/headless/MyListings.tsx +64 -2
- package/src/index.ts +8 -0
- package/src/model/tabAddress.ts +103 -0
package/dist/index.js
CHANGED
|
@@ -68,6 +68,7 @@ export { MY_LISTINGS_TABS, MY_LISTINGS_TAB_STATUSES, MY_LISTINGS_UNTABBED_STATUS
|
|
|
68
68
|
export { LISTING_TRANSITIONS, OWNER_TRANSITIONS, canDelete, canTransition, ownerMoves, } from "./model/transitions.js";
|
|
69
69
|
// ── model: the owner's own rows ──────────────────────────────────────────────
|
|
70
70
|
export { defaultMyListingsSource } from "./model/mineSource.js";
|
|
71
|
+
export { MY_LISTINGS_TAB_PARAM, NO_ADDRESS, browserAddress, searchWithTab, tabFromSearch, } from "./model/tabAddress.js";
|
|
71
72
|
export { myListingImages, myListingPrice, myListingTitle, showsDraft, } from "./model/mine.js";
|
|
72
73
|
// ── model: the draft twin ────────────────────────────────────────────────────
|
|
73
74
|
export { EMPTY_LOCATION, createDraftBody, draftPatchFromValues, draftValuesFromDetail, draftValuesFromWire, droppedFeatureSlugs, emptyDraftValues, retainKnownFeatureValues, } from "./model/draft.js";
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6DG;AAEH,gFAAgF;AAChF,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAEzD,OAAO,EACL,wBAAwB,EACxB,gBAAgB,EAChB,+BAA+B,EAC/B,mBAAmB,EACnB,aAAa,GACd,MAAM,gBAAgB,CAAC;AAiCxB,gFAAgF;AAChF,OAAO,EACL,gBAAgB,EAChB,wBAAwB,EACxB,6BAA6B,EAC7B,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,KAAK,GACN,MAAM,mBAAmB,CAAC;AAQ3B,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,SAAS,EACT,aAAa,EACb,UAAU,GACX,MAAM,wBAAwB,CAAC;AAEhC,gFAAgF;AAChF,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAEhE,OAAO,EACL,qBAAqB,EACrB,UAAU,EACV,cAAc,EACd,aAAa,EACb,aAAa,GACd,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EACL,eAAe,EACf,cAAc,EACd,cAAc,EACd,UAAU,GACX,MAAM,iBAAiB,CAAC;AAEzB,gFAAgF;AAChF,OAAO,EACL,cAAc,EACd,eAAe,EACf,oBAAoB,EACpB,qBAAqB,EACrB,mBAAmB,EACnB,mBAAmB,EACnB,gBAAgB,EAChB,wBAAwB,GACzB,MAAM,kBAAkB,CAAC;AAO1B,gFAAgF;AAChF,OAAO,EACL,gBAAgB,EAChB,cAAc,EACd,uBAAuB,EACvB,sBAAsB,EACtB,mBAAmB,EACnB,sBAAsB,GACvB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAEtE,gFAAgF;AAChF,OAAO,EACL,iBAAiB,EACjB,cAAc,EACd,aAAa,EACb,cAAc,EACd,oBAAoB,GACrB,MAAM,uBAAuB,CAAC;AAO/B,gFAAgF;AAChF,OAAO,EACL,aAAa,EACb,eAAe,EACf,gBAAgB,EAChB,cAAc,GACf,MAAM,uBAAuB,CAAC;AAE/B,gFAAgF;AAChF,OAAO,EACL,cAAc,EACd,oBAAoB,EACpB,iBAAiB,EACjB,YAAY,EACZ,cAAc,EACd,WAAW,EACX,WAAW,EACX,mBAAmB,EACnB,aAAa,EACb,YAAY,EACZ,kBAAkB,EAClB,WAAW,EACX,mBAAmB,EACnB,cAAc,GACf,MAAM,uBAAuB,CAAC;AAG/B,gFAAgF;AAChF,OAAO,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAM3D,OAAO,EACL,sBAAsB,EACtB,oBAAoB,EACpB,cAAc,EACd,kBAAkB,GACnB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,iBAAiB,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAElE,OAAO,EACL,UAAU,EACV,eAAe,EACf,eAAe,EACf,oBAAoB,EACpB,gBAAgB,EAChB,aAAa,EACb,cAAc,EACd,gBAAgB,GACjB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,iBAAiB,EACjB,kBAAkB,EAClB,cAAc,EACd,gBAAgB,EAChB,kBAAkB,EAClB,iBAAiB,EACjB,YAAY,EACZ,oBAAoB,GACrB,MAAM,sBAAsB,CAAC;AAO9B,gFAAgF;AAChF,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAOpE,gFAAgF;AAChF,OAAO,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AAClE,OAAO,EACL,cAAc,EACd,wBAAwB,EACxB,0BAA0B,GAC3B,MAAM,8BAA8B,CAAC;AACtC,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AAK9E,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAC;AAQpF,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAKrE,OAAO,EACL,wBAAwB,EACxB,sBAAsB,EACtB,iBAAiB,EACjB,oBAAoB,EACpB,2BAA2B,EAC3B,yBAAyB,GAC1B,MAAM,0BAA0B,CAAC;AAMlC,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAMrF,OAAO,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AAOjE,gFAAgF;AAChF,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAEhE,gFAAgF;AAChF,OAAO,EACL,kBAAkB,EAClB,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,gBAAgB,CAAC;AAGxB,gFAAgF;AAChF,OAAO,EACL,eAAe,EACf,oBAAoB,EACpB,4BAA4B,EAC5B,0BAA0B,EAC1B,iBAAiB,EACjB,iCAAiC,EACjC,oBAAoB,EACpB,qBAAqB,GACtB,MAAM,qBAAqB,CAAC"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { MyListingsTab } from "./status.js";
|
|
2
|
+
/** The query parameter the tab lives in. */
|
|
3
|
+
export declare const MY_LISTINGS_TAB_PARAM = "tab";
|
|
4
|
+
/**
|
|
5
|
+
* The tab a query string names, or `undefined`.
|
|
6
|
+
*
|
|
7
|
+
* An unknown value is `undefined` and NOT an error: a link written by hand, a
|
|
8
|
+
* tab this build has dropped, or a `?tab=` some other component on the page
|
|
9
|
+
* owns must all fall back to the host's `initialTab` rather than throwing or
|
|
10
|
+
* opening an empty list.
|
|
11
|
+
*/
|
|
12
|
+
export declare function tabFromSearch(search: string): MyListingsTab | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* The same query string with the tab written into it.
|
|
15
|
+
*
|
|
16
|
+
* Every other parameter is preserved verbatim and in place: the dashboard is
|
|
17
|
+
* one component on somebody's page, and rewriting an address it does not own
|
|
18
|
+
* would drop whatever else is in it.
|
|
19
|
+
*/
|
|
20
|
+
export declare function searchWithTab(search: string, tab: MyListingsTab): string;
|
|
21
|
+
/**
|
|
22
|
+
* The address binding. A host with a router passes its own; absent, the
|
|
23
|
+
* browser's is used and a non-DOM environment gets one that does nothing.
|
|
24
|
+
*/
|
|
25
|
+
export interface MyListingsAddress {
|
|
26
|
+
/** The current query string, `?`-prefixed or not — both parse. */
|
|
27
|
+
readonly search: string;
|
|
28
|
+
/** Write the tab. See the module note for why this replaces. */
|
|
29
|
+
setTab(tab: MyListingsTab): void;
|
|
30
|
+
}
|
|
31
|
+
/** An address that carries nothing and remembers nothing — SSR, and a host
|
|
32
|
+
* that opts out. */
|
|
33
|
+
export declare const NO_ADDRESS: MyListingsAddress;
|
|
34
|
+
/**
|
|
35
|
+
* The browser's own address, or {@link NO_ADDRESS} where there is no document.
|
|
36
|
+
*
|
|
37
|
+
* Read fresh on every call rather than captured: the host's router may have
|
|
38
|
+
* navigated since this component mounted, and a stale copy would write back
|
|
39
|
+
* an address that is no longer on screen.
|
|
40
|
+
*/
|
|
41
|
+
export declare function browserAddress(): MyListingsAddress;
|
|
42
|
+
//# sourceMappingURL=tabAddress.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tabAddress.d.ts","sourceRoot":"","sources":["../../src/model/tabAddress.ts"],"names":[],"mappings":"AA6BA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,4CAA4C;AAC5C,eAAO,MAAM,qBAAqB,QAAQ,CAAC;AAE3C;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS,CAIvE;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,aAAa,GAAG,MAAM,CAKxE;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,kEAAkE;IAClE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,gEAAgE;IAChE,MAAM,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAAC;CAClC;AAED;oBACoB;AACpB,eAAO,MAAM,UAAU,EAAE,iBAGxB,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,cAAc,IAAI,iBAAiB,CAelD"}
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
/** The query parameter the tab lives in. */
|
|
31
|
+
export const MY_LISTINGS_TAB_PARAM = "tab";
|
|
32
|
+
/**
|
|
33
|
+
* The tab a query string names, or `undefined`.
|
|
34
|
+
*
|
|
35
|
+
* An unknown value is `undefined` and NOT an error: a link written by hand, a
|
|
36
|
+
* tab this build has dropped, or a `?tab=` some other component on the page
|
|
37
|
+
* owns must all fall back to the host's `initialTab` rather than throwing or
|
|
38
|
+
* opening an empty list.
|
|
39
|
+
*/
|
|
40
|
+
export function tabFromSearch(search) {
|
|
41
|
+
const value = new URLSearchParams(search).get(MY_LISTINGS_TAB_PARAM);
|
|
42
|
+
if (value === null)
|
|
43
|
+
return undefined;
|
|
44
|
+
return MY_LISTINGS_TABS.find((tab) => tab === value);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The same query string with the tab written into it.
|
|
48
|
+
*
|
|
49
|
+
* Every other parameter is preserved verbatim and in place: the dashboard is
|
|
50
|
+
* one component on somebody's page, and rewriting an address it does not own
|
|
51
|
+
* would drop whatever else is in it.
|
|
52
|
+
*/
|
|
53
|
+
export function searchWithTab(search, tab) {
|
|
54
|
+
const params = new URLSearchParams(search);
|
|
55
|
+
params.set(MY_LISTINGS_TAB_PARAM, tab);
|
|
56
|
+
const next = params.toString();
|
|
57
|
+
return next.length > 0 ? `?${next}` : "";
|
|
58
|
+
}
|
|
59
|
+
/** An address that carries nothing and remembers nothing — SSR, and a host
|
|
60
|
+
* that opts out. */
|
|
61
|
+
export const NO_ADDRESS = {
|
|
62
|
+
search: "",
|
|
63
|
+
setTab: () => undefined,
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* The browser's own address, or {@link NO_ADDRESS} where there is no document.
|
|
67
|
+
*
|
|
68
|
+
* Read fresh on every call rather than captured: the host's router may have
|
|
69
|
+
* navigated since this component mounted, and a stale copy would write back
|
|
70
|
+
* an address that is no longer on screen.
|
|
71
|
+
*/
|
|
72
|
+
export function browserAddress() {
|
|
73
|
+
if (typeof window === "undefined" || typeof window.history === "undefined") {
|
|
74
|
+
return NO_ADDRESS;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
search: window.location.search,
|
|
78
|
+
setTab: (tab) => {
|
|
79
|
+
const next = searchWithTab(window.location.search, tab);
|
|
80
|
+
window.history.replaceState(window.history.state, "", `${window.location.pathname}${next}${window.location.hash}`);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=tabAddress.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tabAddress.js","sourceRoot":"","sources":["../../src/model/tabAddress.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAG/C,4CAA4C;AAC5C,MAAM,CAAC,MAAM,qBAAqB,GAAG,KAAK,CAAC;AAE3C;;;;;;;GAOG;AACH,MAAM,UAAU,aAAa,CAAC,MAAc;IAC1C,MAAM,KAAK,GAAG,IAAI,eAAe,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC;IACrE,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IACrC,OAAO,gBAAgB,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,KAAK,KAAK,CAAC,CAAC;AACvD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,MAAc,EAAE,GAAkB;IAC9D,MAAM,MAAM,GAAG,IAAI,eAAe,CAAC,MAAM,CAAC,CAAC;IAC3C,MAAM,CAAC,GAAG,CAAC,qBAAqB,EAAE,GAAG,CAAC,CAAC;IACvC,MAAM,IAAI,GAAG,MAAM,CAAC,QAAQ,EAAE,CAAC;IAC/B,OAAO,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;AAC3C,CAAC;AAaD;oBACoB;AACpB,MAAM,CAAC,MAAM,UAAU,GAAsB;IAC3C,MAAM,EAAE,EAAE;IACV,MAAM,EAAE,GAAG,EAAE,CAAC,SAAS;CACxB,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,UAAU,cAAc;IAC5B,IAAI,OAAO,MAAM,KAAK,WAAW,IAAI,OAAO,MAAM,CAAC,OAAO,KAAK,WAAW,EAAE,CAAC;QAC3E,OAAO,UAAU,CAAC;IACpB,CAAC;IACD,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,QAAQ,CAAC,MAAM;QAC9B,MAAM,EAAE,CAAC,GAAG,EAAE,EAAE;YACd,MAAM,IAAI,GAAG,aAAa,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;YACxD,MAAM,CAAC,OAAO,CAAC,YAAY,CACzB,MAAM,CAAC,OAAO,CAAC,KAAK,EACpB,EAAE,EACF,GAAG,MAAM,CAAC,QAAQ,CAAC,QAAQ,GAAG,IAAI,GAAG,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,CAC5D,CAAC;QACJ,CAAC;KACF,CAAC;AACJ,CAAC"}
|
package/llms.txt
CHANGED
package/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$generated": "by scripts/gen-manifest.mjs — do not edit; drift-gated (pnpm gen:manifest:check)",
|
|
3
3
|
"package": "@stapel/listings-react",
|
|
4
|
-
"version": "0.25.
|
|
4
|
+
"version": "0.25.2",
|
|
5
5
|
"backend": {
|
|
6
6
|
"module": "stapel-listings",
|
|
7
7
|
"contract": ">=0.21 <0.22"
|
|
@@ -1182,15 +1182,18 @@
|
|
|
1182
1182
|
"ListingsRuntimeContext",
|
|
1183
1183
|
"MODERATION_STATUSES",
|
|
1184
1184
|
"MY_LISTINGS_TABS",
|
|
1185
|
+
"MY_LISTINGS_TAB_PARAM",
|
|
1185
1186
|
"MY_LISTINGS_TAB_STATUSES",
|
|
1186
1187
|
"MY_LISTINGS_UNTABBED_STATUSES",
|
|
1187
1188
|
"MyListings",
|
|
1189
|
+
"NO_ADDRESS",
|
|
1188
1190
|
"OWNER_TRANSITIONS",
|
|
1189
1191
|
"PRICE_FIELD",
|
|
1190
1192
|
"TITLE_FIELD",
|
|
1191
1193
|
"asFeatureDaoList",
|
|
1192
1194
|
"badgePresentation",
|
|
1193
1195
|
"badgeValueText",
|
|
1196
|
+
"browserAddress",
|
|
1194
1197
|
"canDelete",
|
|
1195
1198
|
"canTransition",
|
|
1196
1199
|
"cardBadgeText",
|
|
@@ -1239,7 +1242,9 @@
|
|
|
1239
1242
|
"publishRefusal",
|
|
1240
1243
|
"registerListingsI18n",
|
|
1241
1244
|
"retainKnownFeatureValues",
|
|
1245
|
+
"searchWithTab",
|
|
1242
1246
|
"showsDraft",
|
|
1247
|
+
"tabFromSearch",
|
|
1243
1248
|
"tabOf",
|
|
1244
1249
|
"unreadableFeatureCount",
|
|
1245
1250
|
"useArchiveListing",
|
|
@@ -1335,6 +1340,7 @@
|
|
|
1335
1340
|
"ModerationNotice",
|
|
1336
1341
|
"MyCounters",
|
|
1337
1342
|
"MyListingCard",
|
|
1343
|
+
"MyListingsAddress",
|
|
1338
1344
|
"MyListingsBag",
|
|
1339
1345
|
"MyListingsParams",
|
|
1340
1346
|
"MyListingsSource",
|
package/nav-manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stapel/listings-react",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.2",
|
|
4
4
|
"description": "Headless React pair for stapel-listings: a typed client over the draft twin, the composer that turns a category schema into a submittable listing, and the owner's dashboard. Two independent axes are rendered as two — lifecycle `status` decides visibility and `moderation_status` decides nothing about it, so an edit to a live listing stays live and says 'under review' instead of vanishing. Feature values are drawn and mirrored by @stapel/attributes-react; photos arrive as an injected upload bag whose `refs` ARE `images_draft` and whose `settled` gates the submit; a publish refusal is routed onto the control that caused it by slug. Zero visual opinion in the main entry; an opt-in /default subpath ships the antd skin.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -102,13 +102,13 @@
|
|
|
102
102
|
"size-limit": "^11.2.0",
|
|
103
103
|
"typescript": "^5.8.3",
|
|
104
104
|
"vitest": "^3.2.4",
|
|
105
|
-
"@stapel/attributes-react": "^0.16.
|
|
106
|
-
"@stapel/core": "^0.25.
|
|
105
|
+
"@stapel/attributes-react": "^0.16.1",
|
|
106
|
+
"@stapel/core": "^0.25.2",
|
|
107
107
|
"@stapel/currencies-react": "^0.4.0",
|
|
108
|
-
"@stapel/showcase": "^0.3.0",
|
|
109
108
|
"@stapel/image": "^0.4.2",
|
|
110
|
-
"@stapel/
|
|
111
|
-
"@stapel/tokens
|
|
109
|
+
"@stapel/showcase": "^0.3.0",
|
|
110
|
+
"@stapel/tokens": "^0.8.0",
|
|
111
|
+
"@stapel/tokens-antd": "^0.17.1"
|
|
112
112
|
},
|
|
113
113
|
"engines": {
|
|
114
114
|
"node": ">=22"
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
* has to be re-laid-out the day the projection grows the field. The seam is
|
|
74
74
|
* the honest half; the missing data is written down rather than faked.
|
|
75
75
|
*/
|
|
76
|
+
import { Fragment } from "react";
|
|
76
77
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
77
78
|
import { Card, Flex, Typography, theme as antdTheme } from "antd";
|
|
78
79
|
import { SkinTheme } from "@stapel/tokens-antd/skin";
|
|
@@ -141,6 +142,22 @@ export interface ListingSerpCardBaseProps
|
|
|
141
142
|
/** The seller's name and rating. See the file header for the per-card fetch
|
|
142
143
|
* caveat a container has to avoid. */
|
|
143
144
|
readonly sellerSlot?: ReactNode;
|
|
145
|
+
/**
|
|
146
|
+
* Where the seller line sits relative to the place: `"above"` (the default,
|
|
147
|
+
* and what this card has always drawn) or `"below"`.
|
|
148
|
+
*
|
|
149
|
+
* The reference classified — and the host measured against it — reads the
|
|
150
|
+
* bottom of a card as one descending line of PROVENANCE: what it is, then
|
|
151
|
+
* where it is, then who is selling it. This card put the seller between the
|
|
152
|
+
* specs and the place, which reads as part of the description rather than as
|
|
153
|
+
* the answer to "who am I buying from".
|
|
154
|
+
*
|
|
155
|
+
* A prop and not a change, because the order is a surface's decision and
|
|
156
|
+
* some surfaces genuinely want the seller first — a seller's own page, where
|
|
157
|
+
* every card has the same one, keeps it out of the way at the bottom, and a
|
|
158
|
+
* cross-seller feed leads with it. Both stay one line of composition.
|
|
159
|
+
*/
|
|
160
|
+
readonly sellerSlotPosition?: "above" | "below";
|
|
144
161
|
/** The vertical action column at the trailing edge — call, write. The
|
|
145
162
|
* favourite heart is added at its end by this component. */
|
|
146
163
|
readonly actionsRail?: ReactNode;
|
|
@@ -353,18 +370,28 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
|
353
370
|
|
|
354
371
|
{/* Outside the anchor, both of them: a seller line usually holds
|
|
355
372
|
a link to the seller, and the place is the last thing read
|
|
356
|
-
rather than part of what the card is called.
|
|
357
|
-
{props.sellerSlot ?? null}
|
|
373
|
+
rather than part of what the card is called.
|
|
358
374
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
375
|
+
Their ORDER is the surface's — see
|
|
376
|
+
`sellerSlotPosition`. Rendered from one array so the two
|
|
377
|
+
arms cannot drift into two different pairs of nodes. */}
|
|
378
|
+
{(props.sellerSlotPosition === "below"
|
|
379
|
+
? ["location", "seller"]
|
|
380
|
+
: ["seller", "location"]
|
|
381
|
+
).map((part) =>
|
|
382
|
+
part === "seller" ? (
|
|
383
|
+
<Fragment key="seller">{props.sellerSlot ?? null}</Fragment>
|
|
384
|
+
) : listing.location_label !== undefined &&
|
|
385
|
+
listing.location_label.length > 0 ? (
|
|
386
|
+
<Typography.Text
|
|
387
|
+
key="location"
|
|
388
|
+
type="secondary"
|
|
389
|
+
data-testid="listings-serp-location"
|
|
390
|
+
>
|
|
391
|
+
{listing.location_label}
|
|
392
|
+
</Typography.Text>
|
|
393
|
+
) : null
|
|
394
|
+
)}
|
|
368
395
|
</Flex>
|
|
369
396
|
|
|
370
397
|
{rail}
|
|
@@ -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.
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|