Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 | 13x 28x 13x 11x 11x 11x 11x 11x 10x 11x 11x 11x 1x 1x 11x 9x 9x 9x 1x 13x 9x 9x 9x 13x 58x 18x 13x 15x 1x | import { useMemo, useState } from "react";
import { Box, Stack } from "@mui/material";
import GiveAvatar from "@shared/Avatar/GiveAvatar";
import GiveButton from "@shared/Button/GiveButton";
import GiveStarRating from "@shared/StarRating/GiveStarRating";
import GiveText from "@shared/Text/GiveText";
import { styled } from "@theme/v2/Provider";
import {
buildReviewFilter,
useGetPublicEventReviewsInfinite,
useGetPublicEventReviewStats,
} from "@services/api/products/reviews";
import ReviewRatingDistribution from "@features/Events/EventDetail/reviews/ReviewRatingDistribution";
import ReviewReplyBody from "@features/Events/EventDetail/reviews/ReviewReplyBody";
import {
formatAverage,
reviewCountLabel,
} from "@features/Events/EventDetail/reviews/EventReviewSummary";
import { formatReviewDate } from "@features/Events/EventDetail/reviews/reviewDate";
import {
EventPublicReviewApiRow,
ReviewRatingFilter,
} from "@features/Events/EventDetail/reviews/review.types";
type AdaptiveColor = "black" | "white";
type Props = {
productId?: string | number;
/**
* Which way the page's own text reads against the merchant's background —
* the same value ContentComponent gets, so the reviews inherit the form's
* theme rather than defining one.
*/
adaptiveColorToBackground?: AdaptiveColor;
};
/** rgba of the page's adaptive colour — the payment form has no fixed palette. */
const tint = (color: AdaptiveColor, opacity: number) =>
color === "black"
? `rgba(0, 0, 0, ${opacity})`
: `rgba(255, 255, 255, ${opacity})`;
/**
* PayBuilder 034 — the reviews block on the live event page
* (frames 7357-80162 / 7357-80180).
*
* Read-only by design: "From the payment form user can only read the reviews,
* they cannot answer from here only from mobile app" (annotation on frame
* 7508-78376). There is no composer here and no reply affordance — the customer
* writes in the GiveCash app, the merchant answers in the portal.
*
* Cards are drawn with a **translucent border and no fill**, not the portal's
* white surface: a payment form carries the merchant's own background colour,
* so a solid card would fight it (annotation on frame 7508-78233).
*/
const PublicEventReviews = ({
productId,
adaptiveColorToBackground = "black",
}: Props) => {
const [ratings, setRatings] = useState<ReviewRatingFilter[]>([]);
// Stars only: awaiting-reply is a merchant filter, and the public view
// carries no reply eligibility to filter on.
const filter = useMemo(() => buildReviewFilter(ratings), [ratings]);
const {
data: pages,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useGetPublicEventReviewsInfinite(productId, { filter });
const { data: stats, isError: isStatsError } =
useGetPublicEventReviewStats(productId);
const rows: EventPublicReviewApiRow[] = useMemo(
() => (pages?.pages ?? []).flatMap((page) => page?.data ?? []),
[pages],
);
// The summary is the authority on how many reviews the event has — it counts
// the whole event while the list carries the star filters. But it is a
// separate request: with only that one failed, reading the count from it alone
// would take the entire block off a page whose list has reviews to show.
const listTotal = pages?.pages?.[0]?.total ?? 0;
const total = stats?.total ?? listTotal;
const toggleRating = (rating: ReviewRatingFilter) =>
setRatings((current) =>
current.includes(rating)
? current.filter((value) => value !== rating)
: [...current, rating],
);
// An event nobody has reviewed shows nothing at all — the block is not worth
// a heading and an empty state on a page whose job is selling tickets. Rows on
// screen are proof to the contrary even when neither count arrived.
if (total === 0 && rows.length === 0) return null;
const counts: Record<number, number> = {
1: stats?.numRating1 ?? 0,
2: stats?.numRating2 ?? 0,
3: stats?.numRating3 ?? 0,
4: stats?.numRating4 ?? 0,
5: stats?.numRating5 ?? 0,
};
return (
<Stack gap="24px" width="100%" data-testid="public-event-reviews">
<Stack gap="12px">
{/* The score is the summary's alone. With it unavailable the heading
drops to the count the list can vouch for, rather than announcing
the event scored 0.00. */}
{isStatsError ? (
<GiveText variant="bodyS" color="secondary">
{reviewCountLabel(total)}
</GiveText>
) : (
<>
<Stack direction="row" gap="8px" alignItems="baseline">
<GiveText variant="h5" color="primary">
{formatAverage(stats?.averageRating)}
</GiveText>
<GiveText variant="bodyS" color="secondary">
{reviewCountLabel(total)}
</GiveText>
</Stack>
<GiveStarRating value={stats?.averageRating ?? 0} size={24} />
</>
)}
</Stack>
<Panel tone={adaptiveColorToBackground}>
{/* The same rows the portal's Reviews sidebar draws, tinted for the
merchant's own background rather than the portal palette. */}
<ReviewRatingDistribution
counts={counts}
total={total}
selected={ratings}
onToggle={toggleRating}
trackColor={tint(adaptiveColorToBackground, 0.1)}
// Without the summary there is no distribution to draw; the star
// filters still work, because they run off the list read.
countsUnavailable={isStatsError}
/>
</Panel>
<Stack gap="16px">
{rows.map((review) => (
<PublicReviewCard
key={review.id}
review={review}
tone={adaptiveColorToBackground}
/>
))}
{/* The block has no pager — it is a column on a payment form — so
without this an event with 200 reviews published the newest 25 while
the distribution above counted all 200. */}
{hasNextPage && (
<GiveButton
variant="outline"
size="large"
label={isFetchingNextPage ? "Loading…" : "Show more reviews"}
disabled={isFetchingNextPage}
onClick={() => fetchNextPage()}
sx={{ alignSelf: "center" }}
data-testid="public-event-reviews-show-more"
/>
)}
</Stack>
</Stack>
);
};
export default PublicEventReviews;
const PublicReviewCard = ({
review,
tone,
}: {
review: EventPublicReviewApiRow;
tone: AdaptiveColor;
}) => {
const date = formatReviewDate(review.createdAt);
const repliedAt = formatReviewDate(review.repliedAt);
return (
<Panel tone={tone} data-testid={`public-event-review-${review.id}`}>
<Stack gap="12px">
<Stack direction="row" gap="12px" alignItems="flex-start">
<GiveAvatar
size="32px"
imageUrl={review.reviewerAvatarURL}
name={review.reviewerName}
/>
<Stack gap="2px" flex={1} minWidth={0}>
<GiveText variant="bodyM" color="primary">
{review.reviewerName}
</GiveText>
<Stack direction="row" gap="8px" alignItems="center">
<GiveStarRating value={review.rating} />
{date && (
<GiveText variant="bodyXS" color="secondary">
· {date}
</GiveText>
)}
</Stack>
</Stack>
</Stack>
{/* A rating with no words is a complete review. */}
{review.comment && (
<GiveText
variant="bodyS"
color="primary"
sx={{ whiteSpace: "pre-wrap" }}
>
{review.comment}
</GiveText>
)}
</Stack>
{review.hasReply && (
<ReplySection tone={tone}>
<Stack direction="row" gap="12px" alignItems="flex-start">
<GiveAvatar size="32px" name={review.replyAuthorName} />
<Stack gap="2px">
<GiveText variant="bodyM" color="primary">
{review.replyAuthorName}
</GiveText>
{repliedAt && (
<GiveText variant="bodyXS" color="secondary">
{repliedAt}
</GiveText>
)}
</Stack>
</Stack>
<ReviewReplyBody body={review.replyBody} />
</ReplySection>
)}
</Panel>
);
};
// Border only, never a fill: the form's background is the merchant's own colour
// (annotation on frame 7508-78233).
const Panel = styled(Box, {
shouldForwardProp: (prop) => prop !== "tone",
})<{ tone: AdaptiveColor }>(({ theme, tone }) => ({
display: "flex",
flexDirection: "column",
padding: "20px",
borderRadius: `${theme?.customs?.radius?.medium}px`,
border: `1px solid ${tint(tone, 0.15)}`,
backgroundColor: "transparent",
}));
const ReplySection = styled(Box, {
shouldForwardProp: (prop) => prop !== "tone",
})<{ tone: AdaptiveColor }>(({ tone }) => ({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: "12px",
marginTop: "20px",
paddingTop: "20px",
borderTop: `1px solid ${tint(tone, 0.15)}`,
}));
|