@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,133 @@
1
+ import type { I18nDictionary, I18nEngine } from "@stapel/core";
2
+ import { reviewsErrorBundleEn } from "./generated/errors.gen.js";
3
+
4
+ /**
5
+ * reviews-react's own translation KEYS (frontend-standard §4.2): headless
6
+ * components never render literal strings — hosts resolve these through core's
7
+ * i18n engine (`useT`). Backend error codes flow through the SAME contour: a
8
+ * `StapelApiError.code` is already a key, so the default bundle below ships
9
+ * English for both the backend's codes (generated) and the pair's own UI keys.
10
+ */
11
+ export const REVIEWS_I18N_KEYS = {
12
+ unknownError: "reviews.error.unknown",
13
+
14
+ // The list
15
+ listHeading: "reviews.list.heading",
16
+ listEmpty: "reviews.list.empty",
17
+ listLoadMore: "reviews.list.load_more",
18
+ listRefresh: "reviews.list.refresh",
19
+ moreBlockedEnd: "reviews.list.more.blocked.end",
20
+ moreBlockedPending: "reviews.list.more.blocked.pending",
21
+
22
+ // One row. The wire carries `author_id` and NOTHING else about the person —
23
+ // no name, no avatar — so the skin renders this unless the host passes a
24
+ // `renderAuthor` slot. Printing the raw id would be both useless and a
25
+ // gratuitous disclosure.
26
+ authorFallback: "reviews.review.author_fallback",
27
+ responseHeading: "reviews.review.response_heading",
28
+
29
+ // Visibility, said out loud. A row that is not published is only ever on
30
+ // screen because a moderator asked for `include=all`, and it must not look
31
+ // like an ordinary review.
32
+ statusPending: "reviews.status.pending",
33
+ statusHidden: "reviews.status.hidden",
34
+ statusUnknown: "reviews.status.unknown",
35
+
36
+ // The rating
37
+ ratingNone: "reviews.rating.none",
38
+ ratingValue: "reviews.rating.value",
39
+ ratingCount: "reviews.rating.count",
40
+ ratingStarLabel: "reviews.rating.star_label",
41
+
42
+ // The form. `formSignInRequired` is the ONLY sign-in key left: since
43
+ // stapel-reviews 0.3.0 both reads are anonymous and only the POST refuses a
44
+ // guest, so "sign in" is copy for the write and never a wall in front of
45
+ // the content.
46
+ formHeading: "reviews.form.heading",
47
+ formRatingLabel: "reviews.form.rating_label",
48
+ formBodyLabel: "reviews.form.body_label",
49
+ formBodyPlaceholder: "reviews.form.body_placeholder",
50
+ formSubmit: "reviews.form.submit",
51
+ formSentPublished: "reviews.form.sent.published",
52
+ formSentPending: "reviews.form.sent.pending",
53
+ formSentHidden: "reviews.form.sent.hidden",
54
+ formSentUnknown: "reviews.form.sent.unknown",
55
+ formSignInRequired: "reviews.form.sign_in_required",
56
+
57
+ // Blocked controls — every one of these is the `code` of an
58
+ // ActionAvailability, so a switched-off button always has a sentence.
59
+ submitBlockedNoRating: "reviews.submit.blocked.no_rating",
60
+ submitBlockedPending: "reviews.submit.blocked.pending",
61
+ submitBlockedDuplicate: "reviews.submit.blocked.duplicate",
62
+ submitBlockedSubmitted: "reviews.submit.blocked.submitted",
63
+ submitBlockedForbidden: "reviews.submit.blocked.forbidden",
64
+
65
+ // Backend error keys the pair OWNS the localization of. stapel-reviews ships
66
+ // English only (no `translations/` directory at all), so its 9 keys are
67
+ // absent from the generated ru/es bundles and are authored in `./i18n/<lang>`
68
+ // instead — the stapel-forms/`stapel_attributes` precedent, applied before
69
+ // this by forms, chat, cdn and categories. Listed here so `i18n-key-exists`
70
+ // knows them and `test/i18n.test.ts` can prove all three locales carry them.
71
+ errorDuplicateReview: "error.400.reviews_duplicate_review",
72
+ errorInvalidModerationAction: "error.400.reviews_invalid_moderation_action",
73
+ errorInvalidRating: "error.400.reviews_invalid_rating",
74
+ errorResponseNotAllowed: "error.400.reviews_response_not_allowed",
75
+ errorUnknownTargetType: "error.400.reviews_unknown_target_type",
76
+ errorCannotModerate: "error.403.reviews_cannot_moderate",
77
+ errorCannotReview: "error.403.reviews_cannot_review",
78
+ errorReviewNotFound: "error.404.reviews_review_not_found",
79
+ errorAlreadyResponded: "error.409.reviews_already_responded",
80
+ } as const;
81
+
82
+ /**
83
+ * The English bundle: the generated backend fallbacks first, this pair's own
84
+ * UI copy over them. English is INLINE (not a separate subpath) so a host that
85
+ * registers nothing still renders sentences instead of raw keys.
86
+ */
87
+ export const reviewsI18nBundleEn: I18nDictionary = {
88
+ ...reviewsErrorBundleEn,
89
+
90
+ "reviews.error.unknown": "Something went wrong with the reviews",
91
+
92
+ "reviews.list.heading": "Reviews",
93
+ "reviews.list.empty": "No reviews yet",
94
+ "reviews.list.load_more": "Show more",
95
+ "reviews.list.refresh": "Refresh",
96
+ "reviews.list.more.blocked.end": "That is all of them",
97
+ "reviews.list.more.blocked.pending": "Loading…",
98
+
99
+ "reviews.review.author_fallback": "A customer",
100
+ "reviews.review.response_heading": "Reply from the seller",
101
+
102
+ "reviews.status.pending": "Awaiting moderation",
103
+ "reviews.status.hidden": "Hidden by moderation",
104
+ "reviews.status.unknown": "Unknown state: {status}",
105
+
106
+ "reviews.rating.none": "No reviews yet",
107
+ "reviews.rating.value": "{avg} out of {max}",
108
+ "reviews.rating.count": "{count} reviews",
109
+ "reviews.rating.star_label": "{index} out of {max}",
110
+
111
+ "reviews.form.heading": "Rate this",
112
+ "reviews.form.rating_label": "Your rating",
113
+ "reviews.form.body_label": "Your review",
114
+ "reviews.form.body_placeholder": "What was the deal like? (optional)",
115
+ "reviews.form.submit": "Send",
116
+ "reviews.form.sent.published": "Thank you — your review is published",
117
+ "reviews.form.sent.pending":
118
+ "Thank you — your review will appear once it has been checked",
119
+ "reviews.form.sent.hidden": "Your review was saved but is not shown",
120
+ "reviews.form.sent.unknown": "Your review was saved",
121
+ "reviews.form.sign_in_required": "Sign in to leave a review",
122
+
123
+ "reviews.submit.blocked.no_rating": "Choose a rating first",
124
+ "reviews.submit.blocked.pending": "Sending…",
125
+ "reviews.submit.blocked.duplicate": "You have already rated this",
126
+ "reviews.submit.blocked.submitted": "Your review has been sent",
127
+ "reviews.submit.blocked.forbidden": "You cannot review this",
128
+ };
129
+
130
+ /** Register the English bundle into a core i18n engine. */
131
+ export function registerReviewsI18n(engine: I18nEngine, locale = "en"): void {
132
+ engine.registerBundle(locale, reviewsI18nBundleEn);
133
+ }
package/src/i18n/ru.ts ADDED
@@ -0,0 +1,85 @@
1
+ import type { I18nDictionary, I18nEngine } from "@stapel/core";
2
+ import { reviewsErrorBundleRu } from "./generated/errors.ru.gen.js";
3
+
4
+ export { reviewsErrorBundleRu } from "./generated/errors.ru.gen.js";
5
+
6
+ /**
7
+ * Russian bundle for reviews-react — shipped as the
8
+ * `@stapel/reviews-react/i18n/ru` subpath (i18n-shipping.md §2) so the locale
9
+ * is opt-in: hosts that don't register it never carry these strings (the main
10
+ * entry does not import this module — gated by size-limit and the
11
+ * bundle-purity test).
12
+ *
13
+ * TWO SOURCES, ON PURPOSE. The generated `reviewsErrorBundleRu` covers the 42
14
+ * cross-cutting keys stapel-core owns and localizes. The 9 keys stapel-reviews
15
+ * owns are NOT in it, and cannot be: the module ships no `translations/`
16
+ * directory at all, so the generator emits a `Partial` bundle and says so in
17
+ * its own header (`ERRORS_LOCALE_EXEMPT_OWNERS`, the stapel-forms precedent).
18
+ * They are authored below, beside the UI copy. When upstream ships
19
+ * `translations/errors.ru.json`, these nine lines are deleted and the
20
+ * generated bundle covers them — the keys and the texts do not move.
21
+ */
22
+ export const reviewsI18nBundleRu: I18nDictionary = {
23
+ // Backend error codes — generated ru texts for every key core owns.
24
+ ...reviewsErrorBundleRu,
25
+
26
+ // Backend error codes stapel-reviews owns — authored here (see the note
27
+ // above).
28
+ "error.400.reviews_duplicate_review": "Вы уже оценили это",
29
+ "error.400.reviews_invalid_moderation_action":
30
+ "Действие модерации должно быть одним из: скрыть, опубликовать",
31
+ "error.400.reviews_invalid_rating": "Оценка вне допустимого диапазона",
32
+ "error.400.reviews_response_not_allowed":
33
+ "Для этого типа объекта ответы отключены",
34
+ "error.400.reviews_unknown_target_type": "Неизвестный тип объекта отзыва",
35
+ "error.403.reviews_cannot_moderate":
36
+ "Вы не можете модерировать отзывы об этом объекте",
37
+ "error.403.reviews_cannot_review": "Вы не можете оставить отзыв об этом объекте",
38
+ "error.404.reviews_review_not_found": "Отзыв не найден",
39
+ "error.409.reviews_already_responded": "На этот отзыв уже есть ответ",
40
+
41
+ // UI copy.
42
+ "reviews.error.unknown": "С отзывами что-то пошло не так",
43
+
44
+ "reviews.list.heading": "Отзывы",
45
+ "reviews.list.empty": "Отзывов пока нет",
46
+ "reviews.list.load_more": "Показать ещё",
47
+ "reviews.list.refresh": "Обновить",
48
+ "reviews.list.more.blocked.end": "Это все отзывы",
49
+ "reviews.list.more.blocked.pending": "Загружаем…",
50
+
51
+ "reviews.review.author_fallback": "Покупатель",
52
+ "reviews.review.response_heading": "Ответ продавца",
53
+
54
+ "reviews.status.pending": "Ожидает модерации",
55
+ "reviews.status.hidden": "Скрыт модерацией",
56
+ "reviews.status.unknown": "Неизвестное состояние: {status}",
57
+
58
+ "reviews.rating.none": "Отзывов пока нет",
59
+ "reviews.rating.value": "{avg} из {max}",
60
+ "reviews.rating.count": "Отзывов: {count}",
61
+ "reviews.rating.star_label": "{index} из {max}",
62
+
63
+ "reviews.form.heading": "Оцените",
64
+ "reviews.form.rating_label": "Ваша оценка",
65
+ "reviews.form.body_label": "Ваш отзыв",
66
+ "reviews.form.body_placeholder": "Как прошла сделка? (необязательно)",
67
+ "reviews.form.submit": "Отправить",
68
+ "reviews.form.sent.published": "Спасибо — ваш отзыв опубликован",
69
+ "reviews.form.sent.pending":
70
+ "Спасибо — отзыв появится после проверки модератором",
71
+ "reviews.form.sent.hidden": "Отзыв сохранён, но не показывается",
72
+ "reviews.form.sent.unknown": "Отзыв сохранён",
73
+ "reviews.form.sign_in_required": "Войдите, чтобы оставить отзыв",
74
+
75
+ "reviews.submit.blocked.no_rating": "Сначала выберите оценку",
76
+ "reviews.submit.blocked.pending": "Отправляем…",
77
+ "reviews.submit.blocked.duplicate": "Вы уже оценили это",
78
+ "reviews.submit.blocked.submitted": "Ваш отзыв отправлен",
79
+ "reviews.submit.blocked.forbidden": "Вы не можете оставить здесь отзыв",
80
+ };
81
+
82
+ /** Register the Russian bundle into a core i18n engine. */
83
+ export function registerReviewsI18nRu(engine: I18nEngine, locale = "ru"): void {
84
+ engine.registerBundle(locale, reviewsI18nBundleRu);
85
+ }
package/src/index.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `@stapel/reviews-react` — the headless React pair for stapel-reviews
3
+ * (frontend-standard §2). Business + state only, zero visual opinion; the antd
4
+ * skin lives behind the `./default` subpath, so a host that renders its own
5
+ * review block carries none of it.
6
+ *
7
+ * ── The one-liner ──────────────────────────────────────────────────────────
8
+ *
9
+ * ```tsx
10
+ * const runtime = createReviewsRuntime({ baseUrl: "/reviews/api/v1" });
11
+ * const target = { targetType: "listing", targetKey: listingId };
12
+ * <ReviewsProvider runtime={runtime}>
13
+ * <ReviewAggregate target={target}>{(bag) => <YourStars bag={bag} />}</ReviewAggregate>
14
+ * <ReviewList target={target}>{(bag) => <YourRows bag={bag} />}</ReviewList>
15
+ * <ReviewForm target={target}>{(bag) => <YourForm bag={bag} />}</ReviewForm>
16
+ * </ReviewsProvider>
17
+ * ```
18
+ *
19
+ * `"listing"` in that snippet is the HOST's registry key, not this library's:
20
+ * stapel-reviews ships an EMPTY `TARGET_TYPES` registry and this package
21
+ * exports no target-type constants (see `api/types.ts`, `ReviewTarget`).
22
+ *
23
+ * ── The four properties this pair exists to guarantee ──────────────────────
24
+ *
25
+ * 1. **A zero average is not a zero rating.** `avg` is `0.0` when `count` is
26
+ * `0`, by the module's own contract. `ratingSummary()` answers
27
+ * `rated: false` there and gives a skin nothing to draw a star row from, so
28
+ * "nobody has rated this yet" can never be rendered as the worst possible
29
+ * score. Same class as `data ?? []`.
30
+ * 2. **The refusal is read by CODE, never by status.** "You have already
31
+ * reviewed this" is `error.400.reviews_duplicate_review` — a 400 — while
32
+ * the module's only 409 says the owner's reply already exists. Branching on
33
+ * the number would miss the first and mishandle the second
34
+ * (`model/refusals.ts`).
35
+ * 3. **A 401 is not an empty list.** Every stapel-reviews endpoint is
36
+ * `IsAuthenticated`, including both reads, so a signed-out visitor to a
37
+ * public listing page gets 401 — and the empty state would tell them a
38
+ * well-reviewed seller has never been reviewed. `signInRequired` is a
39
+ * named state on both read bags.
40
+ * 4. **A review that is not published says so.** `status` is carried to the
41
+ * screen (`pending` / `hidden`, plus an explicit `unknown` arm for a state
42
+ * a future backend adds), and the submit outcome reports the created row's
43
+ * status, so a pre-moderating deployment tells the author their review is
44
+ * waiting instead of leaving them to look for it.
45
+ *
46
+ * ── What this pair does NOT do ─────────────────────────────────────────────
47
+ *
48
+ * No moderation and no owner reply. `POST {id}/moderate` and
49
+ * `POST {id}/response` are both gated on the target type's fail-closed
50
+ * `can_moderate` callback and belong to consoles this pair does not ship
51
+ * (storefront spec §4.4: the reply is DISPLAYED, and the button to write one
52
+ * does not exist rather than existing switched off). Both stay in
53
+ * `manifest.json`, which lists the whole contract.
54
+ *
55
+ * No seller-wide rating fetch. The product model (spec fork F5) reviews the
56
+ * SELLER for a specific listing, so a seller's rating is a roll-up across
57
+ * their listings — which stapel-reviews cannot compute (one
58
+ * `(target_type, target_key)` per call) and the shop composite can
59
+ * (`shop.listing_review_summary`). `<ReviewAggregate aggregate={…}>` renders
60
+ * those two numbers from wherever the host got them; it does not invent an
61
+ * N+1 loop to fake the roll-up.
62
+ *
63
+ * No nav manifest: this pair has no route of its own. It renders INSIDE the
64
+ * listing detail page and the public seller profile, both of which belong to
65
+ * other pairs (the cdn-react precedent — a menu entry leading nowhere is
66
+ * worse than no entry).
67
+ *
68
+ * Layers: api → model → headless → i18n. Generated surfaces (the typed schema,
69
+ * the error map, the manifest, llms.txt) are produced by the monorepo `gen:*`
70
+ * drivers from stapel-reviews' own `docs/` artifacts and stand under drift
71
+ * gates.
72
+ */
73
+
74
+ // ── api ──────────────────────────────────────────────────────────────────────
75
+ export { createReviewsApi } from "./api/reviewsApi.js";
76
+ export type { ReviewsApi } from "./api/reviewsApi.js";
77
+ export type {
78
+ RatingAggregate,
79
+ Review,
80
+ ReviewAggregate as ReviewAggregateResponse,
81
+ ReviewAnchorDirection,
82
+ ReviewCreateRequest,
83
+ ReviewListParams,
84
+ ReviewOwnerResponse,
85
+ ReviewPage,
86
+ ReviewStatus,
87
+ ReviewTarget,
88
+ Schemas,
89
+ } from "./api/types.js";
90
+
91
+ // ── model ────────────────────────────────────────────────────────────────────
92
+ export { createReviewsRuntime, DEFAULT_RATING_BOUNDS } from "./model/runtime.js";
93
+ export type {
94
+ CreateReviewsRuntimeOptions,
95
+ ReviewRatingBounds,
96
+ ReviewsRuntime,
97
+ } from "./model/runtime.js";
98
+ export {
99
+ ReviewsRuntimeContext,
100
+ useReviewsAnalytics,
101
+ useReviewsApi,
102
+ useReviewsRuntime,
103
+ } from "./model/context.js";
104
+ export { reviewsQueryKeys } from "./model/queryKeys.js";
105
+ export { REVIEWS_PAGE, useReviewAggregate, useReviewList } from "./model/queries.js";
106
+ export type { UseReviewListOptions } from "./model/queries.js";
107
+ export { useSubmitReview } from "./model/mutations.js";
108
+ export type { SubmitReviewVariables } from "./model/mutations.js";
109
+ export { ratingSummary, starBreakdown } from "./model/rating.js";
110
+ export type { RatingSummary, StarBreakdown } from "./model/rating.js";
111
+ export {
112
+ findOwnReview,
113
+ isModeratedOut,
114
+ reviewsFromPages,
115
+ reviewVisibility,
116
+ } from "./model/list.js";
117
+ export {
118
+ isDuplicateReview,
119
+ isReviewingForbidden,
120
+ isSignInRequired,
121
+ isUnknownTargetType,
122
+ REVIEWS_ERROR_ALREADY_RESPONDED,
123
+ REVIEWS_ERROR_CANNOT_MODERATE,
124
+ REVIEWS_ERROR_CANNOT_REVIEW,
125
+ REVIEWS_ERROR_DUPLICATE,
126
+ REVIEWS_ERROR_INVALID_RATING,
127
+ REVIEWS_ERROR_NOT_FOUND,
128
+ REVIEWS_ERROR_RESPONSE_NOT_ALLOWED,
129
+ REVIEWS_ERROR_UNKNOWN_TARGET_TYPE,
130
+ toReviewsError,
131
+ } from "./model/refusals.js";
132
+
133
+ // ── headless ─────────────────────────────────────────────────────────────────
134
+ export { ReviewsProvider } from "./headless/ReviewsProvider.js";
135
+ export { ReviewList } from "./headless/ReviewList.js";
136
+ export type { ReviewListBag, ReviewListProps } from "./headless/ReviewList.js";
137
+ export { ReviewAggregate } from "./headless/ReviewAggregate.js";
138
+ export type {
139
+ ReviewAggregateBag,
140
+ ReviewAggregateProps,
141
+ } from "./headless/ReviewAggregate.js";
142
+ export { ReviewForm } from "./headless/ReviewForm.js";
143
+ export type { ReviewFormBag, ReviewFormProps } from "./headless/ReviewForm.js";
144
+
145
+ // ── i18n ─────────────────────────────────────────────────────────────────────
146
+ export {
147
+ REVIEWS_I18N_KEYS,
148
+ registerReviewsI18n,
149
+ reviewsI18nBundleEn,
150
+ } from "./i18n/keys.js";
151
+ export {
152
+ explainReviewsError,
153
+ REVIEWS_ERROR_CODES,
154
+ REVIEWS_ERRORS,
155
+ reviewsErrorBundleEn,
156
+ } from "./i18n/errorsMap.js";
157
+ export type {
158
+ Remediation,
159
+ ReviewsErrorCode,
160
+ ReviewsErrorSpec,
161
+ } from "./i18n/errorsMap.js";
@@ -0,0 +1,28 @@
1
+ import type { Context } from "react";
2
+ import { createModuleContext } from "@stapel/core";
3
+ import type { Analytics, ModuleContextKit } from "@stapel/core";
4
+ import type { ReviewsApi } from "../api/reviewsApi.js";
5
+ import type { ReviewsRuntime } from "./runtime.js";
6
+
7
+ /**
8
+ * The wired ReviewsRuntime shared through React context by
9
+ * `<ReviewsProvider>`. Hooks in `model/` and `headless/` read the singletons
10
+ * from here. One reviewed copy of this plumbing lives in `@stapel/core`
11
+ * (`createModuleContext`, slim wave §21/S2); this module binds it under the
12
+ * pair's public names.
13
+ */
14
+ const kit: ModuleContextKit<ReviewsRuntime> =
15
+ createModuleContext<ReviewsRuntime>("Reviews");
16
+
17
+ export const ReviewsRuntimeContext: Context<ReviewsRuntime | null> =
18
+ kit.RuntimeContext;
19
+
20
+ export const useReviewsRuntime: () => ReviewsRuntime = kit.useRuntime;
21
+
22
+ export const useReviewsApi: () => ReviewsApi = kit.useApi;
23
+
24
+ export const useReviewsAnalytics: () => Analytics | null = kit.useAnalytics;
25
+
26
+ /** @internal Re-exported as `<ReviewsProvider>` from `headless/`. */
27
+ export const ModuleProvider: ModuleContextKit<ReviewsRuntime>["Provider"] =
28
+ kit.Provider;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Pure readers over a loaded list of reviews — no React, no queries, so a
3
+ * card, a test and a server-rendered page can all use them.
4
+ */
5
+ import type { InfiniteData } from "@tanstack/react-query";
6
+ import type { Review, ReviewPage, ReviewStatus } from "../api/types.js";
7
+
8
+ /**
9
+ * Flatten the pages of the infinite list into one newest-first run.
10
+ *
11
+ * Deliberately takes `InfiniteData | undefined` and answers `undefined` for
12
+ * "not loaded", never `[]`: an empty array here is a claim that the target
13
+ * has no reviews, and the caller must get that claim from a READY load state
14
+ * (`stapel/no-flattened-load-state` — the rule exists because a total outage
15
+ * once rendered as "you have no workspaces").
16
+ */
17
+ export function reviewsFromPages(
18
+ data: InfiniteData<ReviewPage, string | undefined> | undefined
19
+ ): readonly Review[] | undefined {
20
+ if (data === undefined) return undefined;
21
+ return data.pages.flatMap((page) => page.items);
22
+ }
23
+
24
+ /**
25
+ * A review's visibility, narrowed — with a fourth arm for a state this build
26
+ * does not know.
27
+ *
28
+ * `status` is a bare `string` on the wire. A build that met a new upstream
29
+ * state and silently rendered it as published would be showing something the
30
+ * server may have hidden; one that crashed on it would take the page down.
31
+ * `"unknown"` is neither: the skin shows the row with an explicit "this
32
+ * version does not know this state" badge, the same shape attributes-react
33
+ * uses for an unsupported feature type.
34
+ */
35
+ export function reviewVisibility(
36
+ status: string
37
+ ): ReviewStatus | "unknown" {
38
+ return status === "published" || status === "pending" || status === "hidden"
39
+ ? status
40
+ : "unknown";
41
+ }
42
+
43
+ /** Is this row one a moderator sees only because they asked for `include=all`? */
44
+ export function isModeratedOut(review: Review): boolean {
45
+ return reviewVisibility(review.status) !== "published";
46
+ }
47
+
48
+ /**
49
+ * The viewer's own review of this target, if it is in the loaded rows.
50
+ *
51
+ * THIS IS AN OPTIMISTIC PRE-CHECK, AND IT HAS A KNOWN HOLE — recorded here
52
+ * because the hole is in the contract, not in the code. The list a
53
+ * non-moderator reads is published-only, so under `moderation: "pre"` the
54
+ * author's own review is INVISIBLE TO ITS AUTHOR while it waits: this
55
+ * function answers `undefined`, the form offers itself again, and the server
56
+ * refuses the second attempt with `error.400.reviews_duplicate_review`.
57
+ *
58
+ * That is why the refusal is a first-class outcome of the form
59
+ * (`headless/ReviewForm.tsx`) rather than something the pre-check was
60
+ * supposed to prevent. The pre-check saves a pointless round trip in the
61
+ * common (post-moderation) case; it is never the authority.
62
+ *
63
+ * `viewerId` must be the id the backend puts in `author_id` — the user id,
64
+ * NOT a profile id. A host that passes the wrong one gets `undefined`, i.e.
65
+ * the form offered where it need not have been, and the server still holds
66
+ * the line.
67
+ */
68
+ export function findOwnReview(
69
+ reviews: readonly Review[] | undefined,
70
+ viewerId: string | null | undefined
71
+ ): Review | undefined {
72
+ if (reviews === undefined || !viewerId) return undefined;
73
+ return reviews.find((review) => review.author_id === viewerId);
74
+ }
@@ -0,0 +1,62 @@
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import type { UseMutationOptions, UseMutationResult } from "@tanstack/react-query";
3
+ import type { StapelApiError } from "@stapel/core";
4
+ import type { Review, ReviewTarget } from "../api/types.js";
5
+ import { useReviewsApi } from "./context.js";
6
+ import { reviewsQueryKeys } from "./queryKeys.js";
7
+
8
+ /** What {@link useSubmitReview} is called with. */
9
+ export interface SubmitReviewVariables {
10
+ /** Inside the deployment's `[RATING_MIN, RATING_MAX]`; the server decides. */
11
+ readonly rating: number;
12
+ /** Optional — a rating with no words is a complete review here. */
13
+ readonly body?: string;
14
+ }
15
+
16
+ /**
17
+ * Write a review of a target (frontend-standard §2 — mutations invalidate on
18
+ * success).
19
+ *
20
+ * ── Why the answer is invalidated rather than spliced in ───────────────────
21
+ *
22
+ * The created row comes back with its `status`, and under the module's
23
+ * default (`MODERATION_DEFAULT: "post"`) that is `published` — so pushing it
24
+ * into the cached window would be correct. Under `moderation: "pre"` it is
25
+ * `pending`, and the SAME row must NOT appear in the list, because the list
26
+ * this reader sees is published-only and a locally-spliced pending review
27
+ * would vanish on the next refetch with no explanation.
28
+ *
29
+ * One behaviour for both policies, and the policy is not visible to the
30
+ * client (no endpoint reports it): invalidate, let the server say what is
31
+ * visible, and carry the created row separately as the SUBMISSION's outcome
32
+ * (`headless/ReviewForm.tsx` renders "sent, awaiting moderation" off its
33
+ * `status`). The aggregate moves for the same reason and is invalidated with
34
+ * it — `avg` and `count` only count published rows.
35
+ */
36
+ export function useSubmitReview(
37
+ target: ReviewTarget
38
+ ): UseMutationResult<Review, StapelApiError, SubmitReviewVariables> {
39
+ const api = useReviewsApi();
40
+ const queryClient = useQueryClient();
41
+ const options: UseMutationOptions<
42
+ Review,
43
+ StapelApiError,
44
+ SubmitReviewVariables
45
+ > = {
46
+ mutationFn: (vars) =>
47
+ api.createReview({
48
+ targetType: target.targetType,
49
+ targetKey: target.targetKey,
50
+ rating: vars.rating,
51
+ ...(vars.body !== undefined ? { body: vars.body } : {}),
52
+ }),
53
+ onSuccess: () => {
54
+ // Both scopes of the list: a moderator's `include=all` window holds the
55
+ // pending row this write may just have created.
56
+ void queryClient.invalidateQueries({
57
+ queryKey: reviewsQueryKeys.target(target),
58
+ });
59
+ },
60
+ };
61
+ return useMutation(options);
62
+ }
@@ -0,0 +1,122 @@
1
+ import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
2
+ import type {
3
+ InfiniteData,
4
+ UseInfiniteQueryResult,
5
+ UseQueryResult,
6
+ } from "@tanstack/react-query";
7
+ import { useActiveSessionReady } from "@stapel/core";
8
+ import type { StapelApiError } from "@stapel/core";
9
+ import type {
10
+ ReviewAggregate,
11
+ ReviewPage,
12
+ ReviewTarget,
13
+ } from "../api/types.js";
14
+ import { useReviewsApi } from "./context.js";
15
+ import { reviewsQueryKeys } from "./queryKeys.js";
16
+
17
+ /**
18
+ * Read hooks over the reviews API.
19
+ *
20
+ * ── Both reads are ANONYMOUS, and both still wait for the session ─────────
21
+ *
22
+ * Since stapel-reviews 0.3.0 a guest reads the list (`IsAuthenticatedOrReadOnly`)
23
+ * and the aggregate (`AllowAny`), so a public listing page shows its reviews
24
+ * to a visitor who will never sign in. Neither hook carries a
25
+ * "sign in first" state any more — that is now only true of the write.
26
+ *
27
+ * They are still gated on {@link useActiveSessionReady}, and the reason got
28
+ * STRONGER rather than weaker with the permission change. What the server
29
+ * returns depends on who is asking: a moderator of the target gets pending
30
+ * and hidden rows for `include=all`, everyone else is silently narrowed to
31
+ * published. A read that races a still-bootstrapping session would therefore
32
+ * succeed — as a guest — and CACHE that answer under a key that does not
33
+ * mention identity. Before 0.3.0 the same race produced a 401, which was at
34
+ * least visible; a silently narrowed page is not. `useActiveSessionReady()`
35
+ * returns `true` the instant the session settles into any of
36
+ * authenticated / anonymous / unauthenticated, and immediately when no
37
+ * session-owning module is mounted at all — so a purely public storefront
38
+ * waits for nothing.
39
+ */
40
+
41
+ /** Default page size for the review list — one screenful. */
42
+ export const REVIEWS_PAGE = 20;
43
+
44
+ export interface UseReviewListOptions {
45
+ /**
46
+ * Ask for pending/hidden rows too — honoured only for a moderator/owner of
47
+ * the target, and narrowed to published for anyone else WITHOUT an error.
48
+ * Part of the query key, because it selects a different set of rows.
49
+ */
50
+ readonly include?: "all";
51
+ readonly limit?: number;
52
+ /** Set `false` to hold the read (e.g. the target id is not resolved yet). */
53
+ readonly enabled?: boolean;
54
+ }
55
+
56
+ /**
57
+ * A target's reviews as an infinite (load-more) list, newest first.
58
+ *
59
+ * Pages are core's `AnchorPagination` envelope — which the schema does not
60
+ * declare (`api/types.ts`) — and the cursor is the previous page's
61
+ * `next_anchor`, a `created_at` timestamp. `has_next` is the authority on
62
+ * whether another page exists; a `next_anchor` is only read when it says yes,
63
+ * because the paginator leaves the anchor `null` on the last page and a
64
+ * cursor derived from the last row instead would re-request it forever.
65
+ */
66
+ export function useReviewList(
67
+ target: ReviewTarget,
68
+ options: UseReviewListOptions = {}
69
+ ): UseInfiniteQueryResult<
70
+ InfiniteData<ReviewPage, string | undefined>,
71
+ StapelApiError
72
+ > {
73
+ const api = useReviewsApi();
74
+ const sessionReady = useActiveSessionReady();
75
+ const limit = options.limit ?? REVIEWS_PAGE;
76
+ const addressable =
77
+ target.targetType.length > 0 && target.targetKey.length > 0;
78
+ return useInfiniteQuery({
79
+ queryKey: reviewsQueryKeys.list(target, options.include),
80
+ queryFn: ({ pageParam, signal }) =>
81
+ api.reviews(
82
+ {
83
+ ...target,
84
+ ...(options.include !== undefined ? { include: options.include } : {}),
85
+ direction: "next",
86
+ limit,
87
+ ...(pageParam !== undefined ? { anchor: pageParam } : {}),
88
+ },
89
+ { signal }
90
+ ),
91
+ initialPageParam: undefined as string | undefined,
92
+ getNextPageParam: (last) =>
93
+ last.has_next ? (last.next_anchor ?? undefined) : undefined,
94
+ enabled: sessionReady && addressable && (options.enabled ?? true),
95
+ });
96
+ }
97
+
98
+ /**
99
+ * The module-owned aggregate for one target.
100
+ *
101
+ * NOT the way to put a rating on every card of a search result: that is one
102
+ * request per card against an endpoint that computes an `AVG` each time. The
103
+ * batch read exists (`reviews.aggregates_by_keys`) but it is a comm Function
104
+ * for a server-side projection, not an HTTP endpoint — a storefront gets
105
+ * per-card ratings from the composite's projection, embedded in the rows its
106
+ * catalogue module already serves, and renders them with
107
+ * {@link ratingSummary} without calling this at all.
108
+ */
109
+ export function useReviewAggregate(
110
+ target: ReviewTarget,
111
+ options: { readonly enabled?: boolean } = {}
112
+ ): UseQueryResult<ReviewAggregate, StapelApiError> {
113
+ const api = useReviewsApi();
114
+ const sessionReady = useActiveSessionReady();
115
+ const addressable =
116
+ target.targetType.length > 0 && target.targetKey.length > 0;
117
+ return useQuery({
118
+ queryKey: reviewsQueryKeys.aggregate(target),
119
+ queryFn: ({ signal }) => api.aggregate(target, { signal }),
120
+ enabled: sessionReady && addressable && (options.enabled ?? true),
121
+ });
122
+ }