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