@stapel/reviews-react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -0
- package/MODULE.md +151 -0
- package/README.md +207 -0
- package/dist/api/generated/schema.d.ts +360 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/reviewsApi.d.ts +89 -0
- package/dist/api/reviewsApi.d.ts.map +1 -0
- package/dist/api/reviewsApi.js +51 -0
- package/dist/api/reviewsApi.js.map +1 -0
- package/dist/api/types.d.ts +127 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +2 -0
- package/dist/api/types.js.map +1 -0
- package/dist/default/ErrorAlert.d.ts +9 -0
- package/dist/default/ErrorAlert.d.ts.map +1 -0
- package/dist/default/ErrorAlert.js +27 -0
- package/dist/default/ErrorAlert.js.map +1 -0
- package/dist/default/RatingBadge.d.ts +17 -0
- package/dist/default/RatingBadge.d.ts.map +1 -0
- package/dist/default/RatingBadge.js +23 -0
- package/dist/default/RatingBadge.js.map +1 -0
- package/dist/default/ReviewFormCard.d.ts +25 -0
- package/dist/default/ReviewFormCard.d.ts.map +1 -0
- package/dist/default/ReviewFormCard.js +42 -0
- package/dist/default/ReviewFormCard.js.map +1 -0
- package/dist/default/ReviewListPanel.d.ts +29 -0
- package/dist/default/ReviewListPanel.d.ts.map +1 -0
- package/dist/default/ReviewListPanel.js +47 -0
- package/dist/default/ReviewListPanel.js.map +1 -0
- package/dist/default/ReviewsPanel.d.ts +33 -0
- package/dist/default/ReviewsPanel.d.ts.map +1 -0
- package/dist/default/ReviewsPanel.js +17 -0
- package/dist/default/ReviewsPanel.js.map +1 -0
- package/dist/default/index.d.ts +31 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +25 -0
- package/dist/default/index.js.map +1 -0
- package/dist/default/theme.d.ts +24 -0
- package/dist/default/theme.d.ts.map +1 -0
- package/dist/default/theme.js +39 -0
- package/dist/default/theme.js.map +1 -0
- package/dist/default/types.d.ts +12 -0
- package/dist/default/types.d.ts.map +1 -0
- package/dist/default/types.js +2 -0
- package/dist/default/types.js.map +1 -0
- package/dist/headless/ReviewAggregate.d.ts +63 -0
- package/dist/headless/ReviewAggregate.d.ts.map +1 -0
- package/dist/headless/ReviewAggregate.js +55 -0
- package/dist/headless/ReviewAggregate.js.map +1 -0
- package/dist/headless/ReviewForm.d.ts +83 -0
- package/dist/headless/ReviewForm.d.ts.map +1 -0
- package/dist/headless/ReviewForm.js +75 -0
- package/dist/headless/ReviewForm.js.map +1 -0
- package/dist/headless/ReviewList.d.ts +47 -0
- package/dist/headless/ReviewList.d.ts.map +1 -0
- package/dist/headless/ReviewList.js +51 -0
- package/dist/headless/ReviewList.js.map +1 -0
- package/dist/headless/ReviewsProvider.d.ts +19 -0
- package/dist/headless/ReviewsProvider.d.ts.map +1 -0
- package/dist/headless/ReviewsProvider.js +15 -0
- package/dist/headless/ReviewsProvider.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +12 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +22 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +19 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +66 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +58 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +341 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +174 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +58 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +59 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +115 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +22 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +70 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +98 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +92 -0
- package/dist/index.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +16 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/list.d.ts +52 -0
- package/dist/model/list.d.ts.map +1 -0
- package/dist/model/list.js +60 -0
- package/dist/model/list.js.map +1 -0
- package/dist/model/mutations.d.ts +32 -0
- package/dist/model/mutations.d.ts.map +1 -0
- package/dist/model/mutations.js +44 -0
- package/dist/model/mutations.js.map +1 -0
- package/dist/model/queries.d.ts +65 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +80 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +27 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +14 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/rating.d.ts +57 -0
- package/dist/model/rating.d.ts.map +1 -0
- package/dist/model/rating.js +47 -0
- package/dist/model/rating.js.map +1 -0
- package/dist/model/refusals.d.ts +51 -0
- package/dist/model/refusals.d.ts.map +1 -0
- package/dist/model/refusals.js +103 -0
- package/dist/model/refusals.js.map +1 -0
- package/dist/model/runtime.d.ts +44 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +15 -0
- package/dist/model/runtime.js.map +1 -0
- package/llms.txt +80 -0
- package/manifest.json +577 -0
- package/package.json +115 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/generated/schema.ts +362 -0
- package/src/api/reviewsApi.ts +170 -0
- package/src/api/types.ts +140 -0
- package/src/default/ErrorAlert.tsx +46 -0
- package/src/default/RatingBadge.tsx +81 -0
- package/src/default/ReviewFormCard.tsx +139 -0
- package/src/default/ReviewListPanel.tsx +204 -0
- package/src/default/ReviewsPanel.tsx +74 -0
- package/src/default/index.ts +30 -0
- package/src/default/theme.tsx +66 -0
- package/src/default/types.ts +12 -0
- package/src/headless/ReviewAggregate.tsx +94 -0
- package/src/headless/ReviewForm.tsx +163 -0
- package/src/headless/ReviewList.tsx +99 -0
- package/src/headless/ReviewsProvider.tsx +20 -0
- package/src/i18n/errorsMap.ts +33 -0
- package/src/i18n/es.ts +81 -0
- package/src/i18n/generated/errors.es.gen.ts +64 -0
- package/src/i18n/generated/errors.gen.ts +205 -0
- package/src/i18n/generated/errors.json +387 -0
- package/src/i18n/generated/errors.ru.gen.ts +64 -0
- package/src/i18n/keys.ts +133 -0
- package/src/i18n/ru.ts +85 -0
- package/src/index.ts +161 -0
- package/src/model/context.tsx +28 -0
- package/src/model/list.ts +74 -0
- package/src/model/mutations.ts +62 -0
- package/src/model/queries.ts +122 -0
- package/src/model/queryKeys.ts +45 -0
- package/src/model/rating.ts +101 -0
- package/src/model/refusals.ts +114 -0
- package/src/model/runtime.ts +61 -0
- package/tsconfig.json +26 -0
package/src/i18n/keys.ts
ADDED
|
@@ -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
|
+
}
|