@stapel/listings-react 0.25.10 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +259 -0
- package/MODULE.md +3 -1
- package/README.md +122 -9
- package/dist/api/generated/schema.d.ts +22 -12
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/CardBadges.d.ts.map +1 -1
- package/dist/default/CardBadges.js +3 -1
- package/dist/default/CardBadges.js.map +1 -1
- package/dist/default/ListingActions.d.ts +108 -0
- package/dist/default/ListingActions.d.ts.map +1 -0
- package/dist/default/ListingActions.js +29 -0
- package/dist/default/ListingActions.js.map +1 -0
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +15 -8
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +170 -62
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +284 -28
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingFeedCard.d.ts.map +1 -1
- package/dist/default/ListingFeedCard.js +2 -1
- package/dist/default/ListingFeedCard.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +5 -2
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +18 -4
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +49 -21
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/default/ShareAction.d.ts +32 -0
- package/dist/default/ShareAction.d.ts.map +1 -0
- package/dist/default/ShareAction.js +116 -0
- package/dist/default/ShareAction.js.map +1 -0
- package/dist/default/actionRow.d.ts +58 -0
- package/dist/default/actionRow.d.ts.map +1 -0
- package/dist/default/actionRow.js +128 -0
- package/dist/default/actionRow.js.map +1 -0
- package/dist/default/cardGallery.d.ts +2 -0
- package/dist/default/cardGallery.d.ts.map +1 -1
- package/dist/default/cardGallery.js +31 -0
- package/dist/default/cardGallery.js.map +1 -1
- package/dist/default/detailGallery.d.ts +77 -0
- package/dist/default/detailGallery.d.ts.map +1 -0
- package/dist/default/detailGallery.js +91 -0
- package/dist/default/detailGallery.js.map +1 -0
- package/dist/default/favorite.d.ts +25 -0
- package/dist/default/favorite.d.ts.map +1 -1
- package/dist/default/favorite.js +29 -3
- package/dist/default/favorite.js.map +1 -1
- package/dist/default/icons.d.ts +19 -0
- package/dist/default/icons.d.ts.map +1 -1
- package/dist/default/icons.js +23 -0
- package/dist/default/icons.js.map +1 -1
- package/dist/default/index.d.ts +11 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +10 -1
- package/dist/default/index.js.map +1 -1
- package/dist/default/movableCluster.d.ts +18 -0
- package/dist/default/movableCluster.d.ts.map +1 -0
- package/dist/default/movableCluster.js +110 -0
- package/dist/default/movableCluster.js.map +1 -0
- package/dist/default/notice.d.ts +14 -0
- package/dist/default/notice.d.ts.map +1 -0
- package/dist/default/notice.js +62 -0
- package/dist/default/notice.js.map +1 -0
- package/dist/headless/MyListings.d.ts +61 -19
- package/dist/headless/MyListings.d.ts.map +1 -1
- package/dist/headless/MyListings.js +58 -19
- package/dist/headless/MyListings.js.map +1 -1
- package/dist/headless/Share.d.ts +103 -0
- package/dist/headless/Share.d.ts.map +1 -0
- package/dist/headless/Share.js +190 -0
- package/dist/headless/Share.js.map +1 -0
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +21 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.es.gen.js +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +18 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +9 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -1
- package/dist/i18n/keys.d.ts +45 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +60 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +21 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/model/cardBadges.d.ts +52 -3
- package/dist/model/cardBadges.d.ts.map +1 -1
- package/dist/model/cardBadges.js +74 -12
- package/dist/model/cardBadges.js.map +1 -1
- package/dist/model/mineSource.d.ts +9 -3
- package/dist/model/mineSource.d.ts.map +1 -1
- package/dist/model/mineSource.js.map +1 -1
- package/dist/model/status.d.ts +79 -15
- package/dist/model/status.d.ts.map +1 -1
- package/dist/model/status.js +86 -17
- package/dist/model/status.js.map +1 -1
- package/llms.txt +8 -3
- package/manifest.json +96 -4
- package/nav-manifest.json +1 -1
- package/package.json +10 -10
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +22 -12
- package/src/default/CardBadges.tsx +3 -1
- package/src/default/ListingActions.tsx +196 -0
- package/src/default/ListingCard.tsx +79 -43
- package/src/default/ListingDetailPane.tsx +520 -89
- package/src/default/ListingFeedCard.tsx +2 -0
- package/src/default/ListingPhoto.tsx +28 -0
- package/src/default/ListingSerpCard.tsx +39 -12
- package/src/default/MyListingsPane.tsx +81 -44
- package/src/default/ShareAction.tsx +260 -0
- package/src/default/actionRow.ts +131 -0
- package/src/default/cardGallery.ts +31 -0
- package/src/default/detailGallery.ts +97 -0
- package/src/default/favorite.tsx +68 -1
- package/src/default/icons.tsx +60 -0
- package/src/default/index.ts +37 -1
- package/src/default/movableCluster.tsx +137 -0
- package/src/default/notice.ts +68 -0
- package/src/headless/MyListings.tsx +130 -39
- package/src/headless/Share.tsx +278 -0
- package/src/i18n/es.ts +28 -0
- package/src/i18n/generated/errors.es.gen.ts +1 -1
- package/src/i18n/generated/errors.gen.ts +9 -0
- package/src/i18n/generated/errors.json +31 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/keys.ts +63 -0
- package/src/i18n/ru.ts +28 -0
- package/src/index.ts +22 -0
- package/src/model/cardBadges.ts +124 -12
- package/src/model/mineSource.ts +9 -3
- package/src/model/status.ts +102 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,264 @@
|
|
|
1
1
|
# @stapel/listings-react
|
|
2
2
|
|
|
3
|
+
## 0.27.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 630b418: **The reader's cluster can be in two places, and is one thing.**
|
|
8
|
+
`<ListingDetailPane actionsPlacement>` now takes a LIST (`["header", "bar"]`)
|
|
9
|
+
and a new `renderActionsBar` render prop is handed the cluster's MOUNT POINT,
|
|
10
|
+
not a copy of it: `<ListingActions>` is rendered once through a portal whose
|
|
11
|
+
container is moved between the two slots as a DOM node, so the component mounts
|
|
12
|
+
once, holds one `useFavoriteToggle`, and is literally the same element in both
|
|
13
|
+
placements. A container that wanted the two verbs beside the title AND in a
|
|
14
|
+
condensed top bar had to mount a second `<ListingActions>` of its own — two
|
|
15
|
+
hearts agreeing only after a refetch, and its own test ids so the pane's
|
|
16
|
+
`listings-detail-reader-actions` stayed single for anything counting it. That
|
|
17
|
+
second mount can go. Returning `null` from `renderActionsBar` hands the cluster
|
|
18
|
+
back home mid-write; asking for `"bar"` without the render prop changes
|
|
19
|
+
nothing at all, as does every existing single-placement mount.
|
|
20
|
+
|
|
21
|
+
**And `onTitleVisible` says when to ask for it.** An `IntersectionObserver` on
|
|
22
|
+
the pane's own title — never a scroll listener — reporting each crossing. The
|
|
23
|
+
same container watched that heading through the pane's published `data-testid`
|
|
24
|
+
and a `MutationObserver` (the title lands with the listing, not with the first
|
|
25
|
+
frame) for a boolean the pane already knew. Nothing is reported in an
|
|
26
|
+
environment with no `IntersectionObserver`: "this page cannot tell" is an
|
|
27
|
+
answer, and a fabricated `true` would wedge a host's bar open on the one arm
|
|
28
|
+
with no scrolling to close it.
|
|
29
|
+
|
|
30
|
+
**The gallery's shape is the host's, and saying so costs no `!important`.**
|
|
31
|
+
`<ListingDetailPane galleryLayout>` takes `"grid"` (default, the
|
|
32
|
+
element-width grid this pane has always drawn) or `"strip"` — a snap-scrolling
|
|
33
|
+
horizontal strip, one photograph visible with the next peeking. On a 390px
|
|
34
|
+
phone the grid resolves to ONE column, so a listing with three pictures pushes
|
|
35
|
+
its own title and price nearly three screens down. The pane also stops writing
|
|
36
|
+
`display` and the track INLINE: they are on `LISTINGS_GALLERY_CLASS` plus a
|
|
37
|
+
`data-gallery-layout` attribute now (`detailGalleryCss()`, exported), so a host
|
|
38
|
+
that wants a third shape at its own breakpoints needs a selector rather than
|
|
39
|
+
`!important` over a pair's own geometry. A live storefront was carrying exactly
|
|
40
|
+
that `!important`. What stays inline is what nobody overrides: the page's
|
|
41
|
+
responsive gutter (D418) and the `position: relative` the
|
|
42
|
+
`actionsPlacement="gallery"` overlay is pinned to.
|
|
43
|
+
|
|
44
|
+
**The takedown tab draws the SERVER's number (D407, second half).** Pinned to
|
|
45
|
+
stapel-listings **v0.22.4**, which adds `MyCountersResponse.blocked` beside
|
|
46
|
+
`active` / `archived` / `drafts`. The fourth tab was counted from its own
|
|
47
|
+
unpaged `?status=blocked` page — right up to a page and blind past one, and
|
|
48
|
+
only after that page had landed. It is the counter now: the tab appears with
|
|
49
|
+
`my/counters`, the badge is the whole set, and the page stays what it always
|
|
50
|
+
was underneath, the tab's rows. A deployment on a server older than 0.22.4
|
|
51
|
+
sends no `blocked` and degrades to the rows' length — never to a `0`, which is
|
|
52
|
+
the defect wearing the new field.
|
|
53
|
+
|
|
54
|
+
## 0.26.0
|
|
55
|
+
|
|
56
|
+
### Minor Changes
|
|
57
|
+
|
|
58
|
+
- f5a7906: A card's spec line stops printing a caption where a value is expected, and stops
|
|
59
|
+
captioning two axes identically (D421).
|
|
60
|
+
|
|
61
|
+
Measured on a live feed (translated): **"HONOR · Model 90 · 256 GB"** — three
|
|
62
|
+
facts, and the middle one reads as a value that begins with the word "Model";
|
|
63
|
+
and **"5 fl. · 9 fl. · 54 m²"** — the floor a flat is on and the number of
|
|
64
|
+
floors in the building, one number and its unit each, twice, with nothing
|
|
65
|
+
saying which is which.
|
|
66
|
+
|
|
67
|
+
Neither is a defect in the server's `presentation`: it was right about each
|
|
68
|
+
element ALONE. What it cannot see is where the text is PUT. A badge strip gives
|
|
69
|
+
every element a chip border; a spec line joins them with `" · "`, so a space
|
|
70
|
+
inside one item is not punctuation and a shared unit is not a distinction.
|
|
71
|
+
|
|
72
|
+
- **`CardBadgeStyle`.** `cardBadgeText(row, locale, style)` and
|
|
73
|
+
`cardBadgeTexts(rows, locale, style)` take `"badge"` (the default — every
|
|
74
|
+
existing call keeps its bytes) or `"line"`. The `name_value` pair is joined
|
|
75
|
+
with a space in a chip, exactly as the 0.22 contract wrote it, and with a
|
|
76
|
+
**colon** in a line: `"Model: 90"`, which is the punctuation that says what
|
|
77
|
+
follows is the answer to this.
|
|
78
|
+
- **The collision pass.** Ambiguity is a property of the SET, so it is resolved
|
|
79
|
+
once in `cardBadgeTexts`: elements printed without a caption that share a unit
|
|
80
|
+
— or that print identical text — get their catalogue names back, in the
|
|
81
|
+
`name_value` shape the contract already defines (`"Floor: 5 fl. · Floors:
|
|
82
|
+
9 fl."`, and in a chip strip `"Floor 5 fl."`). It refuses to act where it
|
|
83
|
+
would not help: a group whose names are missing, or that would wear one word
|
|
84
|
+
twice, is left as the server wrote it rather than captioned with noise.
|
|
85
|
+
|
|
86
|
+
`<CardBadges variant="line">` — the spec line under every card's title — passes
|
|
87
|
+
`"line"`; the badge strip is byte-identical.
|
|
88
|
+
|
|
89
|
+
- f5a7906: The listing gallery's gutter is the page's own, per breakpoint (D418).
|
|
90
|
+
|
|
91
|
+
Measured on the live listing page: `getComputedStyle(gallery).gap` answered
|
|
92
|
+
**`12px` at 1280 and `12px` at 390**, and the gaps between neighbouring photos
|
|
93
|
+
were 12 on both — while the page around it declares 4px on a phone and 24px on
|
|
94
|
+
a desktop. Neither declared number was ever on screen: the grid painted a flat
|
|
95
|
+
`spacing[3]`, so its tiles sat closer together than the page edge on a desktop
|
|
96
|
+
and three times further apart than it on a phone.
|
|
97
|
+
|
|
98
|
+
`--stapel-page-gutter` is a responsive token role (`@stapel/tokens`: 4 / 8 / 24
|
|
99
|
+
by breakpoint, declared once with its own media arms), and the gap reads it as
|
|
100
|
+
a VAR rather than as a number picked in JS — a computed value is applied at
|
|
101
|
+
render, so a window resized between renders keeps the gutter it was drawn with,
|
|
102
|
+
where a var reflows. Written through `cssVar` so a renamed role fails to
|
|
103
|
+
compile instead of resolving to nothing, with the old flat step as the fallback
|
|
104
|
+
for a host that loads no token stylesheet. Exported as `DETAIL_GALLERY_GUTTER`
|
|
105
|
+
for a container laying out against the same edge.
|
|
106
|
+
|
|
107
|
+
- f5a7906: A listing a moderator pulled has a tab, and a number (D407).
|
|
108
|
+
|
|
109
|
+
The desktop walk opened a cabinet holding one taken-down listing and read
|
|
110
|
+
three statements about it at once: the row itself, saying "taken down by a
|
|
111
|
+
moderator"; the counters over it, reading **"Active 0 · Drafts 0 ·
|
|
112
|
+
Archived 0"**; and the active tab's own empty state, saying nothing of the
|
|
113
|
+
seller's was live. The row was on the page, in no tab and in no number — and a
|
|
114
|
+
person reads the numbers. `blocked` is grouped by `my/counters` in none of the
|
|
115
|
+
three, so the pane fetched it separately and rendered it in a block above the
|
|
116
|
+
tab strip, which is exactly the half that was wrong: a row outside the tabs is
|
|
117
|
+
a row the counters do not describe.
|
|
118
|
+
|
|
119
|
+
**`removed` is the fourth tab.** `MY_LISTINGS_TABS` is the server's three plus
|
|
120
|
+
it; `MY_LISTINGS_COUNTED_TABS` and `countedTabOf()` are the three on their own,
|
|
121
|
+
for anything that has to line up with `MyCountersResponse`. Its rows are the
|
|
122
|
+
takedown read that already existed (`?status=blocked`, unpaged, running
|
|
123
|
+
whichever tab is open), so its **count is right while the seller is looking at
|
|
124
|
+
a different tab** — the state the defect was measured in. It is drawn only
|
|
125
|
+
where there is something in it, or where `?tab=removed` asks for it, and the
|
|
126
|
+
line above the tab strip stays as a LINE (`listings-mine-takedowns`, no rows),
|
|
127
|
+
so a takedown still cannot be missed without being printed twice on one screen.
|
|
128
|
+
|
|
129
|
+
Not folded into `archived`, which is the other shape this could take: that tab
|
|
130
|
+
reads the server's `archived` integer, which does not count takedowns, so the
|
|
131
|
+
badge would go on reading `0` for everyone not looking at it — and "I put this
|
|
132
|
+
away" and "a moderator took this down" are not one sentence.
|
|
133
|
+
|
|
134
|
+
**The gap this leaves, stated rather than papered over:** `my/counters` carries
|
|
135
|
+
three integers and no fourth, so the removed tab's number is the length of one
|
|
136
|
+
unpaged `?status=blocked` page. A seller with more takedowns than that page
|
|
137
|
+
holds would see the page and not the total, and there is no counter on the wire
|
|
138
|
+
to check it against.
|
|
139
|
+
|
|
140
|
+
`MyListingsTab` gains `"removed"`; `MyListingsSource` is deliberately **not**
|
|
141
|
+
widened — it stays typed `MyListingsCountedTab`, because the fourth tab never
|
|
142
|
+
goes through a host source and a source written before this release has no
|
|
143
|
+
answer for a tab it has not heard of.
|
|
144
|
+
|
|
145
|
+
- 510531b: A listing can be sent to somebody, and the heart is where a thumb already is.
|
|
146
|
+
|
|
147
|
+
The owner's finding on the live storefront (2026-09-06): **no «Поделиться»
|
|
148
|
+
anywhere in the product.** Not a badly placed share control, not one behind a
|
|
149
|
+
menu — none. The only way to send somebody an offer was the address bar, which
|
|
150
|
+
on a phone is the hardest thing on the screen to reach and which carries the
|
|
151
|
+
SERP query the visitor arrived from, the page anchor and whatever tracking
|
|
152
|
+
parameters came with them. Beside that, the reference measurement (§23): our
|
|
153
|
+
listing page drew a 152px "Save to favourites" button with a word in it at
|
|
154
|
+
every width, where the reference classified draws a 44×44 glyph and puts a
|
|
155
|
+
share glyph next to it.
|
|
156
|
+
|
|
157
|
+
**`<ShareAction>` and `useShare`.** Two arms, and the DEVICE picks. Where
|
|
158
|
+
`navigator.share` exists — every phone, almost no desktop — a press opens the
|
|
159
|
+
platform's own sheet with `{title, text, url}`: the person's apps, in their
|
|
160
|
+
order, including the ones we have never heard of. Where it does not, it opens a
|
|
161
|
+
menu: copy the link, Telegram, WhatsApp, VK. The menu is a `Popover` and that
|
|
162
|
+
is a documented exception to `stapel/no-tooltip-in-skin` rather than a hole —
|
|
163
|
+
click-only trigger (a thumb and a cursor use one gesture), a live enabled
|
|
164
|
+
anchor, and four CONTROLS in the overlay rather than a sentence somebody has to
|
|
165
|
+
hover to read. Every outbound link is `target="_blank"` with **both** halves of
|
|
166
|
+
`rel="noopener noreferrer"`, and every field is percent-encoded: a seller's
|
|
167
|
+
title contains `&` and `#` in the wild, and a raw one silently truncates the
|
|
168
|
+
URL the recipient receives at the first `&`.
|
|
169
|
+
|
|
170
|
+
**The URL is the host's, never the address bar.** `shareUrl` is used verbatim
|
|
171
|
+
(a path resolved against the document base); `window.location.href` is the
|
|
172
|
+
fallback for a host that supplied nothing, which is honest for a bare mount and
|
|
173
|
+
wrong for an app that has a canonical route.
|
|
174
|
+
|
|
175
|
+
**`<ListingActions>` — the reader's two verbs as one cluster.** Icon-only, 44px
|
|
176
|
+
each, at the trailing edge of the title row (`actionsPlacement="gallery"` pins
|
|
177
|
+
them over the photographs instead; `"buy-box"` is the escape hatch for a host
|
|
178
|
+
laid out around the old position). The owner of a listing keeps the share
|
|
179
|
+
button and loses the heart: you do not favourite your own listing, and sending
|
|
180
|
+
somebody your own listing is the first thing a seller does. `actions` now takes
|
|
181
|
+
either the node it always took OR `{ share: false }` / `{ favorite: false }` —
|
|
182
|
+
a plain object was never a legal `ReactNode`, so the two arms cannot be
|
|
183
|
+
confused.
|
|
184
|
+
|
|
185
|
+
**The card heart moved onto the photograph.** `<ListingCard>` and
|
|
186
|
+
`<ListingSerpCard>` join `<ListingFeedCard>`, which has drawn it there since it
|
|
187
|
+
existed: trailing top corner — the one corner of the strip that is free, since
|
|
188
|
+
the dots own the bottom centre and the "3 of 16" counter the bottom trailing —
|
|
189
|
+
outside every anchor, and stopping a press from reaching the card behind it.
|
|
190
|
+
Only the blocked visitor's REASON stayed in its row under the card, because a
|
|
191
|
+
sentence has nowhere to live on top of a picture, and that row is now drawn
|
|
192
|
+
only when there is something to put in it rather than as an empty strip of
|
|
193
|
+
padding under every card on a page. The SERP card's action rail keeps whatever
|
|
194
|
+
the container put in it and nothing else.
|
|
195
|
+
|
|
196
|
+
**Two hit-target tiers, stated once as a class contract.** 44px for the page's
|
|
197
|
+
controls at every width; 36px for a card's with a cursor and 44px with a thumb
|
|
198
|
+
— the reference's own desktop measurement, and the glyph does not change size
|
|
199
|
+
in either tier. `actionRow.ts` holds both, so "how big is this control" has one
|
|
200
|
+
answer in this package instead of one per surface (the SERP heart was 32px, the
|
|
201
|
+
feed heart 40).
|
|
202
|
+
|
|
203
|
+
**Confirmations are said twice, and briefly.** "Link copied" stands inside the
|
|
204
|
+
open menu AND goes out as a two-second toast; the heart's fill is the state and
|
|
205
|
+
"Added to favourites" / "Removed from favourites" is the acknowledgement a
|
|
206
|
+
glyph in the corner of a photograph cannot carry. The message seam is antd's
|
|
207
|
+
own — `App.useApp().message` where the host mounted `<App>`, antd's static
|
|
208
|
+
`message` otherwise — so there is no new dependency, no host wiring, and no
|
|
209
|
+
confirmation that exists only in a toast a person may not have been looking at.
|
|
210
|
+
|
|
211
|
+
Ten i18n keys in en/ru/es. Measured with dependencies held constant: `index`
|
|
212
|
+
15.27 → 15.99 KB (ceiling 16 → 17; the old one was hit EXACTLY, and a budget
|
|
213
|
+
passed by rounding is a budget the next sentence fails), `default` 25.34 →
|
|
214
|
+
27.56 KB (ceiling 26 → 29).
|
|
215
|
+
|
|
216
|
+
### Patch Changes
|
|
217
|
+
|
|
218
|
+
- f5a7906: A card's photo strip says how many photographs there are.
|
|
219
|
+
|
|
220
|
+
The mobile walk drove a real touch swipe across a feed card and watched the
|
|
221
|
+
indicator move correctly — `data-active` hopping from the first dot to the
|
|
222
|
+
second, the strip settling where the finger left it — and then recorded what
|
|
223
|
+
was missing beside it: **no counter of any kind**, where the reference
|
|
224
|
+
classified leads with "1 of 16".
|
|
225
|
+
|
|
226
|
+
Dots say WHERE in a strip a reader is and stop being countable at about five.
|
|
227
|
+
Only a number says how deep it goes, and on a phone — where the strip is one
|
|
228
|
+
photograph wide and the next one is off-screen — that number is the only thing
|
|
229
|
+
saying a swipe is worth making. `<ListingPhotoStrip>` (the strip every card
|
|
230
|
+
surface in this pair draws, `<ListingSerpCard>` included) now carries one:
|
|
231
|
+
`<testId>-counter`, bottom-trailing so it never argues with the dots'
|
|
232
|
+
bottom-centre, moving with a swipe, a hover scrub and a native scroll alike
|
|
233
|
+
because it reads the same `active` the dots do.
|
|
234
|
+
|
|
235
|
+
`aria-live="polite"`: a finger scrolling the strip changes the picture with
|
|
236
|
+
nothing else to report it. `pointer-events: none`, which is load-bearing rather
|
|
237
|
+
than tidy — the box under it owns the scrub and the swipe, and a pill that
|
|
238
|
+
swallowed a pointer would make one corner of every photograph dead to both. A
|
|
239
|
+
one-photograph strip gets no counter, exactly as it gets no dots and no peek.
|
|
240
|
+
|
|
241
|
+
The pill's scrim is a fixed translucent black with white text, and it is the
|
|
242
|
+
one place in this package where a theme role would be wrong: it sits on an
|
|
243
|
+
arbitrary PHOTOGRAPH, which is neither light nor dark.
|
|
244
|
+
|
|
245
|
+
- f5a7906: The delete dialog stops promising the archive to a listing already in it.
|
|
246
|
+
|
|
247
|
+
Measured on the phone walk, deleting from the **Archive** tab: "It disappears
|
|
248
|
+
from your dashboard and cannot be brought back. **Archiving keeps it.**" —
|
|
249
|
+
offered as an alternative to somebody standing in the archive, where it is not
|
|
250
|
+
an alternative at all.
|
|
251
|
+
|
|
252
|
+
The sentence now follows the ROW rather than the wording, and the state that
|
|
253
|
+
decides it is the one the seller would have to act on: whether `archived` is
|
|
254
|
+
still a move this listing has. An archived row (and a taken-down one) has spent
|
|
255
|
+
it and gets `listings.mine.delete_confirm_body.final` — the same warning
|
|
256
|
+
without the promise; a draft, a paused or an expired listing still has it and
|
|
257
|
+
still hears it. That is the row's own `available_transitions` and not a table
|
|
258
|
+
about its status: `<MyListingsPane>`'s confirmation hook is now given the
|
|
259
|
+
card's field, as the row's own controls have been since 0.22.0, so "a sold
|
|
260
|
+
listing may be archived" and "this one may" stop being the same claim.
|
|
261
|
+
|
|
3
262
|
## 0.25.10
|
|
4
263
|
|
|
5
264
|
### Patch Changes
|
package/MODULE.md
CHANGED
|
@@ -133,6 +133,7 @@ own length refusal under. One routing table covers both.
|
|
|
133
133
|
| 1 | An owner-scoped list | **stapel-listings 0.7.0** — `GET my/listings/`, the caller's own rows in every status, `?status=` for a set, the same `IDAnchorPagination` envelope. `defaultMyListingsSource` is what the dashboard runs on; `MyListingsSource` survives as a seam for a host that keeps its rows elsewhere, and the missing-source error is gone rather than kept as a comment about something that no longer happens. |
|
|
134
134
|
| 3 | `_get_own` in front of `update` / `partial_update` | **stapel-listings 0.6.2.** This pair still writes through `save-draft` (one write path is enough), but the endpoints are no longer a hole anything else that speaks the contract can walk through. |
|
|
135
135
|
| 4 | An owner check on `retrieve` | **stapel-listings 0.6.2** — `ListingQuerySet.visible_to(user)`: a stranger's draft now 404s from the same code path an absent id does. The pair's `publiclyVisible` report stays, addressed at the one reader who still reaches an unpublished listing there: its owner. |
|
|
136
|
+
| 7 | A counter for the takedowns | **stapel-listings 0.22.4** — `MyCountersResponse.blocked`, required beside the other three. D407's fourth tab was counted from its own unpaged `?status=blocked` page, which is right up to a page and blind past one; the badge is the server's now and the page is only the tab's rows. A deployment older than the field degrades to the page's length — never to a `0`, which is the defect wearing the new field. |
|
|
136
137
|
|
|
137
138
|
## 4. The three seams, and why none of them is an import
|
|
138
139
|
|
|
@@ -285,7 +286,8 @@ make it pass by copying the other package's keys in.
|
|
|
285
286
|
the weight:
|
|
286
287
|
|
|
287
288
|
- `status.test.ts` — the 9 × 4 table, the tab grouping (including BLOCKED,
|
|
288
|
-
which
|
|
289
|
+
which the three paged tabs fold in nowhere and `my_counters` has counted
|
|
290
|
+
separately since 0.22.4), and the transition mirror;
|
|
289
291
|
- `draft.test.ts` — the payload, in both directions: type tagging from the
|
|
290
292
|
category schema, blanks omitted rather than nulled, the countable/stock
|
|
291
293
|
cross-field rule, code-point length, the two kinds of publish 400;
|
package/README.md
CHANGED
|
@@ -111,6 +111,108 @@ nobody can read), and `signIn` is the door. `SignInCta` is core's, `{href}`
|
|
|
111
111
|
**or** `{onSignIn}`, the same prop `@stapel/chat-react` and
|
|
112
112
|
`@stapel/reviews-react` take. Omit it and the reason renders alone.
|
|
113
113
|
|
|
114
|
+
The heart itself sits **on the photograph**, in its trailing top corner, on
|
|
115
|
+
every card surface — the corner a thumb is already near, and the one corner of
|
|
116
|
+
the strip that is free (the dots own the bottom centre, the "3 of 16" counter
|
|
117
|
+
the bottom trailing). It is outside every anchor, because a link may not
|
|
118
|
+
contain a control, and it stops a press from reaching the card behind it. Only
|
|
119
|
+
the blocked visitor's REASON stayed in a row under the card, where there is a
|
|
120
|
+
line for it to live on.
|
|
121
|
+
|
|
122
|
+
## Save it, or send it to somebody
|
|
123
|
+
|
|
124
|
+
The two verbs a reader has. Before 0.26 this pair had one of them, and the
|
|
125
|
+
storefront had **no share control at all** — the only way to send somebody a
|
|
126
|
+
listing was the address bar.
|
|
127
|
+
|
|
128
|
+
```tsx
|
|
129
|
+
<ListingDetailPane
|
|
130
|
+
id={id}
|
|
131
|
+
shareUrl={`/l/${id}`} // the canonical route, not location.href
|
|
132
|
+
onShared={(channel) => track("share", { channel })}
|
|
133
|
+
actionsPlacement="header" // default; "gallery" pins them over the photos
|
|
134
|
+
/>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`<ListingActions>` is the cluster: the heart and `<ShareAction>` side by side,
|
|
138
|
+
icon-only, each with a 44px hit target, at the trailing edge of the title row
|
|
139
|
+
(or pinned over the gallery with `actionsPlacement="gallery"`). A card's heart
|
|
140
|
+
uses the smaller 36px tier with a cursor and the same 44px with a thumb — both
|
|
141
|
+
stated once, as a class contract, in `actionRow.ts`.
|
|
142
|
+
|
|
143
|
+
**Sharing has two arms and the device picks.** Where `navigator.share` exists —
|
|
144
|
+
every phone, almost no desktop — a press opens the PLATFORM sheet with
|
|
145
|
+
`{title, text, url}`: the person's own apps, in their own order. Where it does
|
|
146
|
+
not, it opens a menu: copy the link, Telegram, WhatsApp, VK. Every outbound
|
|
147
|
+
link is `target="_blank"` with `rel="noopener noreferrer"`, and every field is
|
|
148
|
+
`encodeURIComponent`-encoded, because a seller's title contains `&` and `#` in
|
|
149
|
+
the wild and a raw one truncates the link the recipient receives.
|
|
150
|
+
|
|
151
|
+
**The URL is yours.** `shareUrl` is used verbatim (a path is resolved against
|
|
152
|
+
the document base); `window.location.href` is consulted only when a host
|
|
153
|
+
supplies nothing, because the address a visitor is standing on carries the SERP
|
|
154
|
+
query they arrived from and whatever tracking parameters came with them.
|
|
155
|
+
|
|
156
|
+
**The same cluster can be in two places, and is one thing.** A phone reads the
|
|
157
|
+
listing page over four screens, so past the first one a classified draws a
|
|
158
|
+
condensed bar: back, the title, the two verbs. `actionsPlacement` takes a LIST
|
|
159
|
+
for that, and `renderActionsBar` is handed the cluster's MOUNT POINT — not a
|
|
160
|
+
copy of the cluster:
|
|
161
|
+
|
|
162
|
+
```tsx
|
|
163
|
+
const [inFold, setInFold] = useState(true);
|
|
164
|
+
|
|
165
|
+
<ListingDetailPane
|
|
166
|
+
id={id}
|
|
167
|
+
actionsPlacement={["header", "bar"]} // home, plus the place it may travel to
|
|
168
|
+
onTitleVisible={setInFold} // an IntersectionObserver on the pane's own <h1>
|
|
169
|
+
renderActionsBar={(cluster) =>
|
|
170
|
+
inFold ? null : <div className="topbar">{back}{title}{cluster}</div>
|
|
171
|
+
}
|
|
172
|
+
/>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`<ListingActions>` is rendered ONCE, through a portal, and the portal's
|
|
176
|
+
container is moved between the two mount points as a DOM node. So the cluster
|
|
177
|
+
mounts once, holds one `useFavoriteToggle`, and is literally the same element
|
|
178
|
+
in both placements — a favourite in flight survives the move, and a probe
|
|
179
|
+
counting `listings-detail-reader-actions` still finds exactly one. Return
|
|
180
|
+
`null` from `renderActionsBar` and the cluster goes home; ask for `"bar"`
|
|
181
|
+
without the render prop and nothing changes at all. `onTitleVisible` fires on
|
|
182
|
+
each crossing and never on a scroll frame, and is not called at all in an
|
|
183
|
+
environment with no `IntersectionObserver` — "this page cannot tell" is an
|
|
184
|
+
answer and `true` would not be.
|
|
185
|
+
|
|
186
|
+
**The photographs take the shape the host names.** `galleryLayout="grid"`
|
|
187
|
+
(default) is the element-width grid this pane has always drawn;
|
|
188
|
+
`galleryLayout="strip"` is a snap-scrolling horizontal strip, one photograph
|
|
189
|
+
visible with the next peeking. On a 390px phone the grid resolves to one
|
|
190
|
+
column, and a listing with three pictures pushes its own title and price nearly
|
|
191
|
+
three screens down. The host names it, like `layout` and for the same reason —
|
|
192
|
+
the side that knows the viewport it granted decides. And the pane no longer
|
|
193
|
+
writes `display` inline: it is on `LISTINGS_GALLERY_CLASS` with a
|
|
194
|
+
`data-gallery-layout` attribute (`detailGalleryCss()` is exported), so a host
|
|
195
|
+
that wants a third shape at its own breakpoints writes a selector rather than
|
|
196
|
+
an `!important` over a pair's geometry.
|
|
197
|
+
|
|
198
|
+
**Confirmations are said twice.** "Link copied" stands inside the open menu AND
|
|
199
|
+
goes out as a two-second toast; the heart's fill is the state and
|
|
200
|
+
"Added to favourites" is the acknowledgement. The toast seam is antd's own —
|
|
201
|
+
`App.useApp().message` where the host mounted `<App>`, antd's static `message`
|
|
202
|
+
otherwise — so there is no new dependency and no host wiring.
|
|
203
|
+
|
|
204
|
+
**A host switches either action off**, and the old node slot is untouched:
|
|
205
|
+
|
|
206
|
+
```tsx
|
|
207
|
+
<ListingDetailPane id={id} actions={{ share: false }} /> {/* no share button */}
|
|
208
|
+
<ListingDetailPane id={id} actions={<ReportButton id={id} />} /> {/* extra chrome, as before */}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Headless hosts get the same answers without antd: `useShare({ url, title, text })`
|
|
212
|
+
returns the resolved absolute url, whether the platform sheet exists, the three
|
|
213
|
+
encoded hrefs, and the clipboard write with its `copied`/`copyFailed` states —
|
|
214
|
+
plus `shareLinks()` as a pure function of `{url, title}`.
|
|
215
|
+
|
|
114
216
|
## Submitting a listing
|
|
115
217
|
|
|
116
218
|
Four contracts meet on the composer, and three of them arrive as seams rather
|
|
@@ -268,15 +370,26 @@ own config. Nothing about the contract is required for a card to draw.
|
|
|
268
370
|
0.7.0) is the owner-scoped read `GET /listings/` cannot be: `list` answers
|
|
269
371
|
`published()` and takes no owner parameter, so before 0.7.0 a seller's own
|
|
270
372
|
drafts were unreachable by any call the contract offered and this pane named
|
|
271
|
-
the absence instead of drawing an empty grid. Three tabs, each
|
|
272
|
-
`?status=` to the SERVER's own groupings, so a tab's rows and
|
|
273
|
-
`my/counters` badge always describe the same set. `MyListingsSource` is
|
|
274
|
-
a seam for a deployment that keeps its rows elsewhere
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
373
|
+
the absence instead of drawing an empty grid. Three counted tabs, each
|
|
374
|
+
narrowed with `?status=` to the SERVER's own groupings, so a tab's rows and
|
|
375
|
+
its `my/counters` badge always describe the same set. `MyListingsSource` is
|
|
376
|
+
still a seam for a deployment that keeps its rows elsewhere, and it is typed
|
|
377
|
+
for those three.
|
|
378
|
+
|
|
379
|
+
**The takedown gets a fourth tab, and a count (D407).** `blocked` is counted
|
|
380
|
+
by `my/counters` in no tab at all, so a cabinet holding a listing a moderator
|
|
381
|
+
had pulled read "Active 0 · Drafts 0 · Archived 0" over it — the row was on
|
|
382
|
+
the page, in no tab, in no number. It is now the `removed` tab: fetched off
|
|
383
|
+
the same route (`?status=blocked`, unpaged, whichever tab is open) and counted
|
|
384
|
+
by `MyCountersResponse.blocked`, the fourth integer stapel-listings 0.22.4
|
|
385
|
+
added beside the other three. The rows were the count until that release and
|
|
386
|
+
are still the count for a deployment whose server predates it — never a `0`,
|
|
387
|
+
which is the one answer that would put the defect back. The
|
|
388
|
+
tab is drawn only where there is something in it or `?tab=removed` asks for
|
|
389
|
+
it, and a one-line warning above the tab strip says how many there are without
|
|
390
|
+
waiting for a click. Not folded into `archived`: that tab reads the server's
|
|
391
|
+
`archived` number, which does not include takedowns, so the count would go on
|
|
392
|
+
being wrong for everyone not looking at it.
|
|
280
393
|
|
|
281
394
|
**A draft renders off its twin.** `title` / `price` / `images` are the
|
|
282
395
|
PUBLISHED fields and are empty until a publish promotes them, so the owner
|
|
@@ -436,15 +436,18 @@ export interface paths {
|
|
|
436
436
|
cookie?: never;
|
|
437
437
|
};
|
|
438
438
|
/**
|
|
439
|
-
* @description
|
|
439
|
+
* @description One integer per cabinet tab, over the caller's own listings.
|
|
440
440
|
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
441
|
+
* The tab groupings are the SERVER's — ``my/listings?status=`` takes
|
|
442
|
+
* the same sets — so a cabinet never has to re-derive which statuses
|
|
443
|
+
* make up "active". Every lifecycle status is in exactly one group:
|
|
444
|
+
* ``blocked`` (a moderation takedown) is its own count rather than
|
|
445
|
+
* being folded into ``archived``, because the two tell the seller
|
|
446
|
+
* different things and only one of them is theirs to undo.
|
|
447
|
+
*
|
|
448
|
+
* Owner-scoped at the queryset via ``owned_by``, soft-deleted rows
|
|
449
|
+
* excluded by the default manager — the same scope as
|
|
450
|
+
* ``my/listings``, so a tab's rows and its count always agree.
|
|
448
451
|
*
|
|
449
452
|
* **Permissions:** `IsAuthenticated`
|
|
450
453
|
*/
|
|
@@ -497,7 +500,7 @@ export interface paths {
|
|
|
497
500
|
* @description The caller's OWN listings, in every status.
|
|
498
501
|
*
|
|
499
502
|
* The counterpart of ``my/counters``: the same owner scope and the same
|
|
500
|
-
* status grouping, but the rows behind the
|
|
503
|
+
* status grouping, but the rows behind the four numbers. ``list`` is
|
|
501
504
|
* the shop window (``published()``, narrowable to nobody), so this is
|
|
502
505
|
* the only route by which a person can be shown their own drafts.
|
|
503
506
|
*
|
|
@@ -1053,6 +1056,7 @@ export interface components {
|
|
|
1053
1056
|
lat_draft?: string | null;
|
|
1054
1057
|
/** Format: decimal */
|
|
1055
1058
|
lon_draft?: string | null;
|
|
1059
|
+
/** @description The WRITE shape of features_draft: an object keyed by feature slug, each value a FeatureDto — e.g. {"mileage":{"type":"int","value":42000}}. A listing READ hands back features as a *list* of decorated DAOs instead (`features` / `features_title` / `features_badges` — `{slug, name, label, presentation, …}`); posting that same list back here is also accepted, e.g. [{"slug":"mileage","type":"int","value":42000}] — only `slug`, `type` and `value` are read back out, every other (decoration) key is ignored. A malformed payload of either shape gets a features_draft_shape / features_draft_value_shape / features_draft_unknown_slug 400 naming the shape that would have worked. */
|
|
1056
1060
|
features_draft?: {
|
|
1057
1061
|
[key: string]: components["schemas"]["FeatureDto"];
|
|
1058
1062
|
} | null;
|
|
@@ -1125,11 +1129,18 @@ export interface components {
|
|
|
1125
1129
|
* @enum {string}
|
|
1126
1130
|
*/
|
|
1127
1131
|
ModerationStatusEnum: "not_submitted" | "pending" | "approved" | "rejected" | "needs_review";
|
|
1128
|
-
/**
|
|
1132
|
+
/**
|
|
1133
|
+
* @description Listing counts by tab for the current user.
|
|
1134
|
+
*
|
|
1135
|
+
* One integer per cabinet tab, and every lifecycle status belongs to
|
|
1136
|
+
* exactly one of them — ``blocked`` included, so a listing a moderator
|
|
1137
|
+
* took down is counted somewhere instead of vanishing from the totals.
|
|
1138
|
+
*/
|
|
1129
1139
|
MyCountersResponse: {
|
|
1130
1140
|
active: number;
|
|
1131
1141
|
archived: number;
|
|
1132
1142
|
drafts: number;
|
|
1143
|
+
blocked: number;
|
|
1133
1144
|
};
|
|
1134
1145
|
/**
|
|
1135
1146
|
* @description The owner's own card — the public card plus what only an owner sees.
|
|
@@ -1283,6 +1294,7 @@ export interface components {
|
|
|
1283
1294
|
lat_draft?: string | null;
|
|
1284
1295
|
/** Format: decimal */
|
|
1285
1296
|
lon_draft?: string | null;
|
|
1297
|
+
/** @description The WRITE shape of features_draft: an object keyed by feature slug, each value a FeatureDto — e.g. {"mileage":{"type":"int","value":42000}}. A listing READ hands back features as a *list* of decorated DAOs instead (`features` / `features_title` / `features_badges` — `{slug, name, label, presentation, …}`); posting that same list back here is also accepted, e.g. [{"slug":"mileage","type":"int","value":42000}] — only `slug`, `type` and `value` are read back out, every other (decoration) key is ignored. A malformed payload of either shape gets a features_draft_shape / features_draft_value_shape / features_draft_unknown_slug 400 naming the shape that would have worked. */
|
|
1286
1298
|
features_draft?: {
|
|
1287
1299
|
[key: string]: components["schemas"]["FeatureDto"];
|
|
1288
1300
|
} | null;
|
|
@@ -1354,8 +1366,6 @@ export interface components {
|
|
|
1354
1366
|
labels?: string[];
|
|
1355
1367
|
vocabulary?: string | null;
|
|
1356
1368
|
level?: string | null;
|
|
1357
|
-
prefix?: string | null;
|
|
1358
|
-
postfix?: string | null;
|
|
1359
1369
|
};
|
|
1360
1370
|
/** @description Serializer for the ref_select feature DTO. */
|
|
1361
1371
|
RefSelectDto: {
|