@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,94 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import { loadFailed, loadLoading, loadReady } from "@stapel/core";
3
+ import type { LoadState } from "@stapel/core";
4
+ import type { RatingAggregate, ReviewTarget } from "../api/types.js";
5
+ import { useReviewsRuntime } from "../model/context.js";
6
+ import { useReviewAggregate } from "../model/queries.js";
7
+ import { ratingSummary } from "../model/rating.js";
8
+ import type { RatingSummary } from "../model/rating.js";
9
+
10
+ /** What `<ReviewAggregate>` hands its render prop. */
11
+ export interface ReviewAggregateBag {
12
+ /**
13
+ * The rating, READ — `rated: false` when nobody has rated the target, so a
14
+ * skin can never print the zero the wire carries in that case.
15
+ */
16
+ readonly state: LoadState<RatingSummary>;
17
+ /** Where the number came from. */
18
+ readonly source: "fetched" | "supplied";
19
+ /** The deployment's rating ceiling, for a star row that draws `max` stars. */
20
+ readonly max: number;
21
+ }
22
+
23
+ export interface ReviewAggregateProps {
24
+ /**
25
+ * What this rating is ABOUT. Always required, even when {@link aggregate}
26
+ * is supplied and nothing is fetched: it is the identity of the thing being
27
+ * rated, it keys the cache, and a rating rendered without one is a number
28
+ * nobody can trace back to a subject.
29
+ */
30
+ readonly target: ReviewTarget;
31
+ /**
32
+ * Numbers somebody else computed. Supplying them makes this component a
33
+ * pure display: no request is made, and `source` reports `"supplied"`.
34
+ */
35
+ readonly aggregate?: RatingAggregate;
36
+ readonly children: (bag: ReviewAggregateBag) => ReactNode;
37
+ }
38
+
39
+ /**
40
+ * The headless rating display — the same bag whether the number came from
41
+ * this module or from the composite. `AllowAny` upstream since 0.3.0, so a
42
+ * guest sees the rating and there is no "sign in" state here.
43
+ *
44
+ * ── Why `aggregate` is a prop, and not a second endpoint ───────────────────
45
+ *
46
+ * stapel-reviews can aggregate exactly ONE `(target_type, target_key)` per
47
+ * call. The storefront's product decision (spec fork F5) is that a review
48
+ * targets the SELLER for a specific listing — `target_type: "listing"`, one
49
+ * per author per listing — and the seller's own rating is therefore a ROLL-UP
50
+ * across every listing they own. No endpoint computes that: the composite
51
+ * does, server-side, as `shop.listing_review_summary`
52
+ * (`stapel_shop/projections.py`), and the roll-up over a seller's keys is the
53
+ * composite's projection to publish, on its own route.
54
+ *
55
+ * So this pair does the half it can honestly do: it renders `{avg, count}` —
56
+ * the projection's own field names, chosen upstream precisely so both modes
57
+ * answer one shape — from wherever the host got them. `source` says which,
58
+ * so a skin that wants to (a demo, an admin view) can show it. A seller
59
+ * header therefore reads
60
+ * `<ReviewAggregate target={{targetType: "seller", targetKey: id}}
61
+ * aggregate={rollup}>`: the target names the subject, the numbers come from
62
+ * the composite, and no request is made against an endpoint that could not
63
+ * have answered it.
64
+ *
65
+ * The gap is real and is recorded in the README rather than papered over with
66
+ * N+1 requests: a page that wants a seller rating today must be served the
67
+ * two numbers by its own backend.
68
+ */
69
+ export function ReviewAggregate(props: ReviewAggregateProps): ReactElement {
70
+ const runtime = useReviewsRuntime();
71
+ const supplied = props.aggregate;
72
+ const query = useReviewAggregate(props.target, {
73
+ enabled: supplied === undefined,
74
+ });
75
+
76
+ const state: LoadState<RatingSummary> =
77
+ supplied !== undefined
78
+ ? loadReady(ratingSummary(supplied))
79
+ : query.status === "error"
80
+ ? loadFailed(query.error)
81
+ : query.data === undefined
82
+ ? loadLoading()
83
+ : loadReady(ratingSummary(query.data));
84
+
85
+ return (
86
+ <>
87
+ {props.children({
88
+ state,
89
+ source: supplied !== undefined ? "supplied" : "fetched",
90
+ max: runtime.ratingBounds.max,
91
+ })}
92
+ </>
93
+ );
94
+ }
@@ -0,0 +1,163 @@
1
+ import { useCallback, useState } from "react";
2
+ import type { ReactElement, ReactNode } from "react";
3
+ import { actionAvailable, actionBlocked, firstBlock } from "@stapel/core";
4
+ import type { ActionAvailability, FlowError } from "@stapel/core";
5
+ import type { Review, ReviewTarget } from "../api/types.js";
6
+ import { REVIEWS_I18N_KEYS } from "../i18n/keys.js";
7
+ import { useReviewsRuntime } from "../model/context.js";
8
+ import { useSubmitReview } from "../model/mutations.js";
9
+ import { reviewVisibility } from "../model/list.js";
10
+ import type { ReviewRatingBounds } from "../model/runtime.js";
11
+ import {
12
+ isDuplicateReview,
13
+ isReviewingForbidden,
14
+ isSignInRequired,
15
+ toReviewsError,
16
+ } from "../model/refusals.js";
17
+
18
+ /** What `<ReviewForm>` hands its render prop. */
19
+ export interface ReviewFormBag {
20
+ /** The chosen rating, or `null` while nothing is chosen. */
21
+ readonly rating: number | null;
22
+ readonly setRating: (rating: number) => void;
23
+ readonly body: string;
24
+ readonly setBody: (body: string) => void;
25
+ /** The deployment's inclusive bounds — the star row draws exactly these. */
26
+ readonly bounds: ReviewRatingBounds;
27
+ /** Send it. A no-op while {@link canSubmit} is blocked. */
28
+ readonly submit: () => void;
29
+ /** Whether the button may be pressed, and if not, the sentence saying why. */
30
+ readonly canSubmit: ActionAvailability;
31
+ readonly submitting: boolean;
32
+ /**
33
+ * The persisted review, once it exists. Its `status` is the whole point:
34
+ * `published` means it is already on the page, `pending` means the
35
+ * deployment pre-moderates and it is NOT — and the person who just wrote it
36
+ * must be told that rather than left looking for it.
37
+ */
38
+ readonly submitted: Review | null;
39
+ /** `submitted?.status`, narrowed, with a fourth arm for an unknown state. */
40
+ readonly submittedVisibility: "published" | "pending" | "hidden" | "unknown" | null;
41
+ /**
42
+ * The server said this author has already reviewed this target
43
+ * (`error.400.reviews_duplicate_review` — a 400, not a 409; see
44
+ * `model/refusals.ts`). Or the host told us so up front via
45
+ * `alreadyReviewed`.
46
+ */
47
+ readonly alreadyReviewed: boolean;
48
+ /**
49
+ * The write failed with 401 — the form was offered to a signed-out reader.
50
+ *
51
+ * The LAST place this state survives. Since stapel-reviews 0.3.0 both reads
52
+ * are anonymous (`IsAuthenticatedOrReadOnly` on the list class, `AllowAny`
53
+ * on the aggregate), so a guest reads the reviews and only the POST refuses
54
+ * — there has to be an author to attribute the review to. That makes this a
55
+ * prompt to sign in at the moment it is actually true, rather than a wall in
56
+ * front of content.
57
+ */
58
+ readonly signInRequired: boolean;
59
+ /** The type's `can_review` callback refused this author for this target. */
60
+ readonly forbidden: boolean;
61
+ /**
62
+ * The refusal, for the error surface — `undefined` once it has been
63
+ * translated into one of the named flags above AND that flag is what the
64
+ * skin renders. Duplicate/forbidden/sign-in are named states with their own
65
+ * copy; everything else (an invalid rating the mirror let through, an
66
+ * outage) stays here.
67
+ */
68
+ readonly error: FlowError | undefined;
69
+ }
70
+
71
+ export interface ReviewFormProps {
72
+ readonly target: ReviewTarget;
73
+ /**
74
+ * The host already knows this author has reviewed the target — typically
75
+ * from `findOwnReview()` over the loaded list. An OPTIMISTIC input: it
76
+ * cannot see a pending review (the list is published-only), so a `false`
77
+ * here never means "the server will accept this".
78
+ */
79
+ readonly alreadyReviewed?: boolean;
80
+ readonly children: (bag: ReviewFormBag) => ReactNode;
81
+ }
82
+
83
+ /**
84
+ * The headless "rate this" form.
85
+ *
86
+ * The one rule it exists to hold: **the refusal is read by code, never by
87
+ * status**. "You have already reviewed this" arrives as a 400 whose code is
88
+ * `error.400.reviews_duplicate_review`, while the module's only 409 means the
89
+ * owner's reply already exists. A form that branched on the number would miss
90
+ * the first and mishandle the second.
91
+ *
92
+ * The second rule: a submitted review is not necessarily a visible one. The
93
+ * bag reports the created row's `status` so a pre-moderated deployment can
94
+ * say "sent — it will appear once it is checked", instead of leaving the
95
+ * author to hunt for a review that is deliberately invisible.
96
+ */
97
+ export function ReviewForm(props: ReviewFormProps): ReactElement {
98
+ const { target, alreadyReviewed = false, children } = props;
99
+ const runtime = useReviewsRuntime();
100
+ const mutation = useSubmitReview(target);
101
+ // Destructured because `mutation` is a new object every render while
102
+ // `mutate` is stable (TanStack v5 contract) — depending on the object would
103
+ // rebuild `submit` on every state change and re-render the whole render
104
+ // prop below it.
105
+ const { mutate } = mutation;
106
+ const [rating, setRating] = useState<number | null>(null);
107
+ const [body, setBody] = useState("");
108
+
109
+ const submitted = mutation.data ?? null;
110
+ const failure = mutation.error ?? undefined;
111
+ const duplicate = failure !== undefined && isDuplicateReview(failure);
112
+ const forbidden = failure !== undefined && isReviewingForbidden(failure);
113
+ const signInRequired = failure !== undefined && isSignInRequired(failure);
114
+ const alreadyKnown = alreadyReviewed || duplicate;
115
+
116
+ const canSubmit = firstBlock(
117
+ alreadyKnown
118
+ ? actionBlocked(REVIEWS_I18N_KEYS.submitBlockedDuplicate)
119
+ : actionAvailable(),
120
+ submitted !== null
121
+ ? actionBlocked(REVIEWS_I18N_KEYS.submitBlockedSubmitted)
122
+ : actionAvailable(),
123
+ mutation.isPending
124
+ ? actionBlocked(REVIEWS_I18N_KEYS.submitBlockedPending)
125
+ : actionAvailable(),
126
+ rating === null
127
+ ? actionBlocked(REVIEWS_I18N_KEYS.submitBlockedNoRating)
128
+ : actionAvailable(),
129
+ forbidden
130
+ ? actionBlocked(REVIEWS_I18N_KEYS.submitBlockedForbidden)
131
+ : actionAvailable()
132
+ );
133
+
134
+ const submit = useCallback(() => {
135
+ if (rating === null) return;
136
+ mutate({ rating, ...(body.length > 0 ? { body } : {}) });
137
+ }, [rating, body, mutate]);
138
+
139
+ return (
140
+ <>
141
+ {children({
142
+ rating,
143
+ setRating,
144
+ body,
145
+ setBody,
146
+ bounds: runtime.ratingBounds,
147
+ submit,
148
+ canSubmit,
149
+ submitting: mutation.isPending,
150
+ submitted,
151
+ submittedVisibility:
152
+ submitted === null ? null : reviewVisibility(submitted.status),
153
+ alreadyReviewed: alreadyKnown,
154
+ signInRequired,
155
+ forbidden,
156
+ error:
157
+ failure === undefined || duplicate || forbidden || signInRequired
158
+ ? undefined
159
+ : toReviewsError(failure),
160
+ })}
161
+ </>
162
+ );
163
+ }
@@ -0,0 +1,99 @@
1
+ import { useCallback } from "react";
2
+ import type { ReactElement, ReactNode } from "react";
3
+ import {
4
+ actionAvailable,
5
+ actionBlocked,
6
+ loadFailed,
7
+ loadLoading,
8
+ loadReady,
9
+ } from "@stapel/core";
10
+ import type { ActionAvailability, LoadState } from "@stapel/core";
11
+ import type { Review, ReviewTarget } from "../api/types.js";
12
+ import { REVIEWS_I18N_KEYS } from "../i18n/keys.js";
13
+ import { reviewsFromPages } from "../model/list.js";
14
+ import { useReviewList } from "../model/queries.js";
15
+ import type { UseReviewListOptions } from "../model/queries.js";
16
+
17
+ /** What `<ReviewList>` hands its render prop. */
18
+ export interface ReviewListBag {
19
+ readonly target: ReviewTarget;
20
+ /**
21
+ * The rows, newest first — `loading` / `ready` / `failed`, never a
22
+ * defaulted empty array. An empty READY list is the only honest way to say
23
+ * "this target has no reviews".
24
+ */
25
+ readonly state: LoadState<readonly Review[]>;
26
+ /** Ask for the next (older) page. */
27
+ readonly loadMore: () => void;
28
+ /** Whether there is another page, and if not, why the control is off. */
29
+ readonly more: ActionAvailability;
30
+ /** A next page is in flight (the rows already on screen stay on screen). */
31
+ readonly loadingMore: boolean;
32
+ /** Re-read the first page. */
33
+ readonly refresh: () => void;
34
+ /**
35
+ * The scope actually requested. `"all"` means pending/hidden rows were
36
+ * ASKED for — the server grants them only to a moderator/owner and narrows
37
+ * everyone else silently, so this is never a promise that they are here.
38
+ */
39
+ readonly include: "all" | undefined;
40
+ }
41
+
42
+ export interface ReviewListProps extends UseReviewListOptions {
43
+ readonly target: ReviewTarget;
44
+ readonly children: (bag: ReviewListBag) => ReactNode;
45
+ }
46
+
47
+ /**
48
+ * The headless review list: anchor-paginated, newest first, over one opaque
49
+ * `(target_type, target_key)`, readable by anyone.
50
+ *
51
+ * There is no `signInRequired` here: since stapel-reviews 0.3.0 the list is
52
+ * `IsAuthenticatedOrReadOnly`, so a guest gets the published rows. An empty
53
+ * READY list therefore means what it says — nobody has reviewed this target —
54
+ * and it is a state a signed-out visitor can legitimately reach.
55
+ *
56
+ * No markup, no strings — a render prop and a bag (frontend-standard §2). The
57
+ * antd rendering of the same bag is `@stapel/reviews-react/default`'s
58
+ * `<ReviewListPanel>`.
59
+ */
60
+ export function ReviewList(props: ReviewListProps): ReactElement {
61
+ const { target, children, ...options } = props;
62
+ const query = useReviewList(target, options);
63
+ const { fetchNextPage, refetch } = query;
64
+
65
+ const loadMore = useCallback(() => {
66
+ void fetchNextPage();
67
+ }, [fetchNextPage]);
68
+ const refresh = useCallback(() => {
69
+ void refetch();
70
+ }, [refetch]);
71
+
72
+ const rows = reviewsFromPages(query.data);
73
+ const state: LoadState<readonly Review[]> =
74
+ query.status === "error"
75
+ ? loadFailed(query.error)
76
+ : rows === undefined
77
+ ? loadLoading()
78
+ : loadReady(rows);
79
+
80
+ const more: ActionAvailability = query.hasNextPage
81
+ ? query.isFetchingNextPage
82
+ ? actionBlocked(REVIEWS_I18N_KEYS.moreBlockedPending)
83
+ : actionAvailable()
84
+ : actionBlocked(REVIEWS_I18N_KEYS.moreBlockedEnd);
85
+
86
+ return (
87
+ <>
88
+ {children({
89
+ target,
90
+ state,
91
+ loadMore,
92
+ more,
93
+ loadingMore: query.isFetchingNextPage,
94
+ refresh,
95
+ include: options.include,
96
+ })}
97
+ </>
98
+ );
99
+ }
@@ -0,0 +1,20 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import { ModuleProvider } from "../model/context.js";
3
+ import type { ReviewsRuntime } from "../model/runtime.js";
4
+
5
+ /**
6
+ * Provides the wired {@link ReviewsRuntime} to every reviews hook and headless
7
+ * component below it. Bring your own visual shell — this component renders
8
+ * nothing of its own. (Core's `createModuleContext` provider, bound to this
9
+ * pair — slim wave §21/S2.)
10
+ *
11
+ * ```tsx
12
+ * const runtime = createReviewsRuntime({ baseUrl: "/reviews/api/v1" });
13
+ * // give runtime.client to core's <StapelConfigProvider config={{ client }}>
14
+ * <ReviewsProvider runtime={runtime}>{app}</ReviewsProvider>
15
+ * ```
16
+ */
17
+ export const ReviewsProvider: (props: {
18
+ runtime: ReviewsRuntime;
19
+ children: ReactNode;
20
+ }) => ReactElement = ModuleProvider;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The pair's error map (frontend-standard §4 checklist #7): the generated
3
+ * `code → { status, params, remediation, en }` catalog plus a tiny `explain()`
4
+ * lookup. Backs the manifest `errors` block and gives hosts a mechanical UX
5
+ * branch beside `t(code, params)`. The map itself is generated from the
6
+ * backend registry (`pnpm gen:errors`); this file only adds the lookup helper
7
+ * and re-exports the public surface.
8
+ */
9
+ import { REVIEWS_ERRORS } from "./generated/errors.gen.js";
10
+ import type { Remediation } from "./generated/errors.gen.js";
11
+
12
+ export {
13
+ REVIEWS_ERRORS,
14
+ REVIEWS_ERROR_CODES,
15
+ reviewsErrorBundleEn,
16
+ } from "./generated/errors.gen.js";
17
+ export type {
18
+ ReviewsErrorCode,
19
+ ReviewsErrorSpec,
20
+ Remediation,
21
+ } from "./generated/errors.gen.js";
22
+
23
+ /**
24
+ * Resolve a backend error code to its remediation hint, or `undefined` for a
25
+ * code this module doesn't know (a cross-cutting `stapel.http.*` fallback, or
26
+ * one of this pair's own client-side rules such as
27
+ * `reviews.submit.blocked.no_rating` — which is not a backend code and
28
+ * deliberately has no entry here).
29
+ */
30
+ export function explainReviewsError(code: string): Remediation | undefined {
31
+ return (REVIEWS_ERRORS as Record<string, { remediation: Remediation }>)[code]
32
+ ?.remediation;
33
+ }
package/src/i18n/es.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type { I18nDictionary, I18nEngine } from "@stapel/core";
2
+ import { reviewsErrorBundleEs } from "./generated/errors.es.gen.js";
3
+
4
+ export { reviewsErrorBundleEs } from "./generated/errors.es.gen.js";
5
+
6
+ /**
7
+ * Spanish bundle for reviews-react — the `@stapel/reviews-react/i18n/es`
8
+ * subpath (i18n-shipping.md §2), opt-in exactly like `./i18n/ru`.
9
+ *
10
+ * Same two sources, same reason (see `./ru.ts`): 42 cross-cutting keys come
11
+ * from stapel-core's catalogue through the generated bundle, and the 9 keys
12
+ * stapel-reviews owns are authored below until upstream ships
13
+ * `translations/errors.es.json`.
14
+ *
15
+ * The UI copy is carried here too, not just the error keys: a review form is
16
+ * a surface a buyer reads word by word, and half a translation is worse than
17
+ * either language on its own (the chat-react precedent).
18
+ */
19
+ export const reviewsI18nBundleEs: I18nDictionary = {
20
+ // Backend error codes — generated es texts for every key core owns.
21
+ ...reviewsErrorBundleEs,
22
+
23
+ // Backend error codes stapel-reviews owns — authored here.
24
+ "error.400.reviews_duplicate_review": "Ya has valorado esto",
25
+ "error.400.reviews_invalid_moderation_action":
26
+ "La acción de moderación debe ser una de: ocultar, publicar",
27
+ "error.400.reviews_invalid_rating": "La valoración está fuera del rango permitido",
28
+ "error.400.reviews_response_not_allowed":
29
+ "Las respuestas están desactivadas para este tipo de objeto",
30
+ "error.400.reviews_unknown_target_type": "Tipo de objeto de reseña desconocido",
31
+ "error.403.reviews_cannot_moderate":
32
+ "No puedes moderar las reseñas de este objeto",
33
+ "error.403.reviews_cannot_review": "No puedes reseñar este objeto",
34
+ "error.404.reviews_review_not_found": "Reseña no encontrada",
35
+ "error.409.reviews_already_responded": "Esta reseña ya tiene una respuesta",
36
+
37
+ // UI copy.
38
+ "reviews.error.unknown": "Algo ha fallado con las reseñas",
39
+
40
+ "reviews.list.heading": "Reseñas",
41
+ "reviews.list.empty": "Todavía no hay reseñas",
42
+ "reviews.list.load_more": "Ver más",
43
+ "reviews.list.refresh": "Actualizar",
44
+ "reviews.list.more.blocked.end": "Eso es todo",
45
+ "reviews.list.more.blocked.pending": "Cargando…",
46
+
47
+ "reviews.review.author_fallback": "Un cliente",
48
+ "reviews.review.response_heading": "Respuesta del vendedor",
49
+
50
+ "reviews.status.pending": "Pendiente de moderación",
51
+ "reviews.status.hidden": "Oculta por moderación",
52
+ "reviews.status.unknown": "Estado desconocido: {status}",
53
+
54
+ "reviews.rating.none": "Todavía no hay reseñas",
55
+ "reviews.rating.value": "{avg} de {max}",
56
+ "reviews.rating.count": "{count} reseñas",
57
+ "reviews.rating.star_label": "{index} de {max}",
58
+
59
+ "reviews.form.heading": "Valora esto",
60
+ "reviews.form.rating_label": "Tu valoración",
61
+ "reviews.form.body_label": "Tu reseña",
62
+ "reviews.form.body_placeholder": "¿Qué tal fue el trato? (opcional)",
63
+ "reviews.form.submit": "Enviar",
64
+ "reviews.form.sent.published": "Gracias — tu reseña está publicada",
65
+ "reviews.form.sent.pending":
66
+ "Gracias — tu reseña aparecerá cuando se haya revisado",
67
+ "reviews.form.sent.hidden": "Tu reseña se ha guardado, pero no se muestra",
68
+ "reviews.form.sent.unknown": "Tu reseña se ha guardado",
69
+ "reviews.form.sign_in_required": "Inicia sesión para dejar una reseña",
70
+
71
+ "reviews.submit.blocked.no_rating": "Elige primero una valoración",
72
+ "reviews.submit.blocked.pending": "Enviando…",
73
+ "reviews.submit.blocked.duplicate": "Ya has valorado esto",
74
+ "reviews.submit.blocked.submitted": "Tu reseña se ha enviado",
75
+ "reviews.submit.blocked.forbidden": "No puedes reseñar esto",
76
+ };
77
+
78
+ /** Register the Spanish bundle into a core i18n engine. */
79
+ export function registerReviewsI18nEs(engine: I18nEngine, locale = "es"): void {
80
+ engine.registerBundle(locale, reviewsI18nBundleEs);
81
+ }
@@ -0,0 +1,64 @@
1
+ // AUTO-GENERATED by scripts/gen-errors.mjs — do not edit by hand.
2
+ // Source: stapel-auth canonical errors.json (backend codegen artifact).
3
+ // Regenerate: pnpm gen:errors · Drift gate: pnpm gen:errors:check
4
+ // Locale source: backend translations/errors.es.json (locale catalog
5
+ // beside the canonical errors.json — i18n-shipping.md §1).
6
+ import type { ReviewsErrorCode } from "./errors.gen.js";
7
+
8
+ /**
9
+ * `es` texts for the backend error codes this catalog carries.
10
+ *
11
+ * PARTIAL, and deliberately typed to say so: 9 key(s) owned by
12
+ * stapel_reviews are absent, because that owner ships no locale catalog
13
+ * (ERRORS_LOCALE_EXEMPT_OWNERS). English for them still comes from the registry
14
+ * artifact via the en bundle; the pair layers its own authored `es`
15
+ * strings over this one until upstream ships translations. `Partial` is what
16
+ * makes the gap visible to TypeScript instead of a silent English fallback.
17
+ *
18
+ * Import from the pair's `./i18n/es` subpath, NOT the main entry, so
19
+ * the locale stays out of hosts that don't ship it.
20
+ */
21
+ export const reviewsErrorBundleEs: Partial<Record<ReviewsErrorCode, string>> = {
22
+ "error.400.bad_request": "Solicitud incorrecta",
23
+ "error.400.captcha_invalid": "La verificación del captcha ha fallado. Inténtalo de nuevo.",
24
+ "error.400.captcha_required": "Se requiere el token del captcha.",
25
+ "error.400.expected_list": "Se esperaba una lista de elementos",
26
+ "error.400.field.blank": "{field} no puede estar vacío",
27
+ "error.400.field.does_not_exist": "{field} no existe",
28
+ "error.400.field.invalid": "{field} no es válido",
29
+ "error.400.field.invalid_choice": "{field} no es una opción válida",
30
+ "error.400.field.max_length": "{field} debe tener como máximo {max_length} caracteres",
31
+ "error.400.field.max_value": "{field} debe ser como máximo {max_value}",
32
+ "error.400.field.min_length": "{field} debe tener al menos {min_length} caracteres",
33
+ "error.400.field.min_value": "{field} debe ser como mínimo {min_value}",
34
+ "error.400.field.null": "{field} no puede ser nulo",
35
+ "error.400.field.required": "{field} es obligatorio",
36
+ "error.400.field.unique": "{field} debe ser único",
37
+ "error.400.invalid_ad_id": "ID de anuncio no válido",
38
+ "error.400.validation_error": "Error de validación",
39
+ "error.400.verification_failed": "La verificación ha fallado",
40
+ "error.400.verification_invalid_factor": "Este factor de verificación no está disponible",
41
+ "error.401.unauthorized": "Se requiere autenticación",
42
+ "error.402.payment_required": "Se requiere pago",
43
+ "error.403.forbidden": "No tienes permiso para realizar esta acción",
44
+ "error.403.network_blocked": "No se permiten solicitudes desde esta red.",
45
+ "error.403.verification_enrollment_required": "Es necesario registrar un factor de verificación.",
46
+ "error.403.verification_required": "Se requiere verificación adicional",
47
+ "error.404.ad_not_found": "Anuncio no encontrado",
48
+ "error.404.not_found": "Recurso solicitado no encontrado",
49
+ "error.404.verification_challenge_not_found": "Desafío de verificación no encontrado o caducado",
50
+ "error.405.method_not_allowed": "Método no permitido",
51
+ "error.406.not_acceptable": "No aceptable",
52
+ "error.408.request_timeout": "Tiempo de espera de la solicitud agotado",
53
+ "error.409.conflict": "El recurso ya existe",
54
+ "error.410.gone": "El recurso se ha eliminado permanentemente",
55
+ "error.413.payload_too_large": "El cuerpo de la solicitud es demasiado grande",
56
+ "error.415.unsupported_media_type": "Tipo de contenido no compatible",
57
+ "error.422.unprocessable_entity": "Entidad no procesable",
58
+ "error.423.locked": "El recurso está bloqueado",
59
+ "error.423.verification_locked": "Demasiados intentos fallidos — verificación bloqueada",
60
+ "error.429.rate_limit": "Demasiados intentos. Inténtalo de nuevo en {retry_after_minutes} minutos.",
61
+ "error.429.too_many_requests": "Demasiadas solicitudes. Inténtalo de nuevo más tarde.",
62
+ "error.500.internal": "Algo salió mal",
63
+ "error.503.mandate_unavailable": "No se puede verificar el mandato del espacio de trabajo",
64
+ };