@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,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one error surface this pair's default skin renders, so the split copy
|
|
3
|
+
* core produces reaches a screen the same way everywhere (mirrors the
|
|
4
|
+
* docs/notifications/workspaces etalon).
|
|
5
|
+
*
|
|
6
|
+
* Core's `describeFlowError` splits a failure into the sentence a person
|
|
7
|
+
* reads and the technical detail a support agent quotes (owner report
|
|
8
|
+
* 2026-08-09: the status used to be spliced into the sentence, ending in a
|
|
9
|
+
* bare `" (500)"`). The split only helps if a skin renders both halves at
|
|
10
|
+
* their own weights: `message` at normal weight, `detail` muted and small.
|
|
11
|
+
*
|
|
12
|
+
* `undefined` in — including a `detail` core left `undefined` because there
|
|
13
|
+
* was nothing worth quoting — renders nothing rather than an empty line.
|
|
14
|
+
*/
|
|
15
|
+
import { Alert, Typography } from "antd";
|
|
16
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
17
|
+
import type { FlowErrorDisplay } from "@stapel/core";
|
|
18
|
+
|
|
19
|
+
export function ErrorAlert(props: {
|
|
20
|
+
error: FlowErrorDisplay | undefined;
|
|
21
|
+
style?: CSSProperties | undefined;
|
|
22
|
+
testId?: string | undefined;
|
|
23
|
+
action?: ReactNode;
|
|
24
|
+
}): ReactElement | null {
|
|
25
|
+
const { error } = props;
|
|
26
|
+
if (!error) return null;
|
|
27
|
+
return (
|
|
28
|
+
<Alert
|
|
29
|
+
type="error"
|
|
30
|
+
showIcon
|
|
31
|
+
{...(props.style ? { style: props.style } : {})}
|
|
32
|
+
{...(props.testId ? { "data-testid": props.testId } : {})}
|
|
33
|
+
{...(props.action ? { action: props.action } : {})}
|
|
34
|
+
message={error.message}
|
|
35
|
+
{...(error.detail
|
|
36
|
+
? {
|
|
37
|
+
description: (
|
|
38
|
+
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
39
|
+
{error.detail}
|
|
40
|
+
</Typography.Text>
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
: {})}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<RatingBadge>` — the stars-and-count line, over either source of the two
|
|
3
|
+
* numbers.
|
|
4
|
+
*
|
|
5
|
+
* The whole component is built around one refusal: when `count` is `0` there
|
|
6
|
+
* is no star row at all, only the "no reviews yet" sentence. antd's `<Rate>`
|
|
7
|
+
* given `value={0}` draws five empty stars, which is a perfectly good
|
|
8
|
+
* rendering of the worst possible score and a completely wrong rendering of
|
|
9
|
+
* "nobody has rated this". `ratingSummary()` makes the distinction, and this
|
|
10
|
+
* skin honours it by not reaching `<Rate>` at all in that arm.
|
|
11
|
+
*/
|
|
12
|
+
import type { ReactElement } from "react";
|
|
13
|
+
import { Flex, Rate, Skeleton, Typography } from "antd";
|
|
14
|
+
import { matchLoad, toFlowError, useDescribeFlowError, useT } from "@stapel/core";
|
|
15
|
+
import { ReviewAggregate } from "../headless/ReviewAggregate.js";
|
|
16
|
+
import type { ReviewAggregateProps } from "../headless/ReviewAggregate.js";
|
|
17
|
+
import { REVIEWS_I18N_KEYS } from "../i18n/keys.js";
|
|
18
|
+
import { ErrorAlert } from "./ErrorAlert.js";
|
|
19
|
+
import { ReviewsSkinTheme } from "./theme.js";
|
|
20
|
+
import type { ThemeModeProp } from "./types.js";
|
|
21
|
+
|
|
22
|
+
export type RatingBadgeProps = ThemeModeProp &
|
|
23
|
+
Omit<ReviewAggregateProps, "children">;
|
|
24
|
+
|
|
25
|
+
export function RatingBadge(props: RatingBadgeProps): ReactElement {
|
|
26
|
+
const t = useT();
|
|
27
|
+
const describe = useDescribeFlowError();
|
|
28
|
+
const { mode, ...aggregateProps } = props;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<ReviewsSkinTheme {...(mode !== undefined ? { mode } : {})}>
|
|
32
|
+
<ReviewAggregate {...aggregateProps}>
|
|
33
|
+
{(bag) => (
|
|
34
|
+
<Flex align="center" gap={8} data-testid="reviews-rating">
|
|
35
|
+
{matchLoad(bag.state, {
|
|
36
|
+
loading: () => (
|
|
37
|
+
<Skeleton.Button active data-testid="reviews-rating-loading" />
|
|
38
|
+
),
|
|
39
|
+
failed: (error) => (
|
|
40
|
+
<ErrorAlert
|
|
41
|
+
testId="reviews-rating-failed"
|
|
42
|
+
error={describe(toFlowError(error))}
|
|
43
|
+
/>
|
|
44
|
+
),
|
|
45
|
+
ready: (summary) =>
|
|
46
|
+
summary.rated ? (
|
|
47
|
+
<>
|
|
48
|
+
<Rate
|
|
49
|
+
disabled
|
|
50
|
+
allowHalf
|
|
51
|
+
count={bag.max}
|
|
52
|
+
value={summary.rounded}
|
|
53
|
+
data-testid="reviews-rating-stars"
|
|
54
|
+
/>
|
|
55
|
+
<Typography.Text strong>
|
|
56
|
+
{t(REVIEWS_I18N_KEYS.ratingValue, {
|
|
57
|
+
avg: summary.rounded,
|
|
58
|
+
max: bag.max,
|
|
59
|
+
})}
|
|
60
|
+
</Typography.Text>
|
|
61
|
+
<Typography.Text type="secondary">
|
|
62
|
+
{t(REVIEWS_I18N_KEYS.ratingCount, {
|
|
63
|
+
count: summary.count,
|
|
64
|
+
})}
|
|
65
|
+
</Typography.Text>
|
|
66
|
+
</>
|
|
67
|
+
) : (
|
|
68
|
+
<Typography.Text
|
|
69
|
+
type="secondary"
|
|
70
|
+
data-testid="reviews-rating-none"
|
|
71
|
+
>
|
|
72
|
+
{t(REVIEWS_I18N_KEYS.ratingNone)}
|
|
73
|
+
</Typography.Text>
|
|
74
|
+
),
|
|
75
|
+
})}
|
|
76
|
+
</Flex>
|
|
77
|
+
)}
|
|
78
|
+
</ReviewAggregate>
|
|
79
|
+
</ReviewsSkinTheme>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ReviewFormCard>` — "rate this", in antd.
|
|
3
|
+
*
|
|
4
|
+
* Two outcomes get their own sentence rather than a red banner, because
|
|
5
|
+
* neither is a fault of the person in front of the screen:
|
|
6
|
+
*
|
|
7
|
+
* - **already rated** (`error.400.reviews_duplicate_review`, a 400 — see
|
|
8
|
+
* `model/refusals.ts`): the form collapses into a note. The same note shows
|
|
9
|
+
* when the host told us up front via `alreadyReviewed`, so the two paths to
|
|
10
|
+
* the same fact look the same;
|
|
11
|
+
* - **sent but not visible**: under pre-moderation the created row comes back
|
|
12
|
+
* `pending`, and the author is told it will appear once checked. Saying
|
|
13
|
+
* "published" there, or saying nothing, leaves them hunting for a review
|
|
14
|
+
* that is deliberately invisible to them.
|
|
15
|
+
*/
|
|
16
|
+
import type { ReactElement } from "react";
|
|
17
|
+
import { Alert, Button, Card, Flex, Input, Rate, Typography } from "antd";
|
|
18
|
+
import { useActionGate, useDescribeFlowError, useT } from "@stapel/core";
|
|
19
|
+
import type { ReviewTarget } from "../api/types.js";
|
|
20
|
+
import { ReviewForm } from "../headless/ReviewForm.js";
|
|
21
|
+
import type { ReviewFormBag } from "../headless/ReviewForm.js";
|
|
22
|
+
import { REVIEWS_I18N_KEYS } from "../i18n/keys.js";
|
|
23
|
+
import { ErrorAlert } from "./ErrorAlert.js";
|
|
24
|
+
import { ReviewsSkinTheme } from "./theme.js";
|
|
25
|
+
import type { ThemeModeProp } from "./types.js";
|
|
26
|
+
|
|
27
|
+
export interface ReviewFormCardProps extends ThemeModeProp {
|
|
28
|
+
readonly target: ReviewTarget;
|
|
29
|
+
/** The host already knows this author has reviewed the target (optimistic). */
|
|
30
|
+
readonly alreadyReviewed?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The sentence for a review that exists but may not be on the page. */
|
|
34
|
+
function sentKey(bag: ReviewFormBag): string {
|
|
35
|
+
switch (bag.submittedVisibility) {
|
|
36
|
+
case "published":
|
|
37
|
+
return REVIEWS_I18N_KEYS.formSentPublished;
|
|
38
|
+
case "pending":
|
|
39
|
+
return REVIEWS_I18N_KEYS.formSentPending;
|
|
40
|
+
case "hidden":
|
|
41
|
+
return REVIEWS_I18N_KEYS.formSentHidden;
|
|
42
|
+
default:
|
|
43
|
+
return REVIEWS_I18N_KEYS.formSentUnknown;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function FormBody(props: { bag: ReviewFormBag }): ReactElement {
|
|
48
|
+
const t = useT();
|
|
49
|
+
const describe = useDescribeFlowError();
|
|
50
|
+
const { bag } = props;
|
|
51
|
+
const gate = useActionGate(bag.canSubmit);
|
|
52
|
+
|
|
53
|
+
if (bag.submitted !== null) {
|
|
54
|
+
return (
|
|
55
|
+
<Alert
|
|
56
|
+
type="success"
|
|
57
|
+
showIcon
|
|
58
|
+
data-testid="reviews-form-sent"
|
|
59
|
+
message={t(sentKey(bag))}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (bag.alreadyReviewed) {
|
|
65
|
+
return (
|
|
66
|
+
<Typography.Text type="secondary" data-testid="reviews-form-duplicate">
|
|
67
|
+
{t(REVIEWS_I18N_KEYS.submitBlockedDuplicate)}
|
|
68
|
+
</Typography.Text>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (bag.signInRequired) {
|
|
73
|
+
return (
|
|
74
|
+
<Typography.Text type="secondary" data-testid="reviews-form-sign-in">
|
|
75
|
+
{t(REVIEWS_I18N_KEYS.formSignInRequired)}
|
|
76
|
+
</Typography.Text>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Flex vertical gap={8}>
|
|
82
|
+
<Typography.Text>{t(REVIEWS_I18N_KEYS.formRatingLabel)}</Typography.Text>
|
|
83
|
+
<Rate
|
|
84
|
+
count={bag.bounds.max}
|
|
85
|
+
value={bag.rating ?? 0}
|
|
86
|
+
onChange={bag.setRating}
|
|
87
|
+
data-testid="reviews-form-rate"
|
|
88
|
+
/>
|
|
89
|
+
<Typography.Text>{t(REVIEWS_I18N_KEYS.formBodyLabel)}</Typography.Text>
|
|
90
|
+
<Input.TextArea
|
|
91
|
+
value={bag.body}
|
|
92
|
+
onChange={(event) => bag.setBody(event.target.value)}
|
|
93
|
+
placeholder={t(REVIEWS_I18N_KEYS.formBodyPlaceholder)}
|
|
94
|
+
rows={3}
|
|
95
|
+
data-testid="reviews-form-body"
|
|
96
|
+
/>
|
|
97
|
+
{bag.error ? (
|
|
98
|
+
<ErrorAlert testId="reviews-form-failed" error={describe(bag.error)} />
|
|
99
|
+
) : null}
|
|
100
|
+
<Flex vertical gap={4} align="flex-start">
|
|
101
|
+
<Button
|
|
102
|
+
type="primary"
|
|
103
|
+
onClick={bag.submit}
|
|
104
|
+
disabled={gate.disabled}
|
|
105
|
+
loading={bag.submitting}
|
|
106
|
+
data-testid="reviews-form-submit"
|
|
107
|
+
data-analytics="none"
|
|
108
|
+
data-analytics-reason="business action — the host app wraps this with its own tracked(); the pair ships no analytics runtime and no flow machine for a single POST"
|
|
109
|
+
>
|
|
110
|
+
{t(REVIEWS_I18N_KEYS.formSubmit)}
|
|
111
|
+
</Button>
|
|
112
|
+
{gate.reason ? (
|
|
113
|
+
<Typography.Text type="secondary" data-testid="reviews-form-blocked">
|
|
114
|
+
{gate.reason}
|
|
115
|
+
</Typography.Text>
|
|
116
|
+
) : null}
|
|
117
|
+
</Flex>
|
|
118
|
+
</Flex>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function ReviewFormCard(props: ReviewFormCardProps): ReactElement {
|
|
123
|
+
const t = useT();
|
|
124
|
+
const { mode, ...formProps } = props;
|
|
125
|
+
return (
|
|
126
|
+
<ReviewsSkinTheme {...(mode !== undefined ? { mode } : {})}>
|
|
127
|
+
<Card size="small" data-testid="reviews-form">
|
|
128
|
+
<Flex vertical gap={8}>
|
|
129
|
+
<Typography.Title level={5} style={{ margin: 0 }}>
|
|
130
|
+
{t(REVIEWS_I18N_KEYS.formHeading)}
|
|
131
|
+
</Typography.Title>
|
|
132
|
+
<ReviewForm {...formProps}>
|
|
133
|
+
{(bag) => <FormBody bag={bag} />}
|
|
134
|
+
</ReviewForm>
|
|
135
|
+
</Flex>
|
|
136
|
+
</Card>
|
|
137
|
+
</ReviewsSkinTheme>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ReviewListPanel>` — the antd rendering of the review list.
|
|
3
|
+
*
|
|
4
|
+
* Two things it says out loud that a naive list would swallow:
|
|
5
|
+
*
|
|
6
|
+
* 1. A row that is not `published` carries a badge naming its state. It is on
|
|
7
|
+
* screen only because a moderator asked for `include=all`, and a pending
|
|
8
|
+
* or hidden review that looked like an ordinary one would misrepresent
|
|
9
|
+
* what the public can see.
|
|
10
|
+
* 2. The author is a SLOT. The wire carries `author_id` and nothing else — no
|
|
11
|
+
* name, no avatar — so the skin renders a neutral label unless the host
|
|
12
|
+
* passes `renderAuthor`. Printing a raw user id would be both useless to a
|
|
13
|
+
* reader and a gratuitous disclosure.
|
|
14
|
+
*/
|
|
15
|
+
import type { ReactElement, ReactNode } from "react";
|
|
16
|
+
import { Button, Card, Empty, Flex, List, Rate, Skeleton, Tag, Typography } from "antd";
|
|
17
|
+
import {
|
|
18
|
+
matchLoad,
|
|
19
|
+
toFlowError,
|
|
20
|
+
useActionGate,
|
|
21
|
+
useDescribeFlowError,
|
|
22
|
+
useT,
|
|
23
|
+
} from "@stapel/core";
|
|
24
|
+
import type { Review, ReviewTarget } from "../api/types.js";
|
|
25
|
+
import { ReviewList } from "../headless/ReviewList.js";
|
|
26
|
+
import type { ReviewListBag } from "../headless/ReviewList.js";
|
|
27
|
+
import { REVIEWS_I18N_KEYS } from "../i18n/keys.js";
|
|
28
|
+
import { reviewVisibility } from "../model/list.js";
|
|
29
|
+
import { useReviewsRuntime } from "../model/context.js";
|
|
30
|
+
import { ErrorAlert } from "./ErrorAlert.js";
|
|
31
|
+
import { ReviewsSkinTheme } from "./theme.js";
|
|
32
|
+
import type { ThemeModeProp } from "./types.js";
|
|
33
|
+
|
|
34
|
+
export interface ReviewListPanelProps extends ThemeModeProp {
|
|
35
|
+
readonly target: ReviewTarget;
|
|
36
|
+
/** Ask for pending/hidden rows (granted only to a moderator of the target). */
|
|
37
|
+
readonly include?: "all";
|
|
38
|
+
readonly limit?: number;
|
|
39
|
+
/** Turn `author_id` into something a reader recognises. See the header. */
|
|
40
|
+
readonly renderAuthor?: (review: Review) => ReactNode;
|
|
41
|
+
/** Format `created_at`. Absent means the raw ISO string is NOT shown. */
|
|
42
|
+
readonly renderDate?: (review: Review) => ReactNode;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The badge a non-published row carries. `null` for an ordinary review. */
|
|
46
|
+
function VisibilityTag(props: { status: string }): ReactElement | null {
|
|
47
|
+
const t = useT();
|
|
48
|
+
const visibility = reviewVisibility(props.status);
|
|
49
|
+
if (visibility === "published") return null;
|
|
50
|
+
if (visibility === "pending") {
|
|
51
|
+
return (
|
|
52
|
+
<Tag color="warning" data-testid="reviews-row-pending">
|
|
53
|
+
{t(REVIEWS_I18N_KEYS.statusPending)}
|
|
54
|
+
</Tag>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (visibility === "hidden") {
|
|
58
|
+
return (
|
|
59
|
+
<Tag color="error" data-testid="reviews-row-hidden">
|
|
60
|
+
{t(REVIEWS_I18N_KEYS.statusHidden)}
|
|
61
|
+
</Tag>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
// A state this build does not know. Naming it beats rendering it as an
|
|
65
|
+
// ordinary review (it may be one the server hides) and beats crashing.
|
|
66
|
+
return (
|
|
67
|
+
<Tag data-testid="reviews-row-unknown">
|
|
68
|
+
{t(REVIEWS_I18N_KEYS.statusUnknown, { status: props.status })}
|
|
69
|
+
</Tag>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ReviewRow(props: {
|
|
74
|
+
review: Review;
|
|
75
|
+
max: number;
|
|
76
|
+
renderAuthor: ReviewListPanelProps["renderAuthor"];
|
|
77
|
+
renderDate: ReviewListPanelProps["renderDate"];
|
|
78
|
+
}): ReactElement {
|
|
79
|
+
const t = useT();
|
|
80
|
+
const { review } = props;
|
|
81
|
+
return (
|
|
82
|
+
<List.Item data-testid="reviews-row" data-review-id={review.id}>
|
|
83
|
+
<Flex vertical gap={4} style={{ width: "100%" }}>
|
|
84
|
+
<Flex align="center" gap={8} wrap>
|
|
85
|
+
<Rate disabled count={props.max} value={review.rating} />
|
|
86
|
+
<Typography.Text strong>
|
|
87
|
+
{props.renderAuthor?.(review) ?? t(REVIEWS_I18N_KEYS.authorFallback)}
|
|
88
|
+
</Typography.Text>
|
|
89
|
+
{props.renderDate ? (
|
|
90
|
+
<Typography.Text type="secondary">
|
|
91
|
+
{props.renderDate(review)}
|
|
92
|
+
</Typography.Text>
|
|
93
|
+
) : null}
|
|
94
|
+
<VisibilityTag status={review.status} />
|
|
95
|
+
</Flex>
|
|
96
|
+
{review.body.length > 0 ? (
|
|
97
|
+
<Typography.Paragraph style={{ margin: 0 }}>
|
|
98
|
+
{review.body}
|
|
99
|
+
</Typography.Paragraph>
|
|
100
|
+
) : null}
|
|
101
|
+
{review.response ? (
|
|
102
|
+
<Card size="small" data-testid="reviews-row-response">
|
|
103
|
+
<Typography.Text type="secondary">
|
|
104
|
+
{t(REVIEWS_I18N_KEYS.responseHeading)}
|
|
105
|
+
</Typography.Text>
|
|
106
|
+
<Typography.Paragraph style={{ margin: 0 }}>
|
|
107
|
+
{review.response.body}
|
|
108
|
+
</Typography.Paragraph>
|
|
109
|
+
</Card>
|
|
110
|
+
) : null}
|
|
111
|
+
</Flex>
|
|
112
|
+
</List.Item>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function MoreButton(props: { bag: ReviewListBag }): ReactElement {
|
|
117
|
+
const t = useT();
|
|
118
|
+
const gate = useActionGate(props.bag.more);
|
|
119
|
+
return (
|
|
120
|
+
<Flex vertical align="center" gap={4}>
|
|
121
|
+
<Button
|
|
122
|
+
onClick={props.bag.loadMore}
|
|
123
|
+
disabled={gate.disabled}
|
|
124
|
+
loading={props.bag.loadingMore}
|
|
125
|
+
data-testid="reviews-load-more"
|
|
126
|
+
data-analytics="none"
|
|
127
|
+
data-analytics-reason="paging further into a read — the host app wraps this with its own tracked(); pairs carry no @stapel/analytics runtime dependency by architecture"
|
|
128
|
+
>
|
|
129
|
+
{t(REVIEWS_I18N_KEYS.listLoadMore)}
|
|
130
|
+
</Button>
|
|
131
|
+
{gate.reason ? (
|
|
132
|
+
<Typography.Text type="secondary" data-testid="reviews-load-more-reason">
|
|
133
|
+
{gate.reason}
|
|
134
|
+
</Typography.Text>
|
|
135
|
+
) : null}
|
|
136
|
+
</Flex>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function ReviewListPanel(props: ReviewListPanelProps): ReactElement {
|
|
141
|
+
const t = useT();
|
|
142
|
+
const describe = useDescribeFlowError();
|
|
143
|
+
const runtime = useReviewsRuntime();
|
|
144
|
+
const { mode, target, renderAuthor, renderDate, ...listOptions } = props;
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<ReviewsSkinTheme {...(mode !== undefined ? { mode } : {})}>
|
|
148
|
+
<ReviewList target={target} {...listOptions}>
|
|
149
|
+
{(bag) => (
|
|
150
|
+
<Flex vertical gap={8} data-testid="reviews-list">
|
|
151
|
+
<Typography.Title level={5} style={{ margin: 0 }}>
|
|
152
|
+
{t(REVIEWS_I18N_KEYS.listHeading)}
|
|
153
|
+
</Typography.Title>
|
|
154
|
+
|
|
155
|
+
{matchLoad(bag.state, {
|
|
156
|
+
loading: () => (
|
|
157
|
+
<Skeleton active data-testid="reviews-list-loading" />
|
|
158
|
+
),
|
|
159
|
+
failed: (error) => (
|
|
160
|
+
<ErrorAlert
|
|
161
|
+
testId="reviews-list-failed"
|
|
162
|
+
error={describe(toFlowError(error))}
|
|
163
|
+
action={
|
|
164
|
+
<Button
|
|
165
|
+
size="small"
|
|
166
|
+
onClick={bag.refresh}
|
|
167
|
+
data-analytics="none"
|
|
168
|
+
data-analytics-reason="recovery affordance for a failed read — host app wraps with its own tracked()"
|
|
169
|
+
>
|
|
170
|
+
{t(REVIEWS_I18N_KEYS.listRefresh)}
|
|
171
|
+
</Button>
|
|
172
|
+
}
|
|
173
|
+
/>
|
|
174
|
+
),
|
|
175
|
+
ready: (reviews) =>
|
|
176
|
+
reviews.length === 0 ? (
|
|
177
|
+
<Empty
|
|
178
|
+
data-testid="reviews-list-empty"
|
|
179
|
+
description={t(REVIEWS_I18N_KEYS.listEmpty)}
|
|
180
|
+
/>
|
|
181
|
+
) : (
|
|
182
|
+
<>
|
|
183
|
+
<List
|
|
184
|
+
dataSource={[...reviews]}
|
|
185
|
+
data-testid="reviews-list-rows"
|
|
186
|
+
renderItem={(review: Review) => (
|
|
187
|
+
<ReviewRow
|
|
188
|
+
review={review}
|
|
189
|
+
max={runtime.ratingBounds.max}
|
|
190
|
+
renderAuthor={renderAuthor}
|
|
191
|
+
renderDate={renderDate}
|
|
192
|
+
/>
|
|
193
|
+
)}
|
|
194
|
+
/>
|
|
195
|
+
<MoreButton bag={bag} />
|
|
196
|
+
</>
|
|
197
|
+
),
|
|
198
|
+
})}
|
|
199
|
+
</Flex>
|
|
200
|
+
)}
|
|
201
|
+
</ReviewList>
|
|
202
|
+
</ReviewsSkinTheme>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ReviewsPanel>` — the composed block a listing detail page drops in: the
|
|
3
|
+
* rating line, the list, and (for a signed-in reader who has not rated yet)
|
|
4
|
+
* the form.
|
|
5
|
+
*
|
|
6
|
+
* The pre-check is the interesting part, and its LIMIT is the reason it lives
|
|
7
|
+
* here rather than inside the form. Given `viewerId`, the panel looks for the
|
|
8
|
+
* viewer's own review among the loaded rows and, finding one, tells the form
|
|
9
|
+
* not to offer itself. That list is published-only, so under pre-moderation
|
|
10
|
+
* the viewer's pending review is invisible even to them: the form is offered,
|
|
11
|
+
* the server refuses with `error.400.reviews_duplicate_review`, and the form
|
|
12
|
+
* shows the same "you have already rated this" sentence. One fact, two
|
|
13
|
+
* discoveries, one sentence — which is why the refusal is a first-class
|
|
14
|
+
* outcome and the pre-check is only an optimisation.
|
|
15
|
+
*/
|
|
16
|
+
import type { ReactElement, ReactNode } from "react";
|
|
17
|
+
import { Flex } from "antd";
|
|
18
|
+
import type { Review, ReviewTarget } from "../api/types.js";
|
|
19
|
+
import { ReviewList } from "../headless/ReviewList.js";
|
|
20
|
+
import { findOwnReview } from "../model/list.js";
|
|
21
|
+
import { RatingBadge } from "./RatingBadge.js";
|
|
22
|
+
import { ReviewFormCard } from "./ReviewFormCard.js";
|
|
23
|
+
import { ReviewListPanel } from "./ReviewListPanel.js";
|
|
24
|
+
import { ReviewsSkinTheme } from "./theme.js";
|
|
25
|
+
import type { ThemeModeProp } from "./types.js";
|
|
26
|
+
|
|
27
|
+
export interface ReviewsPanelProps extends ThemeModeProp {
|
|
28
|
+
readonly target: ReviewTarget;
|
|
29
|
+
/**
|
|
30
|
+
* The reader's user id — the value the backend puts in `author_id`, NOT a
|
|
31
|
+
* profile id. Absent means no pre-check: the form is offered and the server
|
|
32
|
+
* is the only judge.
|
|
33
|
+
*/
|
|
34
|
+
readonly viewerId?: string | null;
|
|
35
|
+
/** Hide the form entirely (a seller looking at their own listing). */
|
|
36
|
+
readonly canReview?: boolean;
|
|
37
|
+
readonly renderAuthor?: (review: Review) => ReactNode;
|
|
38
|
+
readonly renderDate?: (review: Review) => ReactNode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ReviewsPanel(props: ReviewsPanelProps): ReactElement {
|
|
42
|
+
const { mode, target, viewerId, canReview = true, ...rows } = props;
|
|
43
|
+
return (
|
|
44
|
+
<ReviewsSkinTheme {...(mode !== undefined ? { mode } : {})}>
|
|
45
|
+
<Flex vertical gap={16} data-testid="reviews-panel">
|
|
46
|
+
<RatingBadge target={target} {...(mode !== undefined ? { mode } : {})} />
|
|
47
|
+
<ReviewListPanel
|
|
48
|
+
target={target}
|
|
49
|
+
{...(mode !== undefined ? { mode } : {})}
|
|
50
|
+
{...rows}
|
|
51
|
+
/>
|
|
52
|
+
{canReview ? (
|
|
53
|
+
// The list is read a second time here, from the SAME query key, so
|
|
54
|
+
// this is a cache hit rather than a second request — the own-review
|
|
55
|
+
// pre-check needs the rows and the panel needs them rendered.
|
|
56
|
+
<ReviewList target={target}>
|
|
57
|
+
{(bag) => (
|
|
58
|
+
<ReviewFormCard
|
|
59
|
+
target={target}
|
|
60
|
+
{...(mode !== undefined ? { mode } : {})}
|
|
61
|
+
alreadyReviewed={
|
|
62
|
+
findOwnReview(
|
|
63
|
+
bag.state.status === "ready" ? bag.state.data : undefined,
|
|
64
|
+
viewerId
|
|
65
|
+
) !== undefined
|
|
66
|
+
}
|
|
67
|
+
/>
|
|
68
|
+
)}
|
|
69
|
+
</ReviewList>
|
|
70
|
+
) : null}
|
|
71
|
+
</Flex>
|
|
72
|
+
</ReviewsSkinTheme>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@stapel/reviews-react/default` — the antd skin over the headless pair.
|
|
3
|
+
*
|
|
4
|
+
* A separate entry point (the convention every pair's `/default` follows) so a
|
|
5
|
+
* host rendering its own review block never pulls `antd` into its bundle. The
|
|
6
|
+
* main entry has no visual opinion at all and no import path from it reaches
|
|
7
|
+
* this directory — size-limit and the bundle-purity test are the teeth on
|
|
8
|
+
* that.
|
|
9
|
+
*
|
|
10
|
+
* ```tsx
|
|
11
|
+
* import { createReviewsRuntime, ReviewsProvider } from "@stapel/reviews-react";
|
|
12
|
+
* import { ReviewsPanel } from "@stapel/reviews-react/default";
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* `<RatingBadge aggregate={…}>` is the seller-rating half: it renders two
|
|
16
|
+
* numbers the composite's projection produced, because stapel-reviews cannot
|
|
17
|
+
* roll a seller's listings up itself (main entry header).
|
|
18
|
+
*/
|
|
19
|
+
export { ReviewsPanel } from "./ReviewsPanel.js";
|
|
20
|
+
export type { ReviewsPanelProps } from "./ReviewsPanel.js";
|
|
21
|
+
export { ReviewListPanel } from "./ReviewListPanel.js";
|
|
22
|
+
export type { ReviewListPanelProps } from "./ReviewListPanel.js";
|
|
23
|
+
export { ReviewFormCard } from "./ReviewFormCard.js";
|
|
24
|
+
export type { ReviewFormCardProps } from "./ReviewFormCard.js";
|
|
25
|
+
export { RatingBadge } from "./RatingBadge.js";
|
|
26
|
+
export type { RatingBadgeProps } from "./RatingBadge.js";
|
|
27
|
+
export { ReviewsSkinTheme } from "./theme.js";
|
|
28
|
+
export type { ReviewsSkinThemeProps } from "./theme.js";
|
|
29
|
+
export { ErrorAlert } from "./ErrorAlert.js";
|
|
30
|
+
export type { ThemeModeProp } from "./types.js";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default skin's INTERNAL theme provider — every `/default` surface wraps
|
|
3
|
+
* itself in this, so the skin is readable no matter what the host supplies
|
|
4
|
+
* (tracker #26: a skin set with no internal provider once inherited a theme
|
|
5
|
+
* bridge serving light-mode values inside a dark document — text on
|
|
6
|
+
* background at 1.00:1). The theme derives from `@stapel/tokens` via
|
|
7
|
+
* `@stapel/tokens-antd`'s `toAntdThemeConfig(mode)`; `mode` defaults to what
|
|
8
|
+
* the HOST's document declares (`resolveThemeMode()` — the `data-theme`
|
|
9
|
+
* attribute `tokens.css` keys its dark block on), never to a hardcoded side.
|
|
10
|
+
*
|
|
11
|
+
* This is also what "override styles" means for this pair: a storefront
|
|
12
|
+
* retheming through the §68 token JSON regenerates its `--stapel-*` custom
|
|
13
|
+
* properties and the result page follows with zero code — a far better deal
|
|
14
|
+
* than a prop-per-colour API, and the reason the skin has none.
|
|
15
|
+
*/
|
|
16
|
+
import { useMemo } from "react";
|
|
17
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
18
|
+
import { ConfigProvider } from "antd";
|
|
19
|
+
import {
|
|
20
|
+
resolveThemeMode,
|
|
21
|
+
toAntdTheme,
|
|
22
|
+
toAntdThemeConfig,
|
|
23
|
+
} from "@stapel/tokens-antd";
|
|
24
|
+
import type { ThemeMode } from "@stapel/tokens-antd";
|
|
25
|
+
|
|
26
|
+
export interface ReviewsSkinThemeProps {
|
|
27
|
+
/**
|
|
28
|
+
* Light or dark. Defaults to the mode the host's document declares
|
|
29
|
+
* (`resolveThemeMode()`). Pass explicitly to pin a side.
|
|
30
|
+
*/
|
|
31
|
+
readonly mode?: ThemeMode;
|
|
32
|
+
/** Extra styles merged onto the themed root element (layout only — colors
|
|
33
|
+
* come from the mode). */
|
|
34
|
+
readonly style?: CSSProperties;
|
|
35
|
+
readonly children: ReactNode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `<ReviewsSkinTheme/>` — the self-theming wrapper of
|
|
40
|
+
* `@stapel/reviews-react/default`. Standalone use is supported (wrap any
|
|
41
|
+
* composition of the skin's parts once); the shipped surfaces
|
|
42
|
+
* (`ReviewsPanel`, `ReviewListPanel`, `ReviewFormCard`, `RatingBadge`)
|
|
43
|
+
* each already wrap themselves, and nested antd
|
|
44
|
+
* `ConfigProvider`s merge, so composing them under one `ReviewsSkinTheme`
|
|
45
|
+
* stays correct.
|
|
46
|
+
*/
|
|
47
|
+
export function ReviewsSkinTheme(props: ReviewsSkinThemeProps): ReactElement {
|
|
48
|
+
const mode = props.mode ?? resolveThemeMode();
|
|
49
|
+
const theme = useMemo(() => toAntdThemeConfig(mode), [mode]);
|
|
50
|
+
const token = useMemo(() => toAntdTheme(mode), [mode]);
|
|
51
|
+
return (
|
|
52
|
+
<ConfigProvider theme={theme}>
|
|
53
|
+
<div
|
|
54
|
+
data-reviews-skin-root
|
|
55
|
+
data-reviews-skin-mode={mode}
|
|
56
|
+
style={{
|
|
57
|
+
color: token.colorText,
|
|
58
|
+
backgroundColor: token.colorBgContainer,
|
|
59
|
+
...props.style,
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{props.children}
|
|
63
|
+
</div>
|
|
64
|
+
</ConfigProvider>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small shared types for the `/default` skin — kept in one place so every
|
|
3
|
+
* surface takes the same `mode` prop and re-exports the same error dialect.
|
|
4
|
+
*/
|
|
5
|
+
export type { FlowError } from "@stapel/core";
|
|
6
|
+
import type { ThemeMode } from "@stapel/tokens-antd";
|
|
7
|
+
|
|
8
|
+
/** Every `/default` surface accepts a theme mode; absent means "whatever the
|
|
9
|
+
* host document declares" (`resolveThemeMode()`), never a hardcoded side. */
|
|
10
|
+
export interface ThemeModeProp {
|
|
11
|
+
readonly mode?: ThemeMode;
|
|
12
|
+
}
|