@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.
Files changed (144) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/MODULE.md +3 -1
  3. package/README.md +122 -9
  4. package/dist/api/generated/schema.d.ts +22 -12
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/default/CardBadges.d.ts.map +1 -1
  7. package/dist/default/CardBadges.js +3 -1
  8. package/dist/default/CardBadges.js.map +1 -1
  9. package/dist/default/ListingActions.d.ts +108 -0
  10. package/dist/default/ListingActions.d.ts.map +1 -0
  11. package/dist/default/ListingActions.js +29 -0
  12. package/dist/default/ListingActions.js.map +1 -0
  13. package/dist/default/ListingCard.d.ts.map +1 -1
  14. package/dist/default/ListingCard.js +15 -8
  15. package/dist/default/ListingCard.js.map +1 -1
  16. package/dist/default/ListingDetailPane.d.ts +170 -62
  17. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  18. package/dist/default/ListingDetailPane.js +284 -28
  19. package/dist/default/ListingDetailPane.js.map +1 -1
  20. package/dist/default/ListingFeedCard.d.ts.map +1 -1
  21. package/dist/default/ListingFeedCard.js +2 -1
  22. package/dist/default/ListingFeedCard.js.map +1 -1
  23. package/dist/default/ListingPhoto.d.ts.map +1 -1
  24. package/dist/default/ListingPhoto.js +5 -2
  25. package/dist/default/ListingPhoto.js.map +1 -1
  26. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  27. package/dist/default/ListingSerpCard.js +18 -4
  28. package/dist/default/ListingSerpCard.js.map +1 -1
  29. package/dist/default/MyListingsPane.d.ts.map +1 -1
  30. package/dist/default/MyListingsPane.js +49 -21
  31. package/dist/default/MyListingsPane.js.map +1 -1
  32. package/dist/default/ShareAction.d.ts +32 -0
  33. package/dist/default/ShareAction.d.ts.map +1 -0
  34. package/dist/default/ShareAction.js +116 -0
  35. package/dist/default/ShareAction.js.map +1 -0
  36. package/dist/default/actionRow.d.ts +58 -0
  37. package/dist/default/actionRow.d.ts.map +1 -0
  38. package/dist/default/actionRow.js +128 -0
  39. package/dist/default/actionRow.js.map +1 -0
  40. package/dist/default/cardGallery.d.ts +2 -0
  41. package/dist/default/cardGallery.d.ts.map +1 -1
  42. package/dist/default/cardGallery.js +31 -0
  43. package/dist/default/cardGallery.js.map +1 -1
  44. package/dist/default/detailGallery.d.ts +77 -0
  45. package/dist/default/detailGallery.d.ts.map +1 -0
  46. package/dist/default/detailGallery.js +91 -0
  47. package/dist/default/detailGallery.js.map +1 -0
  48. package/dist/default/favorite.d.ts +25 -0
  49. package/dist/default/favorite.d.ts.map +1 -1
  50. package/dist/default/favorite.js +29 -3
  51. package/dist/default/favorite.js.map +1 -1
  52. package/dist/default/icons.d.ts +19 -0
  53. package/dist/default/icons.d.ts.map +1 -1
  54. package/dist/default/icons.js +23 -0
  55. package/dist/default/icons.js.map +1 -1
  56. package/dist/default/index.d.ts +11 -2
  57. package/dist/default/index.d.ts.map +1 -1
  58. package/dist/default/index.js +10 -1
  59. package/dist/default/index.js.map +1 -1
  60. package/dist/default/movableCluster.d.ts +18 -0
  61. package/dist/default/movableCluster.d.ts.map +1 -0
  62. package/dist/default/movableCluster.js +110 -0
  63. package/dist/default/movableCluster.js.map +1 -0
  64. package/dist/default/notice.d.ts +14 -0
  65. package/dist/default/notice.d.ts.map +1 -0
  66. package/dist/default/notice.js +62 -0
  67. package/dist/default/notice.js.map +1 -0
  68. package/dist/headless/MyListings.d.ts +61 -19
  69. package/dist/headless/MyListings.d.ts.map +1 -1
  70. package/dist/headless/MyListings.js +58 -19
  71. package/dist/headless/MyListings.js.map +1 -1
  72. package/dist/headless/Share.d.ts +103 -0
  73. package/dist/headless/Share.d.ts.map +1 -0
  74. package/dist/headless/Share.js +190 -0
  75. package/dist/headless/Share.js.map +1 -0
  76. package/dist/i18n/es.d.ts.map +1 -1
  77. package/dist/i18n/es.js +21 -0
  78. package/dist/i18n/es.js.map +1 -1
  79. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  80. package/dist/i18n/generated/errors.es.gen.js +1 -1
  81. package/dist/i18n/generated/errors.gen.d.ts +18 -0
  82. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  83. package/dist/i18n/generated/errors.gen.js +9 -0
  84. package/dist/i18n/generated/errors.gen.js.map +1 -1
  85. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  86. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  87. package/dist/i18n/keys.d.ts +45 -0
  88. package/dist/i18n/keys.d.ts.map +1 -1
  89. package/dist/i18n/keys.js +60 -0
  90. package/dist/i18n/keys.js.map +1 -1
  91. package/dist/i18n/ru.d.ts.map +1 -1
  92. package/dist/i18n/ru.js +21 -0
  93. package/dist/i18n/ru.js.map +1 -1
  94. package/dist/index.d.ts +5 -3
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +2 -1
  97. package/dist/index.js.map +1 -1
  98. package/dist/model/cardBadges.d.ts +52 -3
  99. package/dist/model/cardBadges.d.ts.map +1 -1
  100. package/dist/model/cardBadges.js +74 -12
  101. package/dist/model/cardBadges.js.map +1 -1
  102. package/dist/model/mineSource.d.ts +9 -3
  103. package/dist/model/mineSource.d.ts.map +1 -1
  104. package/dist/model/mineSource.js.map +1 -1
  105. package/dist/model/status.d.ts +79 -15
  106. package/dist/model/status.d.ts.map +1 -1
  107. package/dist/model/status.js +86 -17
  108. package/dist/model/status.js.map +1 -1
  109. package/llms.txt +8 -3
  110. package/manifest.json +96 -4
  111. package/nav-manifest.json +1 -1
  112. package/package.json +10 -10
  113. package/src/analytics/generated/events.json +1 -1
  114. package/src/api/generated/schema.ts +22 -12
  115. package/src/default/CardBadges.tsx +3 -1
  116. package/src/default/ListingActions.tsx +196 -0
  117. package/src/default/ListingCard.tsx +79 -43
  118. package/src/default/ListingDetailPane.tsx +520 -89
  119. package/src/default/ListingFeedCard.tsx +2 -0
  120. package/src/default/ListingPhoto.tsx +28 -0
  121. package/src/default/ListingSerpCard.tsx +39 -12
  122. package/src/default/MyListingsPane.tsx +81 -44
  123. package/src/default/ShareAction.tsx +260 -0
  124. package/src/default/actionRow.ts +131 -0
  125. package/src/default/cardGallery.ts +31 -0
  126. package/src/default/detailGallery.ts +97 -0
  127. package/src/default/favorite.tsx +68 -1
  128. package/src/default/icons.tsx +60 -0
  129. package/src/default/index.ts +37 -1
  130. package/src/default/movableCluster.tsx +137 -0
  131. package/src/default/notice.ts +68 -0
  132. package/src/headless/MyListings.tsx +130 -39
  133. package/src/headless/Share.tsx +278 -0
  134. package/src/i18n/es.ts +28 -0
  135. package/src/i18n/generated/errors.es.gen.ts +1 -1
  136. package/src/i18n/generated/errors.gen.ts +9 -0
  137. package/src/i18n/generated/errors.json +31 -0
  138. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  139. package/src/i18n/keys.ts +63 -0
  140. package/src/i18n/ru.ts +28 -0
  141. package/src/index.ts +22 -0
  142. package/src/model/cardBadges.ts +124 -12
  143. package/src/model/mineSource.ts +9 -3
  144. 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 `my_counters` counts in no tab at all), and the transition mirror;
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 narrowed with
272
- `?status=` to the SERVER's own groupings, so a tab's rows and its
273
- `my/counters` badge always describe the same set. `MyListingsSource` is still
274
- a seam for a deployment that keeps its rows elsewhere.
275
-
276
- **The takedown is not in a tab.** `blocked` is counted by `my/counters` in no
277
- tab at all, so folding it into one would make a badge and its rows disagree,
278
- and leaving it out would hide the one listing whose owner most needs to know.
279
- It is fetched beside the tabs and rendered above them.
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 Listings CRUD plus owner lifecycle actions and favorites.
439
+ * @description One integer per cabinet tab, over the caller's own listings.
440
440
  *
441
- * Read and write live in one class, so the guest wall
442
- * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
443
- * is applied per ACTION and never as a class permission: an anonymous
444
- * session must keep browsing and keep its favorites, and only the
445
- * authorship actions ``create``, ``update``/``partial_update``,
446
- * ``save-draft``, ``publish`` are the ones that turn a caller into a
447
- * seller.
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 three numbers. ``list`` is
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
- /** @description Listing counts by tab for the current user. */
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: {