All files / src/features/Events/EventDetail/reviews EventReviewCard.tsx

87.5% Statements 7/8
100% Branches 10/10
75% Functions 3/4
85.71% Lines 6/7

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                                              4x                                           4x 27x   27x                                                                                                               1x                       27x                      
import { Box, Stack } from "@mui/material";
import { ChatCircleIcon } from "@phosphor-icons/react";
import GiveAvatar from "@shared/Avatar/GiveAvatar";
import GiveLink from "@shared/Link/GiveLink";
import GiveStarRating from "@shared/StarRating/GiveStarRating";
import GiveText from "@shared/Text/GiveText";
import { styled } from "@theme/v2/Provider";
import { formatReviewDate } from "./reviewDate";
import { EventReviewRow } from "./review.types";
 
type Props = {
  review: EventReviewRow;
  /**
   * Whether this user may publish replies — the product-update grant. No
   * default: a permission gate that falls open when a caller forgets it is
   * worse than a type error.
   */
  canReply: boolean;
  /** Follows the link through to the Reviews tab. */
  onReply?: (review: EventReviewRow) => void;
  onViewReply?: (review: EventReviewRow) => void;
};
 
const COMMENT_CLAMP_LINES = 3;
 
/**
 * One customer review as the side panel previews it (frames 7878-57940 /
 * 7878-56665). The Reviews tab has its own row — EventReviewListItem — because
 * it carries the composer and the published reply, which do not fit a
 * fixed-height preview card.
 *
 * A review with no comment still renders — stars, name and date, with no empty
 * comment block and no placeholder text standing in for words the customer
 * chose not to write.
 *
 * Which action shows is decided by the server's `review.canReply`, never by
 * comparing the deadline against the browser clock: it is false both when a reply
 * already exists and when the 30-day window has closed. Past the window the card
 * simply carries no action — it reads as closed rather than as an error.
 *
 * Reply also needs the merchant's permission. It leads to the tab's composer,
 * whose POST is routed on the product-update grant rather than the product/stat
 * read that draws this card — so without the gate a read-only user would be
 * invited to write a reply the API will refuse.
 */
const EventReviewCard = ({ review, canReply, onReply, onViewReply }: Props) => {
  const date = formatReviewDate(review.createdAt);
 
  return (
    <Card data-testid={`event-review-${review.id}`}>
      <Stack direction="row" gap="12px" alignItems="flex-start">
        <GiveAvatar
          size="32px"
          imageUrl={review.reviewerAvatarURL}
          name={review.reviewerName}
        />
        <Stack gap="4px" flex={1} minWidth={0}>
          <GiveText variant="bodyM" color="primary">
            {review.reviewerName}
          </GiveText>
          <Stack direction="row" gap="8px" alignItems="center">
            <GiveStarRating value={review.rating} />
            {/* An unset date renders nothing rather than a stray separator. */}
            {date && (
              <GiveText variant="bodyXS" color="secondary">
                · {date}
              </GiveText>
            )}
          </Stack>
        </Stack>
      </Stack>
 
      {/* No comment is a complete review, so the block is dropped entirely
          rather than reserved and left blank. */}
      {review.comment && (
        <GiveText
          variant="bodyS"
          color="primary"
          sx={{
            display: "-webkit-box",
            WebkitLineClamp: COMMENT_CLAMP_LINES,
            WebkitBoxOrient: "vertical",
            overflow: "hidden",
          }}
        >
          {review.comment}
        </GiveText>
      )}
 
      <Box sx={{ marginTop: "auto" }}>
        {review.hasReply && (
          <GiveLink
            component="button"
            color="primary"
            Icon={ChatCircleIcon}
            onClick={() => onViewReply?.(review)}
          >
            View your reply
          </GiveLink>
        )}
        {review.canReply && canReply && onReply && (
          <GiveLink
            component="button"
            color="primary"
            onClick={() => onReply(review)}
          >
            Reply
          </GiveLink>
        )}
      </Box>
    </Card>
  );
};
 
export default EventReviewCard;
 
const Card = styled(Box)(({ theme }) => ({
  display: "flex",
  flexDirection: "column",
  gap: "12px",
  padding: "20px",
  borderRadius: `${theme?.customs?.radius?.medium}px`,
  backgroundColor: theme.palette.background.paper,
  // The preview grid is two cards per row, so a fixed height keeps every card
  // in a row ending at the same baseline whatever its comment length.
  height: "190px",
}));