All files / src/sections/PayBuilder/views/events/components PublicEventReviews.tsx

96.96% Statements 32/33
83.72% Branches 36/43
93.33% Functions 14/15
96.66% Lines 29/30

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)}`,
}));