@stapel/listings-react 0.1.0 → 0.3.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 +194 -0
- package/LICENSE +21 -0
- package/MODULE.md +97 -3
- package/README.md +67 -2
- package/dist/api/types.d.ts +1 -1
- package/dist/api/types.js +1 -1
- package/dist/default/FavoritesPane.d.ts +19 -4
- package/dist/default/FavoritesPane.d.ts.map +1 -1
- package/dist/default/FavoritesPane.js +13 -3
- package/dist/default/FavoritesPane.js.map +1 -1
- package/dist/default/ListingCard.d.ts +54 -11
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +32 -5
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingComposerPage.d.ts +59 -4
- package/dist/default/ListingComposerPage.d.ts.map +1 -1
- package/dist/default/ListingComposerPage.js +12 -3
- package/dist/default/ListingComposerPage.js.map +1 -1
- package/dist/default/SignInLink.d.ts +28 -0
- package/dist/default/SignInLink.d.ts.map +1 -0
- package/dist/default/SignInLink.js +17 -0
- package/dist/default/SignInLink.js.map +1 -0
- package/dist/default/index.d.ts +5 -3
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -0
- package/dist/default/index.js.map +1 -1
- package/dist/headless/ListingComposer.d.ts +17 -0
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +26 -10
- package/dist/headless/ListingComposer.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +1 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +2 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +3 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +2 -1
- package/dist/i18n/ru.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +2 -1
- package/nav-manifest.json +1 -1
- package/package.json +16 -16
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FavoritesPane.tsx +40 -11
- package/src/default/ListingCard.tsx +144 -27
- package/src/default/ListingComposerPage.tsx +79 -14
- package/src/default/SignInLink.tsx +54 -0
- package/src/default/index.ts +15 -3
- package/src/headless/ListingComposer.tsx +43 -10
- package/src/i18n/es.ts +1 -0
- package/src/i18n/keys.ts +3 -0
- package/src/i18n/ru.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1 +1,195 @@
|
|
|
1
1
|
# @stapel/listings-react
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 88a8be4: The composer's category seam runs in both directions, so `/new` can be mounted
|
|
8
|
+
|
|
9
|
+
A seam that only goes one way is not a seam. `ListingComposerPage.categorySlot`
|
|
10
|
+
was a `ReactNode`, and the composer's category moves only through
|
|
11
|
+
`bag.setCategory` — which a node handed in from outside cannot reach. There was
|
|
12
|
+
no `onCategoryChange` either, so a container could neither set the category nor
|
|
13
|
+
learn it, and `features` — the schema OF the chosen category, the entire reason
|
|
14
|
+
the slot exists — was unreachable rather than withheld. The screen rendered and
|
|
15
|
+
could not be used; the storefront named it a gap instead of shipping it (Wave
|
|
16
|
+
D, G-1).
|
|
17
|
+
|
|
18
|
+
Two ways in, and `categorySlot` keeps rendering (deprecated, nothing breaks):
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
<ListingComposerPage
|
|
22
|
+
category={categoryId === null ? "" : String(categoryId)}
|
|
23
|
+
onCategoryChange={(id) => setCategoryId(id === "" ? null : Number(id))}
|
|
24
|
+
renderCategoryPicker={({ value, setCategory }) => (
|
|
25
|
+
<CategoryPickerField
|
|
26
|
+
value={value === "" ? null : Number(value)}
|
|
27
|
+
onChange={(id) => setCategory(id === null ? "" : String(id))}
|
|
28
|
+
/>
|
|
29
|
+
)}
|
|
30
|
+
features={features.data ?? []}
|
|
31
|
+
featuresLoading={features.isPending}
|
|
32
|
+
featuresError={features.error ?? undefined}
|
|
33
|
+
/>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`renderCategoryPicker({ value, setCategory })` is the render-prop shape
|
|
37
|
+
`<CategoryPage renderListings>` already uses in the sibling pair. `category` /
|
|
38
|
+
`onCategoryChange` make the hook controlled on that one field, for the
|
|
39
|
+
container that holds the id anyway — it must, because
|
|
40
|
+
`useCategoryFeatures(id)` is keyed by it. `onCategoryChange` fires either way:
|
|
41
|
+
it is the wire the schema read is asked for on.
|
|
42
|
+
|
|
43
|
+
`useListingComposer` takes the same two options, so a host with its own skin
|
|
44
|
+
gets the same seam.
|
|
45
|
+
|
|
46
|
+
The README's example is now the wiring that works, and
|
|
47
|
+
`test/composerCategorySeam.test.tsx` gates it against the props declaration —
|
|
48
|
+
this pair documented `<MediaGalleryField bag={…}>` for a whole release while
|
|
49
|
+
the package had no such prop, and nothing in the suite could tell.
|
|
50
|
+
|
|
51
|
+
- 9230f5f: `<ListingCard>`: one click, one navigation — and a `<Link>` it can be handed
|
|
52
|
+
|
|
53
|
+
`href` and `onOpen` were two optional props and the card rendered BOTH when
|
|
54
|
+
both were given: the handler ran, the container routed, and the browser then
|
|
55
|
+
followed the anchor still sitting on the button. Two navigations for one click.
|
|
56
|
+
The storefront's workaround was `onOpen` alone, which cost the most linkable
|
|
57
|
+
element in the app its anchor — no middle-click, no "open in new tab", nothing
|
|
58
|
+
for a crawler to follow (Wave D, G-2).
|
|
59
|
+
|
|
60
|
+
`ListingCardOpenProps` is now a union with three arms and no fourth:
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
<ListingCard listing={row} href={`/l/${row.id}`} /> // an anchor
|
|
64
|
+
<ListingCard listing={row} href={`/l/${row.id}`} linkComponent={Link} /> // the host's <Link>
|
|
65
|
+
<ListingCard listing={row} onOpen={(id) => navigate(`/l/${id}`)} /> // a button
|
|
66
|
+
<ListingCard listing={row} /> // no open control
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Passing `href` and `onOpen` together no longer typechecks, and neither does a
|
|
70
|
+
`linkComponent` on the callback arm — `linkComponent` IS the link.
|
|
71
|
+
|
|
72
|
+
`linkComponent` is `@stapel/core`'s `LinkComponent`, a component taking a plain
|
|
73
|
+
`href`, so this pair stays router-agnostic and a container keeps a real anchor
|
|
74
|
+
while the click stays inside the SPA:
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
const RouterLink: LinkComponent = ({ href, children, ...rest }) => (
|
|
78
|
+
<Link to={href} {...rest}>
|
|
79
|
+
{children}
|
|
80
|
+
</Link>
|
|
81
|
+
);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`<FavoritesPane>` takes the same union one level up (`hrefFor` / `onOpen` /
|
|
85
|
+
`linkComponent`), so a pane cannot re-introduce upstream what the card refuses.
|
|
86
|
+
|
|
87
|
+
Breaking only for a caller that passed both props — which is the defect this
|
|
88
|
+
release removes, and which had no correct behaviour to preserve.
|
|
89
|
+
|
|
90
|
+
- 3e2e2a3: A blocked control now carries the door, not just the reason: `signIn`
|
|
91
|
+
|
|
92
|
+
`actionBlocked` ended the grey-rectangle incident by making every switched-off
|
|
93
|
+
control state its reason. It did not end the next one. "Sign in to save this",
|
|
94
|
+
"sign in to leave a review", "sign in to message the seller" are reasons whose
|
|
95
|
+
next action is a LINK, and no pair took one — so the storefront had to put its
|
|
96
|
+
own notice a screen away from each of the three controls it was about, and
|
|
97
|
+
named it a gap rather than shipping it (Wave D, G-3).
|
|
98
|
+
|
|
99
|
+
All three now take the same prop, core's `SignInCta`:
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
<ListingCard listing={row} signIn={{ href: `/login?next=${here}` }} />
|
|
103
|
+
<ReviewsPanel target={target} signIn={{ href: `/login?next=${here}` }} />
|
|
104
|
+
<StartChatButton sellerId={sellerId} signIn={{ onSignIn: () => openModal() }} />
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`{href}` **or** `{onSignIn}`, never both. Omit it and the reason renders alone,
|
|
108
|
+
with no trailing whitespace where the link is not — a host with no sign-in
|
|
109
|
+
route is a supported host.
|
|
110
|
+
|
|
111
|
+
Two more things each pair had to fix to make the door reachable:
|
|
112
|
+
|
|
113
|
+
- **listings**: the favourite's reason lived only in a `title` on a DISABLED
|
|
114
|
+
button, which receives no pointer events in any browser — core's own
|
|
115
|
+
`actionGate.ts` calls that "a reason nobody can read". It is now text beside
|
|
116
|
+
the heart (`listings-card-favorite-blocked`), with the link inside it. The
|
|
117
|
+
heart is still never hidden from a visitor.
|
|
118
|
+
- **chat**: `StartDirectChat` had no mandate gate at all, so a visitor could
|
|
119
|
+
press "message the seller" and collect a 401 — a refusal delivered at the one
|
|
120
|
+
moment it is useless. The axis is now the first arm of its `firstBlock`, read
|
|
121
|
+
through core's `MandateSource` seam. `member` may write; `guest`/`anonymous`
|
|
122
|
+
are told to sign in; `asking` says we are still asking. `unavailable` stays
|
|
123
|
+
AVAILABLE on purpose: that is what core answers outside a `<MandateProvider>`
|
|
124
|
+
too, and a host that never wired the axis must not lose its button — "we
|
|
125
|
+
could not ask" is not "you may not". This raises chat-react's `@stapel/core`
|
|
126
|
+
floor to `>=0.15.0`, where `useMandate`/`matchMandate` shipped.
|
|
127
|
+
|
|
128
|
+
The link's LABEL is each pair's own (`listings.card.sign_in`,
|
|
129
|
+
`reviews.form.sign_in`, `chat.start.sign_in`), in all three locales — core
|
|
130
|
+
floors `en` and `ru`, and these pairs also ship `es`.
|
|
131
|
+
|
|
132
|
+
## 0.2.0
|
|
133
|
+
|
|
134
|
+
### Minor Changes
|
|
135
|
+
|
|
136
|
+
- fcc9f1e: Two axes, both on screen — the storefront's long pole
|
|
137
|
+
|
|
138
|
+
`@stapel/listings-react` is the pair for stapel-listings: the listing page, the
|
|
139
|
+
submission flow, the seller's dashboard, and favourites.
|
|
140
|
+
|
|
141
|
+
The module has two independent state axes and 0.5.0 made them genuinely
|
|
142
|
+
diverge. `status` decides whether anyone can see a listing and nothing else
|
|
143
|
+
does; `moderation_status` decides nothing about that. Editing a LIVE listing
|
|
144
|
+
keeps `status: published` and moves only the moderation axis, so "published,
|
|
145
|
+
and we are reviewing your changes" is a real state — one a dashboard that
|
|
146
|
+
derived either field from the other could not say, because it would either
|
|
147
|
+
hide a listing buyers are reading or never tell its owner their edit is being
|
|
148
|
+
screened. `model/status.ts` produces both halves of the sentence from both
|
|
149
|
+
fields, once; the 9 × 4 table is asserted.
|
|
150
|
+
|
|
151
|
+
A publish refusal is per-field and arrives in an unusual envelope: an invalid
|
|
152
|
+
draft comes back as a BARE `ValidationBatchResult`, while a promotion that
|
|
153
|
+
fails afterwards comes back as the ordinary one. `publishRefusal` branches on
|
|
154
|
+
the body rather than the status, and `featureErrorsBySlug` adds the `field`
|
|
155
|
+
the fleet's routing convention reads — so "this box is wrong" never degrades
|
|
156
|
+
into "something is wrong".
|
|
157
|
+
|
|
158
|
+
Three contracts meet on the composer and none of them is an import. L2 pairs do
|
|
159
|
+
not import each other, so the gallery arrives as a two-member structural bag
|
|
160
|
+
(`@stapel/cdn-react`'s `refs` IS `images_draft`; its `settled` is the submit
|
|
161
|
+
gate), the category schema as a plain `FeatureDef[]`, and a stored CDN
|
|
162
|
+
reference through a host-supplied resolver — because no contract in this fleet
|
|
163
|
+
resolves a stranger's reference, and inventing `${cdnBase}/${ref}` would be
|
|
164
|
+
writing a contract nobody agreed to. `@stapel/attributes-react` is a real
|
|
165
|
+
dependency; it is L0, and it owns the editors, the mirror and the formatter.
|
|
166
|
+
|
|
167
|
+
Four things the pair says out loud rather than papering over:
|
|
168
|
+
|
|
169
|
+
- **there is no owner-scoped list endpoint.** `GET /listings/` answers
|
|
170
|
+
`published()` and takes no owner parameter, so a seller's drafts are
|
|
171
|
+
unreachable. The counters are real and are shown; the rows come from an
|
|
172
|
+
injected source, and with none the dashboard reports a NAMED failure instead
|
|
173
|
+
of an empty grid;
|
|
174
|
+
- **no read returns the `*_draft` twin**, so an abandoned draft reopens empty
|
|
175
|
+
and the composer says so. Editing a live listing is unaffected — the
|
|
176
|
+
published half IS the listing;
|
|
177
|
+
- **`PUT`/`PATCH` skip the ownership check** that every other owner operation
|
|
178
|
+
in the module performs, so they are absent from `ListingsApi`;
|
|
179
|
+
`save-draft` does the same write with the check;
|
|
180
|
+
- **`GET /{pk}/` has no `published()` filter**, so a draft answers 200 to
|
|
181
|
+
anyone with the id. The pane reports it instead of dressing a draft up as a
|
|
182
|
+
shop page.
|
|
183
|
+
|
|
184
|
+
Also here: the card another pair renders (badges formatted from the stored DAO
|
|
185
|
+
projection, so a grid of forty costs one query and no category read); a soft
|
|
186
|
+
delete that reads as "this listing was removed" rather than as a typo, using
|
|
187
|
+
the AllowAny status probe that still answers for it; favourites (owner verdict
|
|
188
|
+
F7) with the heart blocked-and-explained for a visitor rather than hidden; and
|
|
189
|
+
ru/es carrying the UI copy, not only the error keys, because the storefront is
|
|
190
|
+
ru-first and a half-translated submission form is visible immediately.
|
|
191
|
+
|
|
192
|
+
Generated against stapel-listings **0.6.1** — the release that fixed
|
|
193
|
+
`FeatureDto`/`FeatureDao`'s `discriminator.mapping` from one bogus `"null"`
|
|
194
|
+
entry to the ten type slugs, which is what makes the generated union usable as
|
|
195
|
+
the wire type at all.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stapel contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/MODULE.md
CHANGED
|
@@ -137,13 +137,107 @@ never import each other (the monorepo README states the direction). So:
|
|
|
137
137
|
by `useUploadQueue()`'s bag. `bag.refs` IS the value of `images_draft` (same
|
|
138
138
|
order, first tile the cover) and `bag.settled` is the submit gate. That pair
|
|
139
139
|
wrote its bag to this contract on purpose (its §13.6 note 9);
|
|
140
|
-
- **the category schema** is a plain `readonly FeatureDef[]`, plus
|
|
141
|
-
`
|
|
140
|
+
- **the category schema** is a plain `readonly FeatureDef[]`, plus
|
|
141
|
+
`renderCategoryPicker` for the chooser (see below);
|
|
142
142
|
- **a stored image reference** is resolved by a host-supplied
|
|
143
|
-
`ListingImageResolver
|
|
143
|
+
`ListingImageResolver`;
|
|
144
|
+
- **navigation** is core's `LinkComponent` — a component taking a plain `href`.
|
|
145
|
+
Not react-router: there are several routers and a library that picks one
|
|
146
|
+
picks it for every host.
|
|
144
147
|
|
|
145
148
|
`@stapel/attributes-react` IS a dependency; it is L0, like `@stapel/image`.
|
|
146
149
|
|
|
150
|
+
### 4.0 How a card opens is one contract, and the type enforces it
|
|
151
|
+
|
|
152
|
+
`href` and `onOpen` were two optional props, and a card given both navigated
|
|
153
|
+
TWICE for one click: the handler ran, the container routed, and the browser
|
|
154
|
+
then followed the anchor still sitting on the button. The storefront worked
|
|
155
|
+
around it by passing `onOpen` alone — which cost the most linkable element in
|
|
156
|
+
the app its anchor (no middle-click, no "open in new tab", nothing for a
|
|
157
|
+
crawler) and was named as gap G-2 rather than shipped as a preference.
|
|
158
|
+
|
|
159
|
+
`ListingCardOpenProps` is now a three-armed union — `{href, linkComponent?}`,
|
|
160
|
+
`{onOpen}`, or neither — so exactly one navigation mechanism reaches the DOM
|
|
161
|
+
and "both" does not typecheck. `linkComponent` rides on the link arm because
|
|
162
|
+
it IS the link; handing one to a callback card would be two answers to one
|
|
163
|
+
question again. `<FavoritesPane>` takes the same union one level up
|
|
164
|
+
(`hrefFor` / `onOpen` / `linkComponent`), so a pane cannot re-introduce
|
|
165
|
+
upstream what the card refuses.
|
|
166
|
+
|
|
167
|
+
`test/cardNavigation.test.tsx` asserts on the DOM, not on the props: an anchor
|
|
168
|
+
and no handler, a button and no `href`, a `linkComponent` and no `<a href>` at
|
|
169
|
+
all, plus two `@ts-expect-error` cases for the combinations that used to
|
|
170
|
+
compile.
|
|
171
|
+
|
|
172
|
+
### 4.1 The category seam has to run in both directions
|
|
173
|
+
|
|
174
|
+
A seam that only goes one way is not a seam. Until 0.3.0 the chooser arrived
|
|
175
|
+
as `categorySlot: ReactNode` and the composer's category moved only through
|
|
176
|
+
`bag.setCategory` — which a node handed in from outside cannot reach. There
|
|
177
|
+
was no `onCategoryChange` either, so the container could neither set the
|
|
178
|
+
category nor learn it, and `features` — the schema OF the chosen category,
|
|
179
|
+
which is the entire reason the slot exists — was unreachable rather than
|
|
180
|
+
withheld. The screen could not be mounted at all (storefront Wave D, G-1).
|
|
181
|
+
|
|
182
|
+
Two ways in, both shipping in 0.3.0:
|
|
183
|
+
|
|
184
|
+
- `renderCategoryPicker({ value, setCategory })` — the render-prop shape
|
|
185
|
+
`<CategoryPage renderListings>` already uses in the sibling pair;
|
|
186
|
+
- `category` / `onCategoryChange` — controlled, for the container that holds
|
|
187
|
+
the id anyway.
|
|
188
|
+
|
|
189
|
+
`categorySlot` still renders (nothing that passed it breaks) and is
|
|
190
|
+
deprecated.
|
|
191
|
+
|
|
192
|
+
**The features wiring, end to end, and it is the container's:**
|
|
193
|
+
|
|
194
|
+
```tsx
|
|
195
|
+
const [categoryId, setCategoryId] = useState<number | null>(null);
|
|
196
|
+
const features = useCategoryFeatures(categoryId); // @stapel/categories-react
|
|
197
|
+
|
|
198
|
+
<ListingComposerPage
|
|
199
|
+
category={categoryId === null ? "" : String(categoryId)}
|
|
200
|
+
onCategoryChange={(id) => setCategoryId(id === "" ? null : Number(id))}
|
|
201
|
+
renderCategoryPicker={({ value, setCategory }) => (
|
|
202
|
+
<CategoryPickerField
|
|
203
|
+
value={value === "" ? null : Number(value)}
|
|
204
|
+
onChange={(id) => setCategory(id === null ? "" : String(id))}
|
|
205
|
+
/>
|
|
206
|
+
)}
|
|
207
|
+
features={features.data ?? []}
|
|
208
|
+
featuresLoading={features.isPending}
|
|
209
|
+
featuresError={features.error ?? undefined}
|
|
210
|
+
/>
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`useCategoryFeatures` returns `UseQueryResult<readonly CategoryFeature[]>` and
|
|
214
|
+
`CategoryFeature` IS `FeatureDef` (categories-react re-exports the L0 type
|
|
215
|
+
rather than mirroring it), so nothing converts. The three props travel
|
|
216
|
+
together on purpose: `features={[]}` alone would say "this category asks
|
|
217
|
+
nothing", which during the read is a lie the publish gate must not repeat.
|
|
218
|
+
|
|
219
|
+
Category ids cross this seam as STRINGS because that is what
|
|
220
|
+
`Listing.category` carries on the wire; the picker speaks numbers, and the two
|
|
221
|
+
lines above are the whole conversion — done once, in the container, where both
|
|
222
|
+
halves are visible.
|
|
223
|
+
|
|
224
|
+
### 4.2 A stated reason needs a next action
|
|
225
|
+
|
|
226
|
+
`actionBlocked` ended the grey-rectangle incident: every switched-off control
|
|
227
|
+
states its reason. It did not end the next one. "Sign in to save this" is a
|
|
228
|
+
reason whose next action is a LINK, and no pair took one — so the storefront
|
|
229
|
+
put its own notice a screen away from the three controls it was about (Wave D,
|
|
230
|
+
G-3), and the card's reason lived only in a `title` on a disabled button, which
|
|
231
|
+
receives no pointer events in any browser.
|
|
232
|
+
|
|
233
|
+
Both halves are closed here: the reason renders as text beside the heart, and
|
|
234
|
+
`signIn` — core's `SignInCta`, `{href}` or `{onSignIn}` and never both — is
|
|
235
|
+
rendered inside the same element. `<StartChatButton>` and `<ReviewFormCard>`
|
|
236
|
+
take the identical prop, so a container writes the destination once.
|
|
237
|
+
|
|
238
|
+
The LABEL is this pair's (`listings.card.sign_in`, all three locales) rather
|
|
239
|
+
than core's: core floors `en` and `ru`, and this pair also ships `es`.
|
|
240
|
+
|
|
147
241
|
## 5. Gates and their reasons
|
|
148
242
|
|
|
149
243
|
Every write in the pair is behind an `ActionAvailability`. The mandate axis is
|
package/README.md
CHANGED
|
@@ -68,6 +68,45 @@ container is the seam:
|
|
|
68
68
|
that carries each type's display config beside its value. A grid of forty
|
|
69
69
|
cards therefore costs one query and **no category read**.
|
|
70
70
|
|
|
71
|
+
### How a card opens: one of three, never two
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
<ListingCard listing={row} href={`/l/${row.id}`} /> // an anchor
|
|
75
|
+
<ListingCard listing={row} href={`/l/${row.id}`} linkComponent={Link} /> // the host's <Link>
|
|
76
|
+
<ListingCard listing={row} onOpen={(id) => navigate(`/l/${id}`)} /> // a button
|
|
77
|
+
<ListingCard listing={row} /> // no open control
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`href` and `onOpen` are arms of a union, not two optional props, because a card
|
|
81
|
+
that had both navigated **twice** for one click — the handler ran and the
|
|
82
|
+
browser then followed the anchor. Passing both is now a type error.
|
|
83
|
+
|
|
84
|
+
`linkComponent` is `@stapel/core`'s `LinkComponent`: a component taking a plain
|
|
85
|
+
`href`, so the pair stays router-agnostic and a container keeps a real anchor
|
|
86
|
+
(middle-click, "open in new tab", a crawler) while the click stays inside the
|
|
87
|
+
SPA:
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
const RouterLink: LinkComponent = ({ href, children, ...rest }) => (
|
|
91
|
+
<Link to={href} {...rest}>{children}</Link>
|
|
92
|
+
);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`<FavoritesPane>` takes the same union (`hrefFor` / `onOpen` / `linkComponent`),
|
|
96
|
+
so a pane cannot re-introduce upstream what the card no longer allows.
|
|
97
|
+
|
|
98
|
+
### The heart a visitor can see, read, and act on
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
<ListingCard listing={row} href={`/l/${row.id}`} signIn={{ href: `/login?next=${here}` }} />
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The favourite control is never hidden from a visitor — it is switched off, the
|
|
105
|
+
reason is printed as TEXT beside it (a tooltip on a disabled button is a reason
|
|
106
|
+
nobody can read), and `signIn` is the door. `SignInCta` is core's, `{href}`
|
|
107
|
+
**or** `{onSignIn}`, the same prop `@stapel/chat-react` and
|
|
108
|
+
`@stapel/reviews-react` take. Omit it and the reason renders alone.
|
|
109
|
+
|
|
71
110
|
## Submitting a listing
|
|
72
111
|
|
|
73
112
|
Four contracts meet on the composer, and three of them arrive as seams rather
|
|
@@ -80,17 +119,43 @@ than dependencies:
|
|
|
80
119
|
| the value editors | `@stapel/attributes-react` | L0, so a direct dependency |
|
|
81
120
|
|
|
82
121
|
```tsx
|
|
122
|
+
const [categoryId, setCategoryId] = useState<number | null>(null);
|
|
83
123
|
const gallery = useUploadQueue({ max: 10 }); // @stapel/cdn-react
|
|
84
124
|
const features = useCategoryFeatures(categoryId); // @stapel/categories-react
|
|
85
125
|
|
|
86
126
|
<ListingComposerPage
|
|
87
|
-
features={features}
|
|
127
|
+
features={features.data ?? []}
|
|
128
|
+
featuresLoading={features.isPending}
|
|
129
|
+
featuresError={features.error ?? undefined}
|
|
88
130
|
images={gallery}
|
|
89
|
-
|
|
131
|
+
category={categoryId === null ? "" : String(categoryId)}
|
|
132
|
+
onCategoryChange={(id) => setCategoryId(id === "" ? null : Number(id))}
|
|
133
|
+
renderCategoryPicker={({ value, setCategory }) => (
|
|
134
|
+
<CategoryPickerField
|
|
135
|
+
value={value === "" ? null : Number(value)}
|
|
136
|
+
onChange={(id) => setCategory(id === null ? "" : String(id))}
|
|
137
|
+
/>
|
|
138
|
+
)}
|
|
90
139
|
gallerySlot={<MediaGalleryField bag={gallery} />}
|
|
91
140
|
/>
|
|
92
141
|
```
|
|
93
142
|
|
|
143
|
+
The category is a **render prop**, not a node, because the composer's category
|
|
144
|
+
moves only through `setCategory` — a node rendered into a slot cannot reach it,
|
|
145
|
+
and a picker that cannot report what was chosen means `features` (the whole
|
|
146
|
+
point of the slot) is never read. `category` / `onCategoryChange` are there
|
|
147
|
+
because the container holds the id anyway: `useCategoryFeatures(id)` is keyed
|
|
148
|
+
by it.
|
|
149
|
+
|
|
150
|
+
The gallery is a node, but the BAG is the same object the composer got — that
|
|
151
|
+
is what makes `bag.refs` the value of `images_draft` and `bag.settled` the
|
|
152
|
+
publish gate. Two queues means the gate talks about photos it cannot see.
|
|
153
|
+
|
|
154
|
+
The schema arrives as three props, not one, so an empty list is never mistaken
|
|
155
|
+
for "this category asks nothing": `featuresLoading` and `featuresError` each
|
|
156
|
+
block the publish with their own sentence, and only a settled, empty schema
|
|
157
|
+
prints "no extra details for this category".
|
|
158
|
+
|
|
94
159
|
The flow is `create draft → save into it → publish`. The composer always saves
|
|
95
160
|
before it publishes, because `publish` promotes the STORED draft: publishing
|
|
96
161
|
without saving would promote whatever was there before the last keystroke.
|
package/dist/api/types.d.ts
CHANGED
|
@@ -146,7 +146,7 @@ export interface ListingPageParams {
|
|
|
146
146
|
/**
|
|
147
147
|
* The currency a composer starts a new listing in.
|
|
148
148
|
*
|
|
149
|
-
* RUB by owner verdict F6 (
|
|
149
|
+
* RUB by owner verdict F6 (storefront spec, the owner's
|
|
150
150
|
* fork verdicts of 2026-08-22), and a default rather than a constant: `currency` is
|
|
151
151
|
* a free `maxLength: 8` string on the wire (stapel-listings has no currency
|
|
152
152
|
* enum — the vocabulary lives in stapel-currencies), so a deployment that
|
package/dist/api/types.js
CHANGED
|
@@ -27,7 +27,7 @@ export const MODERATION_STATUSES = [
|
|
|
27
27
|
/**
|
|
28
28
|
* The currency a composer starts a new listing in.
|
|
29
29
|
*
|
|
30
|
-
* RUB by owner verdict F6 (
|
|
30
|
+
* RUB by owner verdict F6 (storefront spec, the owner's
|
|
31
31
|
* fork verdicts of 2026-08-22), and a default rather than a constant: `currency` is
|
|
32
32
|
* a free `maxLength: 8` string on the wire (stapel-listings has no currency
|
|
33
33
|
* enum — the vocabulary lives in stapel-currencies), so a deployment that
|
|
@@ -8,11 +8,26 @@
|
|
|
8
8
|
* the substitution that cost the fleet an incident (spec §7.4).
|
|
9
9
|
*/
|
|
10
10
|
import type { ReactElement } from "react";
|
|
11
|
+
import type { LinkComponent } from "@stapel/core";
|
|
11
12
|
import type { ThemeModeProp } from "./types.js";
|
|
12
|
-
|
|
13
|
+
/** How a card in this grid opens — the same one-contract union `<ListingCard>`
|
|
14
|
+
* takes, one level up, so a pane cannot re-introduce the double navigation the
|
|
15
|
+
* card no longer allows. */
|
|
16
|
+
export type FavoritesPaneOpenProps = {
|
|
13
17
|
/** Where a card leads. The container owns routing. */
|
|
14
|
-
readonly hrefFor
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
readonly hrefFor: (id: number) => string;
|
|
19
|
+
/** The host's `<Link>`, so a click stays inside the SPA. */
|
|
20
|
+
readonly linkComponent?: LinkComponent;
|
|
21
|
+
readonly onOpen?: undefined;
|
|
22
|
+
} | {
|
|
23
|
+
readonly onOpen: (id: number) => void;
|
|
24
|
+
readonly hrefFor?: undefined;
|
|
25
|
+
readonly linkComponent?: undefined;
|
|
26
|
+
} | {
|
|
27
|
+
readonly hrefFor?: undefined;
|
|
28
|
+
readonly onOpen?: undefined;
|
|
29
|
+
readonly linkComponent?: undefined;
|
|
30
|
+
};
|
|
31
|
+
export type FavoritesPaneProps = ThemeModeProp & FavoritesPaneOpenProps;
|
|
17
32
|
export declare function FavoritesPane(props: FavoritesPaneProps): ReactElement;
|
|
18
33
|
//# sourceMappingURL=FavoritesPane.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FavoritesPane.d.ts","sourceRoot":"","sources":["../../src/default/FavoritesPane.tsx"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"FavoritesPane.d.ts","sourceRoot":"","sources":["../../src/default/FavoritesPane.tsx"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AAG1C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAOlD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD;;4BAE4B;AAC5B,MAAM,MAAM,sBAAsB,GAC9B;IACE,sDAAsD;IACtD,QAAQ,CAAC,OAAO,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,CAAC;IACzC,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;IACvC,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;CAC7B,GACD;IACE,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,CAAC;IAC7B,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,CAAC;CACpC,GACD;IACE,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,CAAC;IAC7B,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAC5B,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,CAAC;CACpC,CAAC;AAEN,MAAM,MAAM,kBAAkB,GAAG,aAAa,GAAG,sBAAsB,CAAC;AAiBxE,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,YAAY,CA2FrE"}
|
|
@@ -6,6 +6,18 @@ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
|
6
6
|
import { ErrorAlert } from "./ErrorAlert.js";
|
|
7
7
|
import { ListingCard } from "./ListingCard.js";
|
|
8
8
|
import { ListingsSkinTheme } from "./theme.js";
|
|
9
|
+
/** The card's own open props for one row. One arm, never two. */
|
|
10
|
+
function cardOpenProps(props, id) {
|
|
11
|
+
if (props.hrefFor !== undefined) {
|
|
12
|
+
const href = props.hrefFor(id);
|
|
13
|
+
return props.linkComponent !== undefined
|
|
14
|
+
? { href, linkComponent: props.linkComponent }
|
|
15
|
+
: { href };
|
|
16
|
+
}
|
|
17
|
+
if (props.onOpen !== undefined)
|
|
18
|
+
return { onOpen: props.onOpen };
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
9
21
|
export function FavoritesPane(props) {
|
|
10
22
|
const t = useT();
|
|
11
23
|
const describe = useDescribeFlowError();
|
|
@@ -20,9 +32,7 @@ export function FavoritesPane(props) {
|
|
|
20
32
|
language: undefined,
|
|
21
33
|
}), action: _jsx(Button, { size: "small", "data-analytics": "none", "data-analytics-reason": "retrying a read the person already asked for; not a business action", onClick: bag.refetch, children: t(LISTINGS_I18N_KEYS.mineRetry) }) })),
|
|
22
34
|
empty: () => (_jsx(Empty, { "data-testid": "listings-favorites-empty", description: t(LISTINGS_I18N_KEYS.favoritesEmpty) })),
|
|
23
|
-
ready: (rows) => (_jsx(Flex, { wrap: true, gap: 16, "data-testid": "listings-favorites-grid", children: rows.map((row) => (_jsx("div", { style: { width: 240 }, children: _jsx(ListingCard, { listing: row, ...(props.
|
|
24
|
-
? { href: props.hrefFor(row.id) }
|
|
25
|
-
: {}), ...(props.onOpen !== undefined ? { onOpen: props.onOpen } : {}) }) }, row.id))) })),
|
|
35
|
+
ready: (rows) => (_jsx(Flex, { wrap: true, gap: 16, "data-testid": "listings-favorites-grid", children: rows.map((row) => (_jsx("div", { style: { width: 240 }, children: _jsx(ListingCard, { listing: row, ...cardOpenProps(props, row.id) }) }, row.id))) })),
|
|
26
36
|
}), _jsxs(Space, { children: [_jsx(Button, { size: "small", disabled: !bag.prevPage.available, "data-testid": "listings-favorites-prev", "data-analytics": "none", "data-analytics-reason": "paging a list the person is already reading; not a business action", onClick: bag.goPrev, children: t(LISTINGS_I18N_KEYS.pagePrev) }), _jsx(Button, { size: "small", disabled: !bag.nextPage.available, "data-testid": "listings-favorites-next", "data-analytics": "none", "data-analytics-reason": "paging a list the person is already reading; not a business action", onClick: bag.goNext, children: t(LISTINGS_I18N_KEYS.pageNext) })] })] }) }));
|
|
27
37
|
}
|
|
28
38
|
//# sourceMappingURL=FavoritesPane.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FavoritesPane.js","sourceRoot":"","sources":["../../src/default/FavoritesPane.tsx"],"names":[],"mappings":";AAUA,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAC3E,OAAO,EAAE,SAAS,EAAE,oBAAoB,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"FavoritesPane.js","sourceRoot":"","sources":["../../src/default/FavoritesPane.tsx"],"names":[],"mappings":"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"}
|
|
@@ -4,7 +4,15 @@
|
|
|
4
4
|
* `@stapel/search-react` takes a `renderCard` slot and the container fills it
|
|
5
5
|
* with this (spec §3.7 / §6.2 item 1). The two pairs never import each other;
|
|
6
6
|
* the CONTAINER is the seam, which is why this component takes a plain card
|
|
7
|
-
* row and a plain `href
|
|
7
|
+
* row and a plain `href` rather than reaching for a router.
|
|
8
|
+
*
|
|
9
|
+
* ── One click, one navigation ──────────────────────────────────────────────
|
|
10
|
+
*
|
|
11
|
+
* `href` and `onOpen` used to be two optional props, and a card given both
|
|
12
|
+
* navigated TWICE: the handler ran, and the browser then followed the anchor
|
|
13
|
+
* that was still on the button. They are now three arms of a union — link,
|
|
14
|
+
* button, or neither — and `linkComponent` rides on the link arm so a
|
|
15
|
+
* container can hand in its router's `<Link>` and keep the anchor.
|
|
8
16
|
*
|
|
9
17
|
* ── What it renders without asking the server anything else ────────────────
|
|
10
18
|
*
|
|
@@ -16,21 +24,55 @@
|
|
|
16
24
|
*
|
|
17
25
|
* ── The heart is never hidden ──────────────────────────────────────────────
|
|
18
26
|
*
|
|
19
|
-
* A visitor sees it, blocked, with the reason
|
|
20
|
-
* container
|
|
21
|
-
* favourites exist (private-space canon
|
|
27
|
+
* A visitor sees it, blocked, with the reason IN WORDS beside it and the
|
|
28
|
+
* sign-in link the container supplies (`signIn`, typically `?next=<current>`).
|
|
29
|
+
* Hiding it would teach nobody that favourites exist (private-space canon
|
|
30
|
+
* §6.3, spec §6.2 item 6) — and until 0.3.0 the reason lived only in a tooltip
|
|
31
|
+
* on a disabled button, which receives no pointer events in any browser: a
|
|
32
|
+
* reason nobody could read, and no door to walk through.
|
|
22
33
|
*/
|
|
23
34
|
import type { ReactElement, ReactNode } from "react";
|
|
35
|
+
import type { LinkComponent, SignInCtaProp } from "@stapel/core";
|
|
24
36
|
import type { ListingCard as ListingCardData } from "../api/types.js";
|
|
25
37
|
import type { ThemeModeProp } from "./types.js";
|
|
26
|
-
|
|
38
|
+
/**
|
|
39
|
+
* How the card opens — ONE of three, and the type says so.
|
|
40
|
+
*
|
|
41
|
+
* It used to be two optional props, and a caller that passed both got two
|
|
42
|
+
* navigations for one click: the handler ran and the browser then followed the
|
|
43
|
+
* anchor anyway. The storefront worked around it by passing `onOpen` only,
|
|
44
|
+
* which cost it a real anchor (no middle-click, no "open in new tab", nothing
|
|
45
|
+
* for a crawler to follow) on the most linkable element in the app.
|
|
46
|
+
*
|
|
47
|
+
* So the union has three arms and no fourth: a link, a button, or neither.
|
|
48
|
+
* `linkComponent` belongs to the link arm because it IS the link — handing a
|
|
49
|
+
* `<Link>` to a card that navigates by callback would be two answers to one
|
|
50
|
+
* question again.
|
|
51
|
+
*/
|
|
52
|
+
export type ListingCardOpenProps = {
|
|
53
|
+
/** Where the card leads. A plain path — the pair never calls
|
|
54
|
+
* `window.location` and never builds a router descriptor. */
|
|
55
|
+
readonly href: string;
|
|
56
|
+
/** The host's `<Link>`, so the click stays inside the SPA. Absent: an
|
|
57
|
+
* antd link-button carrying the `href`, which reloads the page in a
|
|
58
|
+
* router app — correct, just not fast. */
|
|
59
|
+
readonly linkComponent?: LinkComponent;
|
|
60
|
+
readonly onOpen?: undefined;
|
|
61
|
+
} | {
|
|
62
|
+
/** The card opens by callback: rendered as a button, with no `href` for
|
|
63
|
+
* the browser to follow after the handler has already navigated. */
|
|
64
|
+
readonly onOpen: (id: number) => void;
|
|
65
|
+
readonly href?: undefined;
|
|
66
|
+
readonly linkComponent?: undefined;
|
|
67
|
+
} | {
|
|
68
|
+
/** No open control at all — a card inside a screen that is already the
|
|
69
|
+
* listing. */
|
|
70
|
+
readonly href?: undefined;
|
|
71
|
+
readonly onOpen?: undefined;
|
|
72
|
+
readonly linkComponent?: undefined;
|
|
73
|
+
};
|
|
74
|
+
export interface ListingCardBaseProps extends ThemeModeProp, SignInCtaProp {
|
|
27
75
|
readonly listing: ListingCardData;
|
|
28
|
-
/** Where the card leads. The container owns routing, so this is a plain
|
|
29
|
-
* href a `<Link>` or an `<a>` can carry — the pair never calls
|
|
30
|
-
* `window.location`. */
|
|
31
|
-
readonly href?: string;
|
|
32
|
-
/** Called instead of following `href`, for a host with its own navigation. */
|
|
33
|
-
readonly onOpen?: (id: number) => void;
|
|
34
76
|
/** Extra chrome the container adds (a `promoted` tag from search, say —
|
|
35
77
|
* DSA Art. 26 marking belongs to the pair that receives it). */
|
|
36
78
|
readonly badge?: ReactNode;
|
|
@@ -38,5 +80,6 @@ export interface ListingCardProps extends ThemeModeProp {
|
|
|
38
80
|
* sense (the owner's own dashboard). NOT a way to hide it from visitors. */
|
|
39
81
|
readonly showFavorite?: boolean;
|
|
40
82
|
}
|
|
83
|
+
export type ListingCardProps = ListingCardBaseProps & ListingCardOpenProps;
|
|
41
84
|
export declare function ListingCard(props: ListingCardProps): ReactElement;
|
|
42
85
|
//# sourceMappingURL=ListingCard.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ListingCard.d.ts","sourceRoot":"","sources":["../../src/default/ListingCard.tsx"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"ListingCard.d.ts","sourceRoot":"","sources":["../../src/default/ListingCard.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAGrD,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAEjE,OAAO,KAAK,EAAE,WAAW,IAAI,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAStE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,oBAAoB,GAC5B;IACE;iEAC6D;IAC7D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;8CAE0C;IAC1C,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;IACvC,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;CAC7B,GACD;IACE;wEACoE;IACpE,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,CAAC;CACpC,GACD;IACE;kBACc;IACd,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAC5B,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,CAAC;CACpC,CAAC;AAEN,MAAM,WAAW,oBAAqB,SAAQ,aAAa,EAAE,aAAa;IACxE,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC;oEACgE;IAChE,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,CAAC;IAC3B;gFAC4E;IAC5E,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC;CACjC;AAED,MAAM,MAAM,gBAAgB,GAAG,oBAAoB,GAAG,oBAAoB,CAAC;AAmE3E,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,GAAG,YAAY,CAgIjE"}
|