@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
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Flatten the pages of the infinite list into one newest-first run.
3
+ *
4
+ * Deliberately takes `InfiniteData | undefined` and answers `undefined` for
5
+ * "not loaded", never `[]`: an empty array here is a claim that the target
6
+ * has no reviews, and the caller must get that claim from a READY load state
7
+ * (`stapel/no-flattened-load-state` — the rule exists because a total outage
8
+ * once rendered as "you have no workspaces").
9
+ */
10
+ export function reviewsFromPages(data) {
11
+ if (data === undefined)
12
+ return undefined;
13
+ return data.pages.flatMap((page) => page.items);
14
+ }
15
+ /**
16
+ * A review's visibility, narrowed — with a fourth arm for a state this build
17
+ * does not know.
18
+ *
19
+ * `status` is a bare `string` on the wire. A build that met a new upstream
20
+ * state and silently rendered it as published would be showing something the
21
+ * server may have hidden; one that crashed on it would take the page down.
22
+ * `"unknown"` is neither: the skin shows the row with an explicit "this
23
+ * version does not know this state" badge, the same shape attributes-react
24
+ * uses for an unsupported feature type.
25
+ */
26
+ export function reviewVisibility(status) {
27
+ return status === "published" || status === "pending" || status === "hidden"
28
+ ? status
29
+ : "unknown";
30
+ }
31
+ /** Is this row one a moderator sees only because they asked for `include=all`? */
32
+ export function isModeratedOut(review) {
33
+ return reviewVisibility(review.status) !== "published";
34
+ }
35
+ /**
36
+ * The viewer's own review of this target, if it is in the loaded rows.
37
+ *
38
+ * THIS IS AN OPTIMISTIC PRE-CHECK, AND IT HAS A KNOWN HOLE — recorded here
39
+ * because the hole is in the contract, not in the code. The list a
40
+ * non-moderator reads is published-only, so under `moderation: "pre"` the
41
+ * author's own review is INVISIBLE TO ITS AUTHOR while it waits: this
42
+ * function answers `undefined`, the form offers itself again, and the server
43
+ * refuses the second attempt with `error.400.reviews_duplicate_review`.
44
+ *
45
+ * That is why the refusal is a first-class outcome of the form
46
+ * (`headless/ReviewForm.tsx`) rather than something the pre-check was
47
+ * supposed to prevent. The pre-check saves a pointless round trip in the
48
+ * common (post-moderation) case; it is never the authority.
49
+ *
50
+ * `viewerId` must be the id the backend puts in `author_id` — the user id,
51
+ * NOT a profile id. A host that passes the wrong one gets `undefined`, i.e.
52
+ * the form offered where it need not have been, and the server still holds
53
+ * the line.
54
+ */
55
+ export function findOwnReview(reviews, viewerId) {
56
+ if (reviews === undefined || !viewerId)
57
+ return undefined;
58
+ return reviews.find((review) => review.author_id === viewerId);
59
+ }
60
+ //# sourceMappingURL=list.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"list.js","sourceRoot":"","sources":["../../src/model/list.ts"],"names":[],"mappings":"AAOA;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAC9B,IAA8D;IAE9D,IAAI,IAAI,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IACzC,OAAO,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AAClD,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,gBAAgB,CAC9B,MAAc;IAEd,OAAO,MAAM,KAAK,WAAW,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,QAAQ;QAC1E,CAAC,CAAC,MAAM;QACR,CAAC,CAAC,SAAS,CAAC;AAChB,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,cAAc,CAAC,MAAc;IAC3C,OAAO,gBAAgB,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,WAAW,CAAC;AACzD,CAAC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,aAAa,CAC3B,OAAsC,EACtC,QAAmC;IAEnC,IAAI,OAAO,KAAK,SAAS,IAAI,CAAC,QAAQ;QAAE,OAAO,SAAS,CAAC;IACzD,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,SAAS,KAAK,QAAQ,CAAC,CAAC;AACjE,CAAC"}
@@ -0,0 +1,32 @@
1
+ import type { UseMutationResult } from "@tanstack/react-query";
2
+ import type { StapelApiError } from "@stapel/core";
3
+ import type { Review, ReviewTarget } from "../api/types.js";
4
+ /** What {@link useSubmitReview} is called with. */
5
+ export interface SubmitReviewVariables {
6
+ /** Inside the deployment's `[RATING_MIN, RATING_MAX]`; the server decides. */
7
+ readonly rating: number;
8
+ /** Optional — a rating with no words is a complete review here. */
9
+ readonly body?: string;
10
+ }
11
+ /**
12
+ * Write a review of a target (frontend-standard §2 — mutations invalidate on
13
+ * success).
14
+ *
15
+ * ── Why the answer is invalidated rather than spliced in ───────────────────
16
+ *
17
+ * The created row comes back with its `status`, and under the module's
18
+ * default (`MODERATION_DEFAULT: "post"`) that is `published` — so pushing it
19
+ * into the cached window would be correct. Under `moderation: "pre"` it is
20
+ * `pending`, and the SAME row must NOT appear in the list, because the list
21
+ * this reader sees is published-only and a locally-spliced pending review
22
+ * would vanish on the next refetch with no explanation.
23
+ *
24
+ * One behaviour for both policies, and the policy is not visible to the
25
+ * client (no endpoint reports it): invalidate, let the server say what is
26
+ * visible, and carry the created row separately as the SUBMISSION's outcome
27
+ * (`headless/ReviewForm.tsx` renders "sent, awaiting moderation" off its
28
+ * `status`). The aggregate moves for the same reason and is invalidated with
29
+ * it — `avg` and `count` only count published rows.
30
+ */
31
+ export declare function useSubmitReview(target: ReviewTarget): UseMutationResult<Review, StapelApiError, SubmitReviewVariables>;
32
+ //# sourceMappingURL=mutations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mutations.d.ts","sourceRoot":"","sources":["../../src/model/mutations.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AACnF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAI5D,mDAAmD;AACnD,MAAM,WAAW,qBAAqB;IACpC,8EAA8E;IAC9E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,mEAAmE;IACnE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,YAAY,GACnB,iBAAiB,CAAC,MAAM,EAAE,cAAc,EAAE,qBAAqB,CAAC,CAwBlE"}
@@ -0,0 +1,44 @@
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import { useReviewsApi } from "./context.js";
3
+ import { reviewsQueryKeys } from "./queryKeys.js";
4
+ /**
5
+ * Write a review of a target (frontend-standard §2 — mutations invalidate on
6
+ * success).
7
+ *
8
+ * ── Why the answer is invalidated rather than spliced in ───────────────────
9
+ *
10
+ * The created row comes back with its `status`, and under the module's
11
+ * default (`MODERATION_DEFAULT: "post"`) that is `published` — so pushing it
12
+ * into the cached window would be correct. Under `moderation: "pre"` it is
13
+ * `pending`, and the SAME row must NOT appear in the list, because the list
14
+ * this reader sees is published-only and a locally-spliced pending review
15
+ * would vanish on the next refetch with no explanation.
16
+ *
17
+ * One behaviour for both policies, and the policy is not visible to the
18
+ * client (no endpoint reports it): invalidate, let the server say what is
19
+ * visible, and carry the created row separately as the SUBMISSION's outcome
20
+ * (`headless/ReviewForm.tsx` renders "sent, awaiting moderation" off its
21
+ * `status`). The aggregate moves for the same reason and is invalidated with
22
+ * it — `avg` and `count` only count published rows.
23
+ */
24
+ export function useSubmitReview(target) {
25
+ const api = useReviewsApi();
26
+ const queryClient = useQueryClient();
27
+ const options = {
28
+ mutationFn: (vars) => api.createReview({
29
+ targetType: target.targetType,
30
+ targetKey: target.targetKey,
31
+ rating: vars.rating,
32
+ ...(vars.body !== undefined ? { body: vars.body } : {}),
33
+ }),
34
+ onSuccess: () => {
35
+ // Both scopes of the list: a moderator's `include=all` window holds the
36
+ // pending row this write may just have created.
37
+ void queryClient.invalidateQueries({
38
+ queryKey: reviewsQueryKeys.target(target),
39
+ });
40
+ },
41
+ };
42
+ return useMutation(options);
43
+ }
44
+ //# sourceMappingURL=mutations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mutations.js","sourceRoot":"","sources":["../../src/model/mutations.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAIpE,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAUlD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,eAAe,CAC7B,MAAoB;IAEpB,MAAM,GAAG,GAAG,aAAa,EAAE,CAAC;IAC5B,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;IACrC,MAAM,OAAO,GAIT;QACF,UAAU,EAAE,CAAC,IAAI,EAAE,EAAE,CACnB,GAAG,CAAC,YAAY,CAAC;YACf,UAAU,EAAE,MAAM,CAAC,UAAU;YAC7B,SAAS,EAAE,MAAM,CAAC,SAAS;YAC3B,MAAM,EAAE,IAAI,CAAC,MAAM;YACnB,GAAG,CAAC,IAAI,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACxD,CAAC;QACJ,SAAS,EAAE,GAAG,EAAE;YACd,wEAAwE;YACxE,gDAAgD;YAChD,KAAK,WAAW,CAAC,iBAAiB,CAAC;gBACjC,QAAQ,EAAE,gBAAgB,CAAC,MAAM,CAAC,MAAM,CAAC;aAC1C,CAAC,CAAC;QACL,CAAC;KACF,CAAC;IACF,OAAO,WAAW,CAAC,OAAO,CAAC,CAAC;AAC9B,CAAC"}
@@ -0,0 +1,65 @@
1
+ import type { InfiniteData, UseInfiniteQueryResult, UseQueryResult } from "@tanstack/react-query";
2
+ import type { StapelApiError } from "@stapel/core";
3
+ import type { ReviewAggregate, ReviewPage, ReviewTarget } from "../api/types.js";
4
+ /**
5
+ * Read hooks over the reviews API.
6
+ *
7
+ * ── Both reads are ANONYMOUS, and both still wait for the session ─────────
8
+ *
9
+ * Since stapel-reviews 0.3.0 a guest reads the list (`IsAuthenticatedOrReadOnly`)
10
+ * and the aggregate (`AllowAny`), so a public listing page shows its reviews
11
+ * to a visitor who will never sign in. Neither hook carries a
12
+ * "sign in first" state any more — that is now only true of the write.
13
+ *
14
+ * They are still gated on {@link useActiveSessionReady}, and the reason got
15
+ * STRONGER rather than weaker with the permission change. What the server
16
+ * returns depends on who is asking: a moderator of the target gets pending
17
+ * and hidden rows for `include=all`, everyone else is silently narrowed to
18
+ * published. A read that races a still-bootstrapping session would therefore
19
+ * succeed — as a guest — and CACHE that answer under a key that does not
20
+ * mention identity. Before 0.3.0 the same race produced a 401, which was at
21
+ * least visible; a silently narrowed page is not. `useActiveSessionReady()`
22
+ * returns `true` the instant the session settles into any of
23
+ * authenticated / anonymous / unauthenticated, and immediately when no
24
+ * session-owning module is mounted at all — so a purely public storefront
25
+ * waits for nothing.
26
+ */
27
+ /** Default page size for the review list — one screenful. */
28
+ export declare const REVIEWS_PAGE = 20;
29
+ export interface UseReviewListOptions {
30
+ /**
31
+ * Ask for pending/hidden rows too — honoured only for a moderator/owner of
32
+ * the target, and narrowed to published for anyone else WITHOUT an error.
33
+ * Part of the query key, because it selects a different set of rows.
34
+ */
35
+ readonly include?: "all";
36
+ readonly limit?: number;
37
+ /** Set `false` to hold the read (e.g. the target id is not resolved yet). */
38
+ readonly enabled?: boolean;
39
+ }
40
+ /**
41
+ * A target's reviews as an infinite (load-more) list, newest first.
42
+ *
43
+ * Pages are core's `AnchorPagination` envelope — which the schema does not
44
+ * declare (`api/types.ts`) — and the cursor is the previous page's
45
+ * `next_anchor`, a `created_at` timestamp. `has_next` is the authority on
46
+ * whether another page exists; a `next_anchor` is only read when it says yes,
47
+ * because the paginator leaves the anchor `null` on the last page and a
48
+ * cursor derived from the last row instead would re-request it forever.
49
+ */
50
+ export declare function useReviewList(target: ReviewTarget, options?: UseReviewListOptions): UseInfiniteQueryResult<InfiniteData<ReviewPage, string | undefined>, StapelApiError>;
51
+ /**
52
+ * The module-owned aggregate for one target.
53
+ *
54
+ * NOT the way to put a rating on every card of a search result: that is one
55
+ * request per card against an endpoint that computes an `AVG` each time. The
56
+ * batch read exists (`reviews.aggregates_by_keys`) but it is a comm Function
57
+ * for a server-side projection, not an HTTP endpoint — a storefront gets
58
+ * per-card ratings from the composite's projection, embedded in the rows its
59
+ * catalogue module already serves, and renders them with
60
+ * {@link ratingSummary} without calling this at all.
61
+ */
62
+ export declare function useReviewAggregate(target: ReviewTarget, options?: {
63
+ readonly enabled?: boolean;
64
+ }): UseQueryResult<ReviewAggregate, StapelApiError>;
65
+ //# sourceMappingURL=queries.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queries.d.ts","sourceRoot":"","sources":["../../src/model/queries.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,YAAY,EACZ,sBAAsB,EACtB,cAAc,EACf,MAAM,uBAAuB,CAAC;AAE/B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,KAAK,EACV,eAAe,EACf,UAAU,EACV,YAAY,EACb,MAAM,iBAAiB,CAAC;AAIzB;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,6DAA6D;AAC7D,eAAO,MAAM,YAAY,KAAK,CAAC;AAE/B,MAAM,WAAW,oBAAoB;IACnC;;;;OAIG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,KAAK,CAAC;IACzB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,6EAA6E;IAC7E,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,oBAAyB,GACjC,sBAAsB,CACvB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC,EAC5C,cAAc,CACf,CAwBA;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE;IAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAO,GAC3C,cAAc,CAAC,eAAe,EAAE,cAAc,CAAC,CAUjD"}
@@ -0,0 +1,80 @@
1
+ import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
2
+ import { useActiveSessionReady } from "@stapel/core";
3
+ import { useReviewsApi } from "./context.js";
4
+ import { reviewsQueryKeys } from "./queryKeys.js";
5
+ /**
6
+ * Read hooks over the reviews API.
7
+ *
8
+ * ── Both reads are ANONYMOUS, and both still wait for the session ─────────
9
+ *
10
+ * Since stapel-reviews 0.3.0 a guest reads the list (`IsAuthenticatedOrReadOnly`)
11
+ * and the aggregate (`AllowAny`), so a public listing page shows its reviews
12
+ * to a visitor who will never sign in. Neither hook carries a
13
+ * "sign in first" state any more — that is now only true of the write.
14
+ *
15
+ * They are still gated on {@link useActiveSessionReady}, and the reason got
16
+ * STRONGER rather than weaker with the permission change. What the server
17
+ * returns depends on who is asking: a moderator of the target gets pending
18
+ * and hidden rows for `include=all`, everyone else is silently narrowed to
19
+ * published. A read that races a still-bootstrapping session would therefore
20
+ * succeed — as a guest — and CACHE that answer under a key that does not
21
+ * mention identity. Before 0.3.0 the same race produced a 401, which was at
22
+ * least visible; a silently narrowed page is not. `useActiveSessionReady()`
23
+ * returns `true` the instant the session settles into any of
24
+ * authenticated / anonymous / unauthenticated, and immediately when no
25
+ * session-owning module is mounted at all — so a purely public storefront
26
+ * waits for nothing.
27
+ */
28
+ /** Default page size for the review list — one screenful. */
29
+ export const REVIEWS_PAGE = 20;
30
+ /**
31
+ * A target's reviews as an infinite (load-more) list, newest first.
32
+ *
33
+ * Pages are core's `AnchorPagination` envelope — which the schema does not
34
+ * declare (`api/types.ts`) — and the cursor is the previous page's
35
+ * `next_anchor`, a `created_at` timestamp. `has_next` is the authority on
36
+ * whether another page exists; a `next_anchor` is only read when it says yes,
37
+ * because the paginator leaves the anchor `null` on the last page and a
38
+ * cursor derived from the last row instead would re-request it forever.
39
+ */
40
+ export function useReviewList(target, options = {}) {
41
+ const api = useReviewsApi();
42
+ const sessionReady = useActiveSessionReady();
43
+ const limit = options.limit ?? REVIEWS_PAGE;
44
+ const addressable = target.targetType.length > 0 && target.targetKey.length > 0;
45
+ return useInfiniteQuery({
46
+ queryKey: reviewsQueryKeys.list(target, options.include),
47
+ queryFn: ({ pageParam, signal }) => api.reviews({
48
+ ...target,
49
+ ...(options.include !== undefined ? { include: options.include } : {}),
50
+ direction: "next",
51
+ limit,
52
+ ...(pageParam !== undefined ? { anchor: pageParam } : {}),
53
+ }, { signal }),
54
+ initialPageParam: undefined,
55
+ getNextPageParam: (last) => last.has_next ? (last.next_anchor ?? undefined) : undefined,
56
+ enabled: sessionReady && addressable && (options.enabled ?? true),
57
+ });
58
+ }
59
+ /**
60
+ * The module-owned aggregate for one target.
61
+ *
62
+ * NOT the way to put a rating on every card of a search result: that is one
63
+ * request per card against an endpoint that computes an `AVG` each time. The
64
+ * batch read exists (`reviews.aggregates_by_keys`) but it is a comm Function
65
+ * for a server-side projection, not an HTTP endpoint — a storefront gets
66
+ * per-card ratings from the composite's projection, embedded in the rows its
67
+ * catalogue module already serves, and renders them with
68
+ * {@link ratingSummary} without calling this at all.
69
+ */
70
+ export function useReviewAggregate(target, options = {}) {
71
+ const api = useReviewsApi();
72
+ const sessionReady = useActiveSessionReady();
73
+ const addressable = target.targetType.length > 0 && target.targetKey.length > 0;
74
+ return useQuery({
75
+ queryKey: reviewsQueryKeys.aggregate(target),
76
+ queryFn: ({ signal }) => api.aggregate(target, { signal }),
77
+ enabled: sessionReady && addressable && (options.enabled ?? true),
78
+ });
79
+ }
80
+ //# sourceMappingURL=queries.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queries.js","sourceRoot":"","sources":["../../src/model/queries.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAMnE,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAOrD,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,6DAA6D;AAC7D,MAAM,CAAC,MAAM,YAAY,GAAG,EAAE,CAAC;AAc/B;;;;;;;;;GASG;AACH,MAAM,UAAU,aAAa,CAC3B,MAAoB,EACpB,UAAgC,EAAE;IAKlC,MAAM,GAAG,GAAG,aAAa,EAAE,CAAC;IAC5B,MAAM,YAAY,GAAG,qBAAqB,EAAE,CAAC;IAC7C,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,YAAY,CAAC;IAC5C,MAAM,WAAW,GACf,MAAM,CAAC,UAAU,CAAC,MAAM,GAAG,CAAC,IAAI,MAAM,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,CAAC;IAC9D,OAAO,gBAAgB,CAAC;QACtB,QAAQ,EAAE,gBAAgB,CAAC,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,OAAO,CAAC;QACxD,OAAO,EAAE,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,CACjC,GAAG,CAAC,OAAO,CACT;YACE,GAAG,MAAM;YACT,GAAG,CAAC,OAAO,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACtE,SAAS,EAAE,MAAM;YACjB,KAAK;YACL,GAAG,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC1D,EACD,EAAE,MAAM,EAAE,CACX;QACH,gBAAgB,EAAE,SAA+B;QACjD,gBAAgB,EAAE,CAAC,IAAI,EAAE,EAAE,CACzB,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,IAAI,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS;QAC7D,OAAO,EAAE,YAAY,IAAI,WAAW,IAAI,CAAC,OAAO,CAAC,OAAO,IAAI,IAAI,CAAC;KAClE,CAAC,CAAC;AACL,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,kBAAkB,CAChC,MAAoB,EACpB,UAA0C,EAAE;IAE5C,MAAM,GAAG,GAAG,aAAa,EAAE,CAAC;IAC5B,MAAM,YAAY,GAAG,qBAAqB,EAAE,CAAC;IAC7C,MAAM,WAAW,GACf,MAAM,CAAC,UAAU,CAAC,MAAM,GAAG,CAAC,IAAI,MAAM,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,CAAC;IAC9D,OAAO,QAAQ,CAAC;QACd,QAAQ,EAAE,gBAAgB,CAAC,SAAS,CAAC,MAAM,CAAC;QAC5C,OAAO,EAAE,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,GAAG,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,MAAM,EAAE,CAAC;QAC1D,OAAO,EAAE,YAAY,IAAI,WAAW,IAAI,CAAC,OAAO,CAAC,OAAO,IAAI,IAAI,CAAC;KAClE,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Namespaced TanStack Query keys (frontend-standard §2). Everything under the
3
+ * `"reviews"` root so a host can invalidate the whole module, one target, or
4
+ * one read.
5
+ *
6
+ * THE TARGET IS TWO STRINGS, AND BOTH ARE IN THE KEY. `target_key` alone is
7
+ * not an identity: the module groups by the PAIR, and two deployments'
8
+ * registries can key different things with the same opaque string (a listing
9
+ * `"42"` and a course `"42"`). A key built from `target_key` only would serve
10
+ * one target's reviews for the other.
11
+ *
12
+ * `include` is part of the list key for the same reason: `include=all` is a
13
+ * DIFFERENT set of rows (pending and hidden ones), and folding it into the
14
+ * published-only entry would let a moderator's window overwrite what every
15
+ * other reader sees.
16
+ */
17
+ import type { ReviewTarget } from "../api/types.js";
18
+ export declare const reviewsQueryKeys: {
19
+ readonly all: readonly ["reviews"];
20
+ /** Everything cached about one target. */
21
+ target(target: ReviewTarget): readonly ["reviews", string, string];
22
+ /** The anchor-paginated list window for one target and one visibility scope. */
23
+ list(target: ReviewTarget, include?: "all"): readonly ["reviews", string, string, "list", string];
24
+ /** The module-owned aggregate for one target. */
25
+ aggregate(target: ReviewTarget): readonly ["reviews", string, string, "aggregate"];
26
+ };
27
+ //# sourceMappingURL=queryKeys.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queryKeys.d.ts","sourceRoot":"","sources":["../../src/model/queryKeys.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAIpD,eAAO,MAAM,gBAAgB,EAAE;IAC7B,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACnC,0CAA0C;IAC1C,MAAM,CAAC,MAAM,EAAE,YAAY,GAAG,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IACnE,gFAAgF;IAChF,IAAI,CACF,MAAM,EAAE,YAAY,EACpB,OAAO,CAAC,EAAE,KAAK,GACd,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IACxD,iDAAiD;IACjD,SAAS,CACP,MAAM,EAAE,YAAY,GACnB,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,CAAC,CAAC;CAYtD,CAAC"}
@@ -0,0 +1,14 @@
1
+ const ROOT = "reviews";
2
+ export const reviewsQueryKeys = {
3
+ all: [ROOT],
4
+ target: (target) => [ROOT, target.targetType, target.targetKey],
5
+ list: (target, include) => [
6
+ ROOT,
7
+ target.targetType,
8
+ target.targetKey,
9
+ "list",
10
+ include ?? "published",
11
+ ],
12
+ aggregate: (target) => [ROOT, target.targetType, target.targetKey, "aggregate"],
13
+ };
14
+ //# sourceMappingURL=queryKeys.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queryKeys.js","sourceRoot":"","sources":["../../src/model/queryKeys.ts"],"names":[],"mappings":"AAkBA,MAAM,IAAI,GAAG,SAAkB,CAAC;AAEhC,MAAM,CAAC,MAAM,gBAAgB,GAazB;IACF,GAAG,EAAE,CAAC,IAAI,CAAC;IACX,MAAM,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,CAAC,SAAS,CAAC;IAC/D,IAAI,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,EAAE,CAAC;QACzB,IAAI;QACJ,MAAM,CAAC,UAAU;QACjB,MAAM,CAAC,SAAS;QAChB,MAAM;QACN,OAAO,IAAI,WAAW;KACvB;IACD,SAAS,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,CAAC,SAAS,EAAE,WAAW,CAAC;CAChF,CAAC"}
@@ -0,0 +1,57 @@
1
+ /**
2
+ * What a rating MEANS, kept in one pure module so a card, a panel and a
3
+ * seller header cannot each decide differently.
4
+ *
5
+ * The whole file exists for one line of the contract: `avg` is `0.0` when
6
+ * `count` is `0` (`services.aggregate`, and `AggregateResponse`'s own
7
+ * description says so). Rendered naively that is a zero-star rating — the
8
+ * worst possible score — printed over a target nobody has rated yet. It is the
9
+ * same class of defect as `data ?? []`: a value that was never measured
10
+ * displayed as if it had been measured and found to be nothing.
11
+ *
12
+ * So {@link ratingSummary} answers `rated: false` for that case and the star
13
+ * row has nothing to draw. There is no way to get an `avg` out of this module
14
+ * without also getting the flag that says whether it means anything.
15
+ */
16
+ import type { RatingAggregate } from "../api/types.js";
17
+ /** An aggregate, read. */
18
+ export type RatingSummary = {
19
+ /** Nobody has published a review of this target. */
20
+ readonly rated: false;
21
+ readonly count: 0;
22
+ /** Deliberately absent: there is no average of an empty set. */
23
+ readonly avg?: undefined;
24
+ readonly rounded?: undefined;
25
+ } | {
26
+ readonly rated: true;
27
+ readonly count: number;
28
+ /** Mean over published reviews, as the server computed it (3 decimals). */
29
+ readonly avg: number;
30
+ /** The same mean at display precision — one decimal, half-up. */
31
+ readonly rounded: number;
32
+ };
33
+ /**
34
+ * Read an aggregate — from this module's own `GET /reviews/aggregate`, or from
35
+ * the composite's `shop.listing_review_summary` projection, which answers the
36
+ * same two field names on purpose (`stapel_shop/projections.py`: "the owner's
37
+ * names ARE the contract shape here").
38
+ *
39
+ * A `count` of 0 is "not rated", whatever `avg` says. A negative or
40
+ * non-finite `count` is treated the same way rather than trusted: this is the
41
+ * one place a bad number can enter the display layer.
42
+ */
43
+ export declare function ratingSummary(aggregate: RatingAggregate | null | undefined): RatingSummary;
44
+ /**
45
+ * How many whole, half and empty stars a `rounded` average draws, for a skin
46
+ * that renders stars rather than a number.
47
+ *
48
+ * `max` is the deployment's `RATING_MAX` (the runtime's `ratingBounds.max`),
49
+ * not a hardcoded five — a 1..10 deployment draws ten.
50
+ */
51
+ export interface StarBreakdown {
52
+ readonly full: number;
53
+ readonly half: 0 | 1;
54
+ readonly empty: number;
55
+ }
56
+ export declare function starBreakdown(rounded: number, max: number): StarBreakdown;
57
+ //# sourceMappingURL=rating.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rating.d.ts","sourceRoot":"","sources":["../../src/model/rating.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEvD,0BAA0B;AAC1B,MAAM,MAAM,aAAa,GACrB;IACE,oDAAoD;IACpD,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC;IAClB,gEAAgE;IAChE,QAAQ,CAAC,GAAG,CAAC,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,CAAC;CAC9B,GACD;IACE,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,2EAA2E;IAC3E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,iEAAiE;IACjE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B,CAAC;AAYN;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAC3B,SAAS,EAAE,eAAe,GAAG,IAAI,GAAG,SAAS,GAC5C,aAAa,CAaf;AAED;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,aAAa,CAczE"}
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Round half-up to one decimal for display. `Math.round` is already half-up
3
+ * for positives and ratings are positive, but the float dance is written out
4
+ * so 4.25 → 4.3 rather than 4.2 (binary representation makes
5
+ * `Math.round(4.25 * 10)` unreliable across engines otherwise).
6
+ */
7
+ function oneDecimal(value) {
8
+ return Math.round((value + Number.EPSILON) * 10) / 10;
9
+ }
10
+ /**
11
+ * Read an aggregate — from this module's own `GET /reviews/aggregate`, or from
12
+ * the composite's `shop.listing_review_summary` projection, which answers the
13
+ * same two field names on purpose (`stapel_shop/projections.py`: "the owner's
14
+ * names ARE the contract shape here").
15
+ *
16
+ * A `count` of 0 is "not rated", whatever `avg` says. A negative or
17
+ * non-finite `count` is treated the same way rather than trusted: this is the
18
+ * one place a bad number can enter the display layer.
19
+ */
20
+ export function ratingSummary(aggregate) {
21
+ const count = aggregate?.count;
22
+ if (aggregate === null ||
23
+ aggregate === undefined ||
24
+ typeof count !== "number" ||
25
+ !Number.isFinite(count) ||
26
+ count <= 0) {
27
+ return { rated: false, count: 0 };
28
+ }
29
+ const avg = Number.isFinite(aggregate.avg) ? aggregate.avg : 0;
30
+ return { rated: true, count, avg, rounded: oneDecimal(avg) };
31
+ }
32
+ export function starBreakdown(rounded, max) {
33
+ const clamped = Math.min(Math.max(rounded, 0), max);
34
+ const full = Math.floor(clamped);
35
+ const remainder = clamped - full;
36
+ // A remainder of a quarter or more is a half star; anything less rounds
37
+ // away. The alternative — a partial-width star — is a skin's business, and
38
+ // this breakdown stays a count so a non-antd skin can use it too.
39
+ const half = remainder >= 0.25 && remainder < 0.75 ? 1 : 0;
40
+ const rounded_up = remainder >= 0.75 ? 1 : 0;
41
+ return {
42
+ full: full + rounded_up,
43
+ half,
44
+ empty: max - full - rounded_up - half,
45
+ };
46
+ }
47
+ //# sourceMappingURL=rating.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rating.js","sourceRoot":"","sources":["../../src/model/rating.ts"],"names":[],"mappings":"AAoCA;;;;;GAKG;AACH,SAAS,UAAU,CAAC,KAAa;IAC/B,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,KAAK,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,GAAG,EAAE,CAAC;AACxD,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,aAAa,CAC3B,SAA6C;IAE7C,MAAM,KAAK,GAAG,SAAS,EAAE,KAAK,CAAC;IAC/B,IACE,SAAS,KAAK,IAAI;QAClB,SAAS,KAAK,SAAS;QACvB,OAAO,KAAK,KAAK,QAAQ;QACzB,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;QACvB,KAAK,IAAI,CAAC,EACV,CAAC;QACD,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC;IACpC,CAAC;IACD,MAAM,GAAG,GAAG,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IAC/D,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,OAAO,EAAE,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;AAC/D,CAAC;AAeD,MAAM,UAAU,aAAa,CAAC,OAAe,EAAE,GAAW;IACxD,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IACpD,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IACjC,MAAM,SAAS,GAAG,OAAO,GAAG,IAAI,CAAC;IACjC,wEAAwE;IACxE,2EAA2E;IAC3E,kEAAkE;IAClE,MAAM,IAAI,GAAU,SAAS,IAAI,IAAI,IAAI,SAAS,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAClE,MAAM,UAAU,GAAG,SAAS,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC7C,OAAO;QACL,IAAI,EAAE,IAAI,GAAG,UAAU;QACvB,IAAI;QACJ,KAAK,EAAE,GAAG,GAAG,IAAI,GAAG,UAAU,GAAG,IAAI;KACtC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,51 @@
1
+ import type { FlowError } from "@stapel/core";
2
+ /** The author already reviewed this target (`one_per_author`). A **400**. */
3
+ export declare const REVIEWS_ERROR_DUPLICATE = "error.400.reviews_duplicate_review";
4
+ /** `target_type` is not in the host's registry — or one of the pair was missing. */
5
+ export declare const REVIEWS_ERROR_UNKNOWN_TARGET_TYPE = "error.400.reviews_unknown_target_type";
6
+ /** The rating fell outside `[RATING_MIN, RATING_MAX]`. */
7
+ export declare const REVIEWS_ERROR_INVALID_RATING = "error.400.reviews_invalid_rating";
8
+ /** The type's `can_review` callback said no. */
9
+ export declare const REVIEWS_ERROR_CANNOT_REVIEW = "error.403.reviews_cannot_review";
10
+ /** The type's `can_moderate` callback said no (fail-closed when unset). */
11
+ export declare const REVIEWS_ERROR_CANNOT_MODERATE = "error.403.reviews_cannot_moderate";
12
+ /** No review with that id. */
13
+ export declare const REVIEWS_ERROR_NOT_FOUND = "error.404.reviews_review_not_found";
14
+ /** Responses are switched off for this target type. */
15
+ export declare const REVIEWS_ERROR_RESPONSE_NOT_ALLOWED = "error.400.reviews_response_not_allowed";
16
+ /** The review already carries the owner's reply — the module's ONLY 409. */
17
+ export declare const REVIEWS_ERROR_ALREADY_RESPONDED = "error.409.reviews_already_responded";
18
+ /** Fold any thrown value into this pair's error dialect. */
19
+ export declare function toReviewsError(error: unknown): FlowError;
20
+ /**
21
+ * "You have already reviewed this target."
22
+ *
23
+ * Only ever true when the target type sets `one_per_author: true` — the
24
+ * registry default is `false` (`registry.resolve_policy`), and the catalogue
25
+ * shop preset turns it on for `listing`. A host whose policy allows several
26
+ * reviews will simply never see this.
27
+ */
28
+ export declare function isDuplicateReview(error: unknown): boolean;
29
+ /**
30
+ * "This read needs a signed-in account." True for the 401 every endpoint of
31
+ * this module answers to an anonymous caller.
32
+ *
33
+ * Both halves are checked: core maps an un-keyed 401 body to
34
+ * `stapel.http.401`, but a deployment that puts its own `localizable_error`
35
+ * in a 401 body would arrive with that key instead — and it is still a 401.
36
+ */
37
+ export declare function isSignInRequired(error: unknown): boolean;
38
+ /** The host's `can_review` callback refused this author for this target. */
39
+ export declare function isReviewingForbidden(error: unknown): boolean;
40
+ /**
41
+ * The target type is not registered on this deployment.
42
+ *
43
+ * Worth its own predicate because the view answers it for TWO different
44
+ * situations: an unknown type, and a missing `target_type`/`target_key` on a
45
+ * GET (`views.py` — "a missing one is ERR_400_UNKNOWN_TARGET_TYPE, not an
46
+ * empty list/aggregate"). Either way it is a wiring fault in the host, not
47
+ * something the person reading the page can fix, which is why the skin shows
48
+ * it as an error rather than as an empty state.
49
+ */
50
+ export declare function isUnknownTargetType(error: unknown): boolean;
51
+ //# sourceMappingURL=refusals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"refusals.d.ts","sourceRoot":"","sources":["../../src/model/refusals.ts"],"names":[],"mappings":"AA6BA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,6EAA6E;AAC7E,eAAO,MAAM,uBAAuB,uCAAuC,CAAC;AAC5E,oFAAoF;AACpF,eAAO,MAAM,iCAAiC,0CACL,CAAC;AAC1C,0DAA0D;AAC1D,eAAO,MAAM,4BAA4B,qCAAqC,CAAC;AAC/E,gDAAgD;AAChD,eAAO,MAAM,2BAA2B,oCAAoC,CAAC;AAC7E,2EAA2E;AAC3E,eAAO,MAAM,6BAA6B,sCAAsC,CAAC;AACjF,8BAA8B;AAC9B,eAAO,MAAM,uBAAuB,uCAAuC,CAAC;AAC5E,uDAAuD;AACvD,eAAO,MAAM,kCAAkC,2CACL,CAAC;AAC3C,4EAA4E;AAC5E,eAAO,MAAM,+BAA+B,wCACL,CAAC;AAiBxC,4DAA4D;AAC5D,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,SAAS,CAExD;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEzD;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAGxD;AAED,4EAA4E;AAC5E,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE5D;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE3D"}
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The refusals this pair has to tell apart, and the reason they are read by
3
+ * CODE and never by status.
4
+ *
5
+ * ── The trap ───────────────────────────────────────────────────────────────
6
+ *
7
+ * "You have already reviewed this" is a **400**
8
+ * (`ERR_400_DUPLICATE_REVIEW = "error.400.reviews_duplicate_review"`,
9
+ * `views.ReviewListCreateView.post`), not the 409 the situation reads like.
10
+ * And the module DOES answer 409 — for something else entirely:
11
+ * `error.409.reviews_already_responded`, which says the owner's reply already
12
+ * exists. A form that branched on `status === 409` would therefore miss the
13
+ * duplicate every time and mis-handle a refusal about a different object.
14
+ *
15
+ * That is why every predicate here compares a `FlowError.code`, folded
16
+ * through core's `toFlowError`, and why the codes are exported as named
17
+ * constants rather than typed at each call site.
18
+ *
19
+ * ── The other one worth naming ─────────────────────────────────────────────
20
+ *
21
+ * All four stapel-reviews views are `IsAuthenticated`, so a signed-out
22
+ * visitor gets 401 on the review LIST and on the AGGREGATE. Read as a
23
+ * generic failure that becomes "no reviews yet" under any code that defaults
24
+ * an empty array — a well-reviewed seller shown as unreviewed to exactly the
25
+ * people who have not signed up yet. {@link isSignInRequired} exists so the
26
+ * list bag can carry that as its own state and the skin can say the true
27
+ * thing.
28
+ */
29
+ import { isErrorCode, toFlowError } from "@stapel/core";
30
+ /** The author already reviewed this target (`one_per_author`). A **400**. */
31
+ export const REVIEWS_ERROR_DUPLICATE = "error.400.reviews_duplicate_review";
32
+ /** `target_type` is not in the host's registry — or one of the pair was missing. */
33
+ export const REVIEWS_ERROR_UNKNOWN_TARGET_TYPE = "error.400.reviews_unknown_target_type";
34
+ /** The rating fell outside `[RATING_MIN, RATING_MAX]`. */
35
+ export const REVIEWS_ERROR_INVALID_RATING = "error.400.reviews_invalid_rating";
36
+ /** The type's `can_review` callback said no. */
37
+ export const REVIEWS_ERROR_CANNOT_REVIEW = "error.403.reviews_cannot_review";
38
+ /** The type's `can_moderate` callback said no (fail-closed when unset). */
39
+ export const REVIEWS_ERROR_CANNOT_MODERATE = "error.403.reviews_cannot_moderate";
40
+ /** No review with that id. */
41
+ export const REVIEWS_ERROR_NOT_FOUND = "error.404.reviews_review_not_found";
42
+ /** Responses are switched off for this target type. */
43
+ export const REVIEWS_ERROR_RESPONSE_NOT_ALLOWED = "error.400.reviews_response_not_allowed";
44
+ /** The review already carries the owner's reply — the module's ONLY 409. */
45
+ export const REVIEWS_ERROR_ALREADY_RESPONDED = "error.409.reviews_already_responded";
46
+ /** Core's cross-cutting key for an unauthenticated call. */
47
+ const HTTP_401_KEY = "stapel.http.401";
48
+ /**
49
+ * Named, not inlined — the cdn-react precedent, for the same reason
50
+ * `stapel/no-adhoc-401` gives: the rule bans a bare `=== 401` because that
51
+ * shape is how ad hoc refresh/redirect logic gets written outside core's one
52
+ * seam. What happens here is not that. Nothing is refreshed, retried or
53
+ * redirected; the status is merely CLASSIFIED so a public page can say "sign
54
+ * in to read the reviews" instead of "this seller has no reviews". The real
55
+ * 401 handling stays on the client's `onAuthRefresh` seam and never reaches
56
+ * this file.
57
+ */
58
+ const HTTP_UNAUTHORIZED = 401;
59
+ /** Fold any thrown value into this pair's error dialect. */
60
+ export function toReviewsError(error) {
61
+ return toFlowError(error, "reviews.error.unknown");
62
+ }
63
+ /**
64
+ * "You have already reviewed this target."
65
+ *
66
+ * Only ever true when the target type sets `one_per_author: true` — the
67
+ * registry default is `false` (`registry.resolve_policy`), and the catalogue
68
+ * shop preset turns it on for `listing`. A host whose policy allows several
69
+ * reviews will simply never see this.
70
+ */
71
+ export function isDuplicateReview(error) {
72
+ return isErrorCode(toReviewsError(error), REVIEWS_ERROR_DUPLICATE);
73
+ }
74
+ /**
75
+ * "This read needs a signed-in account." True for the 401 every endpoint of
76
+ * this module answers to an anonymous caller.
77
+ *
78
+ * Both halves are checked: core maps an un-keyed 401 body to
79
+ * `stapel.http.401`, but a deployment that puts its own `localizable_error`
80
+ * in a 401 body would arrive with that key instead — and it is still a 401.
81
+ */
82
+ export function isSignInRequired(error) {
83
+ const flow = toReviewsError(error);
84
+ return flow.status === HTTP_UNAUTHORIZED || isErrorCode(flow, HTTP_401_KEY);
85
+ }
86
+ /** The host's `can_review` callback refused this author for this target. */
87
+ export function isReviewingForbidden(error) {
88
+ return isErrorCode(toReviewsError(error), REVIEWS_ERROR_CANNOT_REVIEW);
89
+ }
90
+ /**
91
+ * The target type is not registered on this deployment.
92
+ *
93
+ * Worth its own predicate because the view answers it for TWO different
94
+ * situations: an unknown type, and a missing `target_type`/`target_key` on a
95
+ * GET (`views.py` — "a missing one is ERR_400_UNKNOWN_TARGET_TYPE, not an
96
+ * empty list/aggregate"). Either way it is a wiring fault in the host, not
97
+ * something the person reading the page can fix, which is why the skin shows
98
+ * it as an error rather than as an empty state.
99
+ */
100
+ export function isUnknownTargetType(error) {
101
+ return isErrorCode(toReviewsError(error), REVIEWS_ERROR_UNKNOWN_TARGET_TYPE);
102
+ }
103
+ //# sourceMappingURL=refusals.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"refusals.js","sourceRoot":"","sources":["../../src/model/refusals.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGxD,6EAA6E;AAC7E,MAAM,CAAC,MAAM,uBAAuB,GAAG,oCAAoC,CAAC;AAC5E,oFAAoF;AACpF,MAAM,CAAC,MAAM,iCAAiC,GAC5C,uCAAuC,CAAC;AAC1C,0DAA0D;AAC1D,MAAM,CAAC,MAAM,4BAA4B,GAAG,kCAAkC,CAAC;AAC/E,gDAAgD;AAChD,MAAM,CAAC,MAAM,2BAA2B,GAAG,iCAAiC,CAAC;AAC7E,2EAA2E;AAC3E,MAAM,CAAC,MAAM,6BAA6B,GAAG,mCAAmC,CAAC;AACjF,8BAA8B;AAC9B,MAAM,CAAC,MAAM,uBAAuB,GAAG,oCAAoC,CAAC;AAC5E,uDAAuD;AACvD,MAAM,CAAC,MAAM,kCAAkC,GAC7C,wCAAwC,CAAC;AAC3C,4EAA4E;AAC5E,MAAM,CAAC,MAAM,+BAA+B,GAC1C,qCAAqC,CAAC;AAExC,4DAA4D;AAC5D,MAAM,YAAY,GAAG,iBAAiB,CAAC;AAEvC;;;;;;;;;GASG;AACH,MAAM,iBAAiB,GAAG,GAAG,CAAC;AAE9B,4DAA4D;AAC5D,MAAM,UAAU,cAAc,CAAC,KAAc;IAC3C,OAAO,WAAW,CAAC,KAAK,EAAE,uBAAuB,CAAC,CAAC;AACrD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAc;IAC9C,OAAO,WAAW,CAAC,cAAc,CAAC,KAAK,CAAC,EAAE,uBAAuB,CAAC,CAAC;AACrE,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,MAAM,IAAI,GAAG,cAAc,CAAC,KAAK,CAAC,CAAC;IACnC,OAAO,IAAI,CAAC,MAAM,KAAK,iBAAiB,IAAI,WAAW,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;AAC9E,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,oBAAoB,CAAC,KAAc;IACjD,OAAO,WAAW,CAAC,cAAc,CAAC,KAAK,CAAC,EAAE,2BAA2B,CAAC,CAAC;AACzE,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,mBAAmB,CAAC,KAAc;IAChD,OAAO,WAAW,CAAC,cAAc,CAAC,KAAK,CAAC,EAAE,iCAAiC,CAAC,CAAC;AAC/E,CAAC"}