@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.
Files changed (164) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/MODULE.md +151 -0
  3. package/README.md +207 -0
  4. package/dist/api/generated/schema.d.ts +360 -0
  5. package/dist/api/generated/schema.d.ts.map +1 -0
  6. package/dist/api/generated/schema.js +2 -0
  7. package/dist/api/generated/schema.js.map +1 -0
  8. package/dist/api/reviewsApi.d.ts +89 -0
  9. package/dist/api/reviewsApi.d.ts.map +1 -0
  10. package/dist/api/reviewsApi.js +51 -0
  11. package/dist/api/reviewsApi.js.map +1 -0
  12. package/dist/api/types.d.ts +127 -0
  13. package/dist/api/types.d.ts.map +1 -0
  14. package/dist/api/types.js +2 -0
  15. package/dist/api/types.js.map +1 -0
  16. package/dist/default/ErrorAlert.d.ts +9 -0
  17. package/dist/default/ErrorAlert.d.ts.map +1 -0
  18. package/dist/default/ErrorAlert.js +27 -0
  19. package/dist/default/ErrorAlert.js.map +1 -0
  20. package/dist/default/RatingBadge.d.ts +17 -0
  21. package/dist/default/RatingBadge.d.ts.map +1 -0
  22. package/dist/default/RatingBadge.js +23 -0
  23. package/dist/default/RatingBadge.js.map +1 -0
  24. package/dist/default/ReviewFormCard.d.ts +25 -0
  25. package/dist/default/ReviewFormCard.d.ts.map +1 -0
  26. package/dist/default/ReviewFormCard.js +42 -0
  27. package/dist/default/ReviewFormCard.js.map +1 -0
  28. package/dist/default/ReviewListPanel.d.ts +29 -0
  29. package/dist/default/ReviewListPanel.d.ts.map +1 -0
  30. package/dist/default/ReviewListPanel.js +47 -0
  31. package/dist/default/ReviewListPanel.js.map +1 -0
  32. package/dist/default/ReviewsPanel.d.ts +33 -0
  33. package/dist/default/ReviewsPanel.d.ts.map +1 -0
  34. package/dist/default/ReviewsPanel.js +17 -0
  35. package/dist/default/ReviewsPanel.js.map +1 -0
  36. package/dist/default/index.d.ts +31 -0
  37. package/dist/default/index.d.ts.map +1 -0
  38. package/dist/default/index.js +25 -0
  39. package/dist/default/index.js.map +1 -0
  40. package/dist/default/theme.d.ts +24 -0
  41. package/dist/default/theme.d.ts.map +1 -0
  42. package/dist/default/theme.js +39 -0
  43. package/dist/default/theme.js.map +1 -0
  44. package/dist/default/types.d.ts +12 -0
  45. package/dist/default/types.d.ts.map +1 -0
  46. package/dist/default/types.js +2 -0
  47. package/dist/default/types.js.map +1 -0
  48. package/dist/headless/ReviewAggregate.d.ts +63 -0
  49. package/dist/headless/ReviewAggregate.d.ts.map +1 -0
  50. package/dist/headless/ReviewAggregate.js +55 -0
  51. package/dist/headless/ReviewAggregate.js.map +1 -0
  52. package/dist/headless/ReviewForm.d.ts +83 -0
  53. package/dist/headless/ReviewForm.d.ts.map +1 -0
  54. package/dist/headless/ReviewForm.js +75 -0
  55. package/dist/headless/ReviewForm.js.map +1 -0
  56. package/dist/headless/ReviewList.d.ts +47 -0
  57. package/dist/headless/ReviewList.d.ts.map +1 -0
  58. package/dist/headless/ReviewList.js +51 -0
  59. package/dist/headless/ReviewList.js.map +1 -0
  60. package/dist/headless/ReviewsProvider.d.ts +19 -0
  61. package/dist/headless/ReviewsProvider.d.ts.map +1 -0
  62. package/dist/headless/ReviewsProvider.js +15 -0
  63. package/dist/headless/ReviewsProvider.js.map +1 -0
  64. package/dist/i18n/errorsMap.d.ts +12 -0
  65. package/dist/i18n/errorsMap.d.ts.map +1 -0
  66. package/dist/i18n/errorsMap.js +22 -0
  67. package/dist/i18n/errorsMap.js.map +1 -0
  68. package/dist/i18n/es.d.ts +19 -0
  69. package/dist/i18n/es.d.ts.map +1 -0
  70. package/dist/i18n/es.js +66 -0
  71. package/dist/i18n/es.js.map +1 -0
  72. package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
  73. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  74. package/dist/i18n/generated/errors.es.gen.js +58 -0
  75. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  76. package/dist/i18n/generated/errors.gen.d.ts +341 -0
  77. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  78. package/dist/i18n/generated/errors.gen.js +174 -0
  79. package/dist/i18n/generated/errors.gen.js.map +1 -0
  80. package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
  81. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  82. package/dist/i18n/generated/errors.ru.gen.js +58 -0
  83. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  84. package/dist/i18n/keys.d.ts +59 -0
  85. package/dist/i18n/keys.d.ts.map +1 -0
  86. package/dist/i18n/keys.js +115 -0
  87. package/dist/i18n/keys.js.map +1 -0
  88. package/dist/i18n/ru.d.ts +22 -0
  89. package/dist/i18n/ru.d.ts.map +1 -0
  90. package/dist/i18n/ru.js +70 -0
  91. package/dist/i18n/ru.js.map +1 -0
  92. package/dist/index.d.ts +98 -0
  93. package/dist/index.d.ts.map +1 -0
  94. package/dist/index.js +92 -0
  95. package/dist/index.js.map +1 -0
  96. package/dist/model/context.d.ts +11 -0
  97. package/dist/model/context.d.ts.map +1 -0
  98. package/dist/model/context.js +16 -0
  99. package/dist/model/context.js.map +1 -0
  100. package/dist/model/list.d.ts +52 -0
  101. package/dist/model/list.d.ts.map +1 -0
  102. package/dist/model/list.js +60 -0
  103. package/dist/model/list.js.map +1 -0
  104. package/dist/model/mutations.d.ts +32 -0
  105. package/dist/model/mutations.d.ts.map +1 -0
  106. package/dist/model/mutations.js +44 -0
  107. package/dist/model/mutations.js.map +1 -0
  108. package/dist/model/queries.d.ts +65 -0
  109. package/dist/model/queries.d.ts.map +1 -0
  110. package/dist/model/queries.js +80 -0
  111. package/dist/model/queries.js.map +1 -0
  112. package/dist/model/queryKeys.d.ts +27 -0
  113. package/dist/model/queryKeys.d.ts.map +1 -0
  114. package/dist/model/queryKeys.js +14 -0
  115. package/dist/model/queryKeys.js.map +1 -0
  116. package/dist/model/rating.d.ts +57 -0
  117. package/dist/model/rating.d.ts.map +1 -0
  118. package/dist/model/rating.js +47 -0
  119. package/dist/model/rating.js.map +1 -0
  120. package/dist/model/refusals.d.ts +51 -0
  121. package/dist/model/refusals.d.ts.map +1 -0
  122. package/dist/model/refusals.js +103 -0
  123. package/dist/model/refusals.js.map +1 -0
  124. package/dist/model/runtime.d.ts +44 -0
  125. package/dist/model/runtime.d.ts.map +1 -0
  126. package/dist/model/runtime.js +15 -0
  127. package/dist/model/runtime.js.map +1 -0
  128. package/llms.txt +80 -0
  129. package/manifest.json +577 -0
  130. package/package.json +115 -0
  131. package/src/analytics/generated/events.json +7 -0
  132. package/src/api/generated/schema.ts +362 -0
  133. package/src/api/reviewsApi.ts +170 -0
  134. package/src/api/types.ts +140 -0
  135. package/src/default/ErrorAlert.tsx +46 -0
  136. package/src/default/RatingBadge.tsx +81 -0
  137. package/src/default/ReviewFormCard.tsx +139 -0
  138. package/src/default/ReviewListPanel.tsx +204 -0
  139. package/src/default/ReviewsPanel.tsx +74 -0
  140. package/src/default/index.ts +30 -0
  141. package/src/default/theme.tsx +66 -0
  142. package/src/default/types.ts +12 -0
  143. package/src/headless/ReviewAggregate.tsx +94 -0
  144. package/src/headless/ReviewForm.tsx +163 -0
  145. package/src/headless/ReviewList.tsx +99 -0
  146. package/src/headless/ReviewsProvider.tsx +20 -0
  147. package/src/i18n/errorsMap.ts +33 -0
  148. package/src/i18n/es.ts +81 -0
  149. package/src/i18n/generated/errors.es.gen.ts +64 -0
  150. package/src/i18n/generated/errors.gen.ts +205 -0
  151. package/src/i18n/generated/errors.json +387 -0
  152. package/src/i18n/generated/errors.ru.gen.ts +64 -0
  153. package/src/i18n/keys.ts +133 -0
  154. package/src/i18n/ru.ts +85 -0
  155. package/src/index.ts +161 -0
  156. package/src/model/context.tsx +28 -0
  157. package/src/model/list.ts +74 -0
  158. package/src/model/mutations.ts +62 -0
  159. package/src/model/queries.ts +122 -0
  160. package/src/model/queryKeys.ts +45 -0
  161. package/src/model/rating.ts +101 -0
  162. package/src/model/refusals.ts +114 -0
  163. package/src/model/runtime.ts +61 -0
  164. 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).