@stapel/reviews-react 0.1.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 +1 -0
- package/MODULE.md +151 -0
- package/README.md +207 -0
- package/dist/api/generated/schema.d.ts +360 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/reviewsApi.d.ts +89 -0
- package/dist/api/reviewsApi.d.ts.map +1 -0
- package/dist/api/reviewsApi.js +51 -0
- package/dist/api/reviewsApi.js.map +1 -0
- package/dist/api/types.d.ts +127 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +2 -0
- package/dist/api/types.js.map +1 -0
- package/dist/default/ErrorAlert.d.ts +9 -0
- package/dist/default/ErrorAlert.d.ts.map +1 -0
- package/dist/default/ErrorAlert.js +27 -0
- package/dist/default/ErrorAlert.js.map +1 -0
- package/dist/default/RatingBadge.d.ts +17 -0
- package/dist/default/RatingBadge.d.ts.map +1 -0
- package/dist/default/RatingBadge.js +23 -0
- package/dist/default/RatingBadge.js.map +1 -0
- package/dist/default/ReviewFormCard.d.ts +25 -0
- package/dist/default/ReviewFormCard.d.ts.map +1 -0
- package/dist/default/ReviewFormCard.js +42 -0
- package/dist/default/ReviewFormCard.js.map +1 -0
- package/dist/default/ReviewListPanel.d.ts +29 -0
- package/dist/default/ReviewListPanel.d.ts.map +1 -0
- package/dist/default/ReviewListPanel.js +47 -0
- package/dist/default/ReviewListPanel.js.map +1 -0
- package/dist/default/ReviewsPanel.d.ts +33 -0
- package/dist/default/ReviewsPanel.d.ts.map +1 -0
- package/dist/default/ReviewsPanel.js +17 -0
- package/dist/default/ReviewsPanel.js.map +1 -0
- package/dist/default/index.d.ts +31 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +25 -0
- package/dist/default/index.js.map +1 -0
- package/dist/default/theme.d.ts +24 -0
- package/dist/default/theme.d.ts.map +1 -0
- package/dist/default/theme.js +39 -0
- package/dist/default/theme.js.map +1 -0
- package/dist/default/types.d.ts +12 -0
- package/dist/default/types.d.ts.map +1 -0
- package/dist/default/types.js +2 -0
- package/dist/default/types.js.map +1 -0
- package/dist/headless/ReviewAggregate.d.ts +63 -0
- package/dist/headless/ReviewAggregate.d.ts.map +1 -0
- package/dist/headless/ReviewAggregate.js +55 -0
- package/dist/headless/ReviewAggregate.js.map +1 -0
- package/dist/headless/ReviewForm.d.ts +83 -0
- package/dist/headless/ReviewForm.d.ts.map +1 -0
- package/dist/headless/ReviewForm.js +75 -0
- package/dist/headless/ReviewForm.js.map +1 -0
- package/dist/headless/ReviewList.d.ts +47 -0
- package/dist/headless/ReviewList.d.ts.map +1 -0
- package/dist/headless/ReviewList.js +51 -0
- package/dist/headless/ReviewList.js.map +1 -0
- package/dist/headless/ReviewsProvider.d.ts +19 -0
- package/dist/headless/ReviewsProvider.d.ts.map +1 -0
- package/dist/headless/ReviewsProvider.js +15 -0
- package/dist/headless/ReviewsProvider.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +12 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +22 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +19 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +66 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +58 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +341 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +174 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +58 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +59 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +115 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +22 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +70 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +98 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +92 -0
- package/dist/index.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +16 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/list.d.ts +52 -0
- package/dist/model/list.d.ts.map +1 -0
- package/dist/model/list.js +60 -0
- package/dist/model/list.js.map +1 -0
- package/dist/model/mutations.d.ts +32 -0
- package/dist/model/mutations.d.ts.map +1 -0
- package/dist/model/mutations.js +44 -0
- package/dist/model/mutations.js.map +1 -0
- package/dist/model/queries.d.ts +65 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +80 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +27 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +14 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/rating.d.ts +57 -0
- package/dist/model/rating.d.ts.map +1 -0
- package/dist/model/rating.js +47 -0
- package/dist/model/rating.js.map +1 -0
- package/dist/model/refusals.d.ts +51 -0
- package/dist/model/refusals.d.ts.map +1 -0
- package/dist/model/refusals.js +103 -0
- package/dist/model/refusals.js.map +1 -0
- package/dist/model/runtime.d.ts +44 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +15 -0
- package/dist/model/runtime.js.map +1 -0
- package/llms.txt +80 -0
- package/manifest.json +577 -0
- package/package.json +115 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/generated/schema.ts +362 -0
- package/src/api/reviewsApi.ts +170 -0
- package/src/api/types.ts +140 -0
- package/src/default/ErrorAlert.tsx +46 -0
- package/src/default/RatingBadge.tsx +81 -0
- package/src/default/ReviewFormCard.tsx +139 -0
- package/src/default/ReviewListPanel.tsx +204 -0
- package/src/default/ReviewsPanel.tsx +74 -0
- package/src/default/index.ts +30 -0
- package/src/default/theme.tsx +66 -0
- package/src/default/types.ts +12 -0
- package/src/headless/ReviewAggregate.tsx +94 -0
- package/src/headless/ReviewForm.tsx +163 -0
- package/src/headless/ReviewList.tsx +99 -0
- package/src/headless/ReviewsProvider.tsx +20 -0
- package/src/i18n/errorsMap.ts +33 -0
- package/src/i18n/es.ts +81 -0
- package/src/i18n/generated/errors.es.gen.ts +64 -0
- package/src/i18n/generated/errors.gen.ts +205 -0
- package/src/i18n/generated/errors.json +387 -0
- package/src/i18n/generated/errors.ru.gen.ts +64 -0
- package/src/i18n/keys.ts +133 -0
- package/src/i18n/ru.ts +85 -0
- package/src/index.ts +161 -0
- package/src/model/context.tsx +28 -0
- package/src/model/list.ts +74 -0
- package/src/model/mutations.ts +62 -0
- package/src/model/queries.ts +122 -0
- package/src/model/queryKeys.ts +45 -0
- package/src/model/rating.ts +101 -0
- package/src/model/refusals.ts +114 -0
- package/src/model/runtime.ts +61 -0
- package/tsconfig.json +26 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# @stapel/reviews-react
|
package/MODULE.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# @stapel/reviews-react — module guide
|
|
2
|
+
|
|
3
|
+
Pairs with **stapel-reviews 0.3.0** (`>=0.3 <0.4`), 4 paths under
|
|
4
|
+
`/reviews/api/v1/`. Contract sources: the module's own
|
|
5
|
+
`docs/{schema,errors,flows}.json`, pinned in `contract-pins.json` and
|
|
6
|
+
regenerated under `pnpm gen:check`.
|
|
7
|
+
|
|
8
|
+
## Layers
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
src/api/ reviewsApi.ts the three operations a browser may call;
|
|
12
|
+
the one home of path strings
|
|
13
|
+
types.ts wire aliases + the two remaining corrections
|
|
14
|
+
generated/ openapi-typescript, drift-gated
|
|
15
|
+
src/model/ runtime · context · queryKeys · queries · mutations
|
|
16
|
+
rating.ts the aggregate, READ (the zero that is not a rating)
|
|
17
|
+
list.ts pure readers over loaded rows
|
|
18
|
+
refusals.ts the refusal vocabulary, keyed by CODE
|
|
19
|
+
src/headless/ ReviewsProvider · ReviewList · ReviewAggregate · ReviewForm
|
|
20
|
+
src/default/ the antd skin, `./default` subpath
|
|
21
|
+
src/i18n/ keys · ru · es · errorsMap + generated/
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`rating.ts`, `list.ts` and `refusals.ts` are pure: no React, no fetch, no
|
|
25
|
+
storage. That is what lets the whole "what does this number mean" question be
|
|
26
|
+
tested without a DOM, and what lets an SSR render call them directly.
|
|
27
|
+
|
|
28
|
+
There is **no `flows/` layer** (`docs/flows.json` is `[]`, the module annotates
|
|
29
|
+
no `@flow_step`) and **no `nav/` layer** (this pair owns no route — see below).
|
|
30
|
+
|
|
31
|
+
## What 0.3.0 changed here
|
|
32
|
+
|
|
33
|
+
This pair was built against 0.2.2 and carried two workarounds for it. **Both
|
|
34
|
+
are deleted**, because the release answered both asks:
|
|
35
|
+
|
|
36
|
+
| Was | Now |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `ReviewPage`, `ReviewListParams`, `ReviewAnchorDirection` hand-declared in `api/types.ts` | projections of `components/ReviewPage` and the generated `reviews_api_v1_reviews_retrieve` query type |
|
|
39
|
+
| `signInRequired` on the list bag, the aggregate bag and the form bag; two sign-in arms in the skin; `reviews.list.sign_in_required` + `reviews.rating.sign_in_required` | `signInRequired` on the **form bag only**; one sign-in key (`reviews.form.sign_in_required`) |
|
|
40
|
+
|
|
41
|
+
The permission change is `IsAuthenticatedOrReadOnly` on the list class
|
|
42
|
+
(mirroring stapel-listings' `ListingViewSet`: GET open, POST still needs an
|
|
43
|
+
author) and `AllowAny` on the aggregate, both with
|
|
44
|
+
`stapel_anonymous_access = ANONYMOUS_ALLOWED` and both throttled from
|
|
45
|
+
`STAPEL_REVIEWS` (`LIST_THROTTLE` 120/min, `AGGREGATE_THROTTLE` 300/min) rather
|
|
46
|
+
than the project's `DEFAULT_THROTTLE_RATES`. Nothing new became visible to a
|
|
47
|
+
guest: both endpoints were already published-only for a non-moderator.
|
|
48
|
+
|
|
49
|
+
## The three contract facts this package still absorbs
|
|
50
|
+
|
|
51
|
+
### 1. The list body is the pagination envelope — now declared
|
|
52
|
+
|
|
53
|
+
`ReviewListCreateView` is a plain `APIView` that instantiates
|
|
54
|
+
`ReviewAnchorPagination` inside `get()` rather than declaring a
|
|
55
|
+
`pagination_class`, so drf-spectacular's pagination introspection never runs.
|
|
56
|
+
0.3.0 works around its own limitation the right way — a hand-written
|
|
57
|
+
`ReviewPageSerializer` plus explicit `OpenApiParameter`s — so the CONTRACT now
|
|
58
|
+
tells the truth and this package types the envelope from codegen.
|
|
59
|
+
|
|
60
|
+
Two properties of the paging are load-bearing:
|
|
61
|
+
|
|
62
|
+
- the anchor is a **`created_at` ISO timestamp**, because
|
|
63
|
+
`anchor_field = "created_at"` and `ordering = "-created_at"`;
|
|
64
|
+
- `has_next` is the authority. The paginator leaves `next_anchor` `null` on the
|
|
65
|
+
last page, so a cursor rebuilt from the last row would re-request it forever.
|
|
66
|
+
|
|
67
|
+
### 2. `avg` is `0.0` when `count` is `0`
|
|
68
|
+
|
|
69
|
+
`services.aggregate` returns exactly that, and `AggregateResponse` documents
|
|
70
|
+
it. `ratingSummary()` is the single reader, and its type has no `avg` on the
|
|
71
|
+
unrated branch — a skin cannot reach a number to draw there even by accident.
|
|
72
|
+
`starBreakdown(rounded, max)` then splits a real average into full/half/empty
|
|
73
|
+
counts against the DEPLOYMENT's ceiling, not a hardcoded five.
|
|
74
|
+
|
|
75
|
+
### 3. The duplicate refusal is a 400, and the 409 is about something else
|
|
76
|
+
|
|
77
|
+
| Code | Status | Means |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `error.400.reviews_duplicate_review` | 400 | this author already reviewed this target |
|
|
80
|
+
| `error.409.reviews_already_responded` | 409 | the review already has the **owner's reply** |
|
|
81
|
+
|
|
82
|
+
`model/refusals.ts` holds a predicate per refusal, each comparing a
|
|
83
|
+
`FlowError.code` folded through core's `toFlowError`. `test/refusals.test.ts`
|
|
84
|
+
asserts both directions, so the trap is documented in the suite.
|
|
85
|
+
|
|
86
|
+
Note also that the duplicate only exists where the target type sets
|
|
87
|
+
`one_per_author: true` — the registry default is `False`
|
|
88
|
+
(`registry.resolve_policy`), and the shop preset turns it on for `listing`. No
|
|
89
|
+
endpoint reports the policy, so the client cannot pre-compute the rule; it
|
|
90
|
+
handles the answer.
|
|
91
|
+
|
|
92
|
+
### 4. The reads are anonymous — and the session gate matters MORE for it
|
|
93
|
+
|
|
94
|
+
The hooks are still gated on `useActiveSessionReady()`, and the reason got
|
|
95
|
+
stronger with the permission change rather than weaker. What the server returns
|
|
96
|
+
depends on who is asking: a moderator of the target gets pending and hidden
|
|
97
|
+
rows for `include=all`, everyone else is narrowed to published *silently*. A
|
|
98
|
+
read that raced a bootstrapping session used to produce a visible 401; now it
|
|
99
|
+
would SUCCEED as a guest and cache that answer under a key that does not
|
|
100
|
+
mention identity. `useActiveSessionReady()` answers `true` the instant the
|
|
101
|
+
session settles into any of authenticated / anonymous / unauthenticated, and
|
|
102
|
+
immediately when no session-owning module is mounted — so a purely public
|
|
103
|
+
storefront waits for nothing.
|
|
104
|
+
|
|
105
|
+
## The target is two strings, and the pair invents neither
|
|
106
|
+
|
|
107
|
+
`target_type` is a key the host registered in `STAPEL_REVIEWS["TARGET_TYPES"]`
|
|
108
|
+
(built-ins: `{}`); `target_key` is an opaque host string the module stores and
|
|
109
|
+
groups by but never parses. Both are in every query key, because `target_key`
|
|
110
|
+
alone is not an identity — two registries can key different things with the
|
|
111
|
+
same string.
|
|
112
|
+
|
|
113
|
+
This package exports no target-type constants. `test/pair.test.ts` asserts the
|
|
114
|
+
absence.
|
|
115
|
+
|
|
116
|
+
## The seller roll-up
|
|
117
|
+
|
|
118
|
+
Product model (storefront spec fork F5): a review targets the SELLER for a
|
|
119
|
+
specific listing — `target_type: "listing"`, `unique(author, listing)` — and
|
|
120
|
+
the seller's rating is a roll-up across their listings. stapel-reviews cannot
|
|
121
|
+
compute it; the composite does, as `shop.listing_review_summary`, whose
|
|
122
|
+
`read()` answers `{avg, count}` in both local and remote mode *using the
|
|
123
|
+
owner's field names on purpose*.
|
|
124
|
+
|
|
125
|
+
`<ReviewAggregate target={…} aggregate={…}>` renders those two numbers with no
|
|
126
|
+
request. Known gap: no HTTP route publishes the roll-up today, so the host's
|
|
127
|
+
own backend must serve it.
|
|
128
|
+
|
|
129
|
+
## Not on the surface
|
|
130
|
+
|
|
131
|
+
| Operation | Why not |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `POST /reviews/{id}/moderate` | `can_moderate` is fail-closed; a moderator console, which this pair is not |
|
|
134
|
+
| `POST /reviews/{id}/response` | same gate; the reply is DISPLAYED in the MVP and the write is a seller console (spec §4.4) |
|
|
135
|
+
|
|
136
|
+
Both remain in `manifest.json`, which lists the whole contract. Adding either
|
|
137
|
+
later is additive.
|
|
138
|
+
|
|
139
|
+
## Tests
|
|
140
|
+
|
|
141
|
+
| File | What it pins |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `rating.test.ts` | the zero that is not a rating, in every arm; the projection shape reads the same |
|
|
144
|
+
| `refusals.test.ts` | 400 duplicate vs 409 already-responded; the write's 401 vs 403; a non-API fault |
|
|
145
|
+
| `list.test.tsx` | the envelope, the `created_at` cursor, no page past `has_next`, the anonymous read and its reachable empty state |
|
|
146
|
+
| `submit.test.tsx` | the request body, the duplicate as a named state, the 401 the write still answers, `pending` after submit, the bounds |
|
|
147
|
+
| `skin.test.tsx` | no star row for an unrated target, the moderated badges, a guest seeing rows with no sign-in wall, "sign in to leave a review" on a 401 POST, one list request for the composed panel |
|
|
148
|
+
| `i18n.test.ts` | every registry code and every UI key resolves in en/ru/es |
|
|
149
|
+
| `pair.test.ts` | query keys, the surface's absences (moderate/respond/nav/target constants), the logout hook |
|
|
150
|
+
| `demos.test.tsx` | every demo variant renders |
|
|
151
|
+
| `prodBundlePurity.test.ts` | no showcase/demo code in the tarball |
|
package/README.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# @stapel/reviews-react
|
|
2
|
+
|
|
3
|
+
The frontend pair for **stapel-reviews**: ratings and reviews of an opaque
|
|
4
|
+
`(target_type, target_key)` — a listing, a seller, a course, whatever the host
|
|
5
|
+
registered — rendered without any of the four lies the contract makes easy.
|
|
6
|
+
|
|
7
|
+
Business + state in the main entry, zero visual opinion; the antd skin lives
|
|
8
|
+
behind `./default`. Built on `@stapel/core` (typed client + `StapelApiError`
|
|
9
|
+
envelope, `LoadState`, `ActionAvailability`, i18n engine, TanStack Query).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
pnpm add @stapel/reviews-react @stapel/core @tanstack/react-query react
|
|
15
|
+
# for the default skin:
|
|
16
|
+
pnpm add antd @stapel/tokens-antd
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## A review block, in eight lines
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import { createReviewsRuntime, ReviewsProvider } from "@stapel/reviews-react";
|
|
23
|
+
import { ReviewsPanel } from "@stapel/reviews-react/default";
|
|
24
|
+
|
|
25
|
+
const runtime = createReviewsRuntime({ baseUrl: "/reviews/api/v1" });
|
|
26
|
+
|
|
27
|
+
export function ListingReviews({ listingId, me }) {
|
|
28
|
+
return (
|
|
29
|
+
<ReviewsProvider runtime={runtime}>
|
|
30
|
+
<ReviewsPanel
|
|
31
|
+
target={{ targetType: "listing", targetKey: listingId }}
|
|
32
|
+
viewerId={me?.id}
|
|
33
|
+
/>
|
|
34
|
+
</ReviewsProvider>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`"listing"` is **your** registry key, not this library's. stapel-reviews ships
|
|
40
|
+
an empty `TARGET_TYPES` registry and knows nothing about listings; the shop
|
|
41
|
+
composite registers that name in `stapel_shop/preset.py`. This package
|
|
42
|
+
therefore exports no target-type constants at all — a guessed one would be
|
|
43
|
+
wrong for every deployment but one.
|
|
44
|
+
|
|
45
|
+
## Four things this package refuses to get wrong
|
|
46
|
+
|
|
47
|
+
### 1. A zero average is not a zero rating
|
|
48
|
+
|
|
49
|
+
`GET /reviews/aggregate` answers `{"avg": 0.0, "count": 0}` for a target nobody
|
|
50
|
+
has rated — the module's own schema says so in the field description. Rendered
|
|
51
|
+
straight into a star row that is **the worst possible score**, printed over a
|
|
52
|
+
brand-new listing.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const summary = ratingSummary(aggregate);
|
|
56
|
+
summary.rated; // false — and there is no `avg` on this branch of the type
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`<RatingBadge>` renders the "no reviews yet" sentence in that arm and never
|
|
60
|
+
reaches antd's `<Rate>`. Same class of defect as `data ?? []`.
|
|
61
|
+
|
|
62
|
+
### 2. "You have already reviewed this" is a **400**
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
error.400.reviews_duplicate_review ← the duplicate
|
|
66
|
+
error.409.reviews_already_responded ← the module's only 409, about the seller's REPLY
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A form branching on `status === 409` misses the first and mishandles the
|
|
70
|
+
second. `isDuplicateReview(error)` reads the code, and the form turns it into
|
|
71
|
+
"you have already rated this" rather than a red banner. This one is still a
|
|
72
|
+
live trap in 0.3.0 — it is a documented shape, not a defect the release fixed.
|
|
73
|
+
|
|
74
|
+
The optimistic pre-check (`findOwnReview` over the loaded rows) exists, and its
|
|
75
|
+
hole is documented rather than papered over: the list is published-only, so
|
|
76
|
+
under pre-moderation the author's own pending review is invisible **to its
|
|
77
|
+
author**, the form offers itself again, and the server is the one that says no.
|
|
78
|
+
|
|
79
|
+
### 3. A guest reads the reviews; only the write asks them to sign in
|
|
80
|
+
|
|
81
|
+
Both reads are anonymous since **stapel-reviews 0.3.0**:
|
|
82
|
+
`ReviewListCreateView` is `IsAuthenticatedOrReadOnly` (GET open, POST still
|
|
83
|
+
needs an author to attribute the review to) and `AggregateView` is `AllowAny`,
|
|
84
|
+
both throttled from the module's own settings (`LIST_THROTTLE` 120/min,
|
|
85
|
+
`AGGREGATE_THROTTLE` 300/min). Nothing new became visible — both endpoints were
|
|
86
|
+
already published-only for a non-moderator.
|
|
87
|
+
|
|
88
|
+
So `signInRequired` exists on **one** bag, the form's, where a 401 is still the
|
|
89
|
+
honest answer. The read bags have no such state, and an empty list now means
|
|
90
|
+
what it says to everybody: nobody has reviewed this target.
|
|
91
|
+
|
|
92
|
+
> Against the 0.2.2 contract this pair carried the opposite: every endpoint was
|
|
93
|
+
> `IsAuthenticated`, a visitor got 401 for the list *and* the aggregate, and
|
|
94
|
+
> both read bags had to name that so the empty state would not tell a
|
|
95
|
+
> not-yet-registered visitor that a well-reviewed seller has never been
|
|
96
|
+
> reviewed. The ask went upstream instead of being worked around, and 0.3.0
|
|
97
|
+
> answered it.
|
|
98
|
+
|
|
99
|
+
### 4. A review that is not published says so
|
|
100
|
+
|
|
101
|
+
`status` reaches the screen: `pending` and `hidden` rows (visible only to a
|
|
102
|
+
moderator who asked for `include=all`) carry a badge, and a status this build
|
|
103
|
+
does not know is **named** rather than rendered as an ordinary review. After a
|
|
104
|
+
submit, the created row's status decides the sentence — a pre-moderating
|
|
105
|
+
deployment tells the author their review will appear once checked, instead of
|
|
106
|
+
leaving them to hunt for it.
|
|
107
|
+
|
|
108
|
+
## The list body, and where its type comes from
|
|
109
|
+
|
|
110
|
+
`GET /reviews` answers core's `AnchorPagination` envelope:
|
|
111
|
+
|
|
112
|
+
```jsonc
|
|
113
|
+
{ "items": [...], "next_anchor": "2026-08-19T10:00:00Z", "prev_anchor": null,
|
|
114
|
+
"has_next": true, "has_prev": false, "count": 20 }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
It always did — but `ReviewListCreateView` is a plain `APIView` that
|
|
118
|
+
instantiates its paginator inside `get()` instead of declaring a
|
|
119
|
+
`pagination_class`, so drf-spectacular's introspection never ran and the schema
|
|
120
|
+
declared `200: ReviewResponse[]`; the `anchor` / `limit` / `direction`
|
|
121
|
+
parameters were invisible for the same reason. **0.3.0 declares both**
|
|
122
|
+
(`components/ReviewPage`, and `direction` with an enum), so the copies this
|
|
123
|
+
package maintained in `src/api/types.ts` are **deleted** and `ReviewPage`,
|
|
124
|
+
`ReviewListParams` and `ReviewAnchorDirection` are all projections of the
|
|
125
|
+
generated schema.
|
|
126
|
+
|
|
127
|
+
Anchors are `created_at` timestamps (`anchor_field = "created_at"`,
|
|
128
|
+
`ordering = "-created_at"`), and paging stops on `has_next`, never on a
|
|
129
|
+
cursor rebuilt from the last row — the paginator leaves `next_anchor` `null`
|
|
130
|
+
on the last page.
|
|
131
|
+
|
|
132
|
+
## The seller rating is a display, not a fetch
|
|
133
|
+
|
|
134
|
+
The product model reviews the **seller for a specific listing**:
|
|
135
|
+
`target_type: "listing"`, one review per author per listing. A seller's own
|
|
136
|
+
rating is therefore a roll-up across every listing they own — which
|
|
137
|
+
stapel-reviews cannot compute (one `(target_type, target_key)` per call, and
|
|
138
|
+
`reviews.aggregates_by_keys` is a comm Function for server-side projections,
|
|
139
|
+
not an endpoint) and the shop composite can, as
|
|
140
|
+
`shop.listing_review_summary`.
|
|
141
|
+
|
|
142
|
+
So the pair renders the two numbers the composite produced:
|
|
143
|
+
|
|
144
|
+
```tsx
|
|
145
|
+
<RatingBadge
|
|
146
|
+
target={{ targetType: "seller", targetKey: sellerId }}
|
|
147
|
+
aggregate={rollup} // {avg, count} — the projection's own field names
|
|
148
|
+
/>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
No request is made. **Known gap**: today no HTTP route publishes that roll-up,
|
|
152
|
+
so a page that wants a seller rating must be served the two numbers by its own
|
|
153
|
+
backend. Publishing them is the composite's job, not this pair's, and inventing
|
|
154
|
+
an N+1 loop over the seller's listings here would be neither correct nor
|
|
155
|
+
affordable.
|
|
156
|
+
|
|
157
|
+
## What is deliberately not here
|
|
158
|
+
|
|
159
|
+
- **Moderation and the owner's reply.** `POST {id}/moderate` and
|
|
160
|
+
`POST {id}/response` are gated on the target type's **fail-closed**
|
|
161
|
+
`can_moderate` callback and belong to a moderator console and a seller
|
|
162
|
+
console. The reply is *displayed*; the button to write one does not exist,
|
|
163
|
+
rather than existing switched off. Both operations stay in `manifest.json`,
|
|
164
|
+
which lists the whole contract.
|
|
165
|
+
- **A nav manifest.** This pair has no route of its own — it renders inside the
|
|
166
|
+
listing detail page and the public seller profile. A "Reviews" menu item
|
|
167
|
+
leading nowhere would be worse than none (the `cdn-react` precedent), and a
|
|
168
|
+
test asserts the absence.
|
|
169
|
+
- **A `flows/` layer.** `docs/flows.json` is `[]`; the module annotates no
|
|
170
|
+
`@flow_step`.
|
|
171
|
+
|
|
172
|
+
## Rating bounds are the deployment's
|
|
173
|
+
|
|
174
|
+
`RATING_MIN` / `RATING_MAX` are `STAPEL_REVIEWS` settings (library defaults 1
|
|
175
|
+
and 5) and no endpoint reports them, so a host that moved them tells the
|
|
176
|
+
runtime:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
createReviewsRuntime({ baseUrl: "/reviews/api/v1", ratingBounds: { max: 10 } });
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The mirror is a UI affordance only — the star row draws that many stars — and
|
|
183
|
+
the server stays the authority (`error.400.reviews_invalid_rating`).
|
|
184
|
+
|
|
185
|
+
## i18n
|
|
186
|
+
|
|
187
|
+
English ships inline. `./i18n/ru` and `./i18n/es` are opt-in subpaths. There is
|
|
188
|
+
exactly one sign-in string left (`reviews.form.sign_in_required`) — the two
|
|
189
|
+
read-side ones went out with the 0.3.0 permission change. The 42
|
|
190
|
+
cross-cutting error keys come from stapel-core's catalogue through the
|
|
191
|
+
generated bundles; the **9 keys stapel-reviews owns are authored by this
|
|
192
|
+
package**, because the module ships no `translations/` directory (the
|
|
193
|
+
stapel-forms precedent). When upstream ships one, those nine lines are deleted
|
|
194
|
+
and nothing else moves.
|
|
195
|
+
|
|
196
|
+
## Surface
|
|
197
|
+
|
|
198
|
+
| Layer | Exports |
|
|
199
|
+
|---|---|
|
|
200
|
+
| api | `createReviewsApi`, `ReviewsApi`, `Review`, `ReviewPage`, `ReviewTarget`, `RatingAggregate`, `ReviewStatus`, … |
|
|
201
|
+
| model | `createReviewsRuntime`, `reviewsQueryKeys`, `useReviewList`, `useReviewAggregate`, `useSubmitReview`, `ratingSummary`, `starBreakdown`, `reviewsFromPages`, `findOwnReview`, `reviewVisibility`, `isDuplicateReview`, `isSignInRequired`, … |
|
|
202
|
+
| headless | `ReviewsProvider`, `ReviewList`, `ReviewAggregate`, `ReviewForm` |
|
|
203
|
+
| default | `ReviewsPanel`, `ReviewListPanel`, `ReviewFormCard`, `RatingBadge`, `ReviewsSkinTheme` |
|
|
204
|
+
| i18n | `REVIEWS_I18N_KEYS`, `registerReviewsI18n`, `REVIEWS_ERRORS`, `explainReviewsError` |
|
|
205
|
+
|
|
206
|
+
More detail: [`MODULE.md`](./MODULE.md), [`llms.txt`](./llms.txt),
|
|
207
|
+
[`manifest.json`](./manifest.json).
|