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

100% Statements 9/9
100% Branches 12/12
100% Functions 5/5
100% Lines 8/8

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                                                                                    6x           63x   63x                                                                                             3x                   63x                   6x 189x 123x                  
import { Box, Stack } from "@mui/material";
import GiveAvatar from "@shared/Avatar/GiveAvatar";
import GiveStarRating from "@shared/StarRating/GiveStarRating";
import GiveText from "@shared/Text/GiveText";
import { styled } from "@theme/v2/Provider";
import EventReviewReply from "./EventReviewReply";
import EventReviewReplyComposer from "./EventReviewReplyComposer";
import { formatReviewDate } from "./reviewDate";
import { EventReviewRow } from "./review.types";
 
type Props = {
  review: EventReviewRow;
  /**
   * Whether this user may publish replies — the product-update grant, which is
   * a different thing from the review still being answerable. No default: a
   * permission gate that falls open when a caller forgets it is worse than a
   * type error.
   */
  canReply: boolean;
  /** Resolves to whether the reply published — see EventReviewReplyComposer. */
  onReply: (review: EventReviewRow, body: string) => Promise<boolean> | boolean;
  isSubmitting?: boolean;
};
 
/**
 * One row of the Reviews tab (frames 7501-72341 / 7506-74389 / 7506-74973).
 *
 * The card always opens with the review. Below it, exactly one of three things:
 * the published reply, the composer, or nothing at all.
 *
 * "Nothing at all" is the 30-day case (red annotation on frame 7506-74389:
 * "Hide reply input if 30 days passed"). It is deliberately not a disabled box
 * or an explanatory message — the review simply reads as closed. `review.canReply`
 * is the server's word for that; it is false both when a reply exists and when
 * the window has shut, so it is never recomputed from the browser clock.
 *
 * The composer needs the merchant's permission on top of that. The reply POST is
 * routed on the product-update grant, while the tab around it renders on a
 * product/stat read — so read-only product access is enough to see every review
 * here and not enough to answer one. Drawing the box anyway would take a typed
 * reply and answer it with the access-denied modal.
 */
const EventReviewListItem = ({
  review,
  canReply,
  onReply,
  isSubmitting,
}: Props) => {
  const date = formatReviewDate(review.createdAt);
 
  return (
    <Card data-testid={`event-review-${review.id}`}>
      <Section>
        <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, so the block is
            dropped rather than reserved and left blank. */}
        {review.comment && (
          <GiveText
            variant="bodyS"
            color="primary"
            sx={{ whiteSpace: "pre-wrap" }}
          >
            {review.comment}
          </GiveText>
        )}
      </Section>
 
      {review.hasReply && (
        <Section withDivider>
          <EventReviewReply review={review} />
        </Section>
      )}
 
      {!review.hasReply && review.canReply && canReply && (
        <Section withDivider>
          <EventReviewReplyComposer
            reviewId={review.id}
            isSubmitting={isSubmitting}
            onSubmit={(body) => onReply(review, body)}
          />
        </Section>
      )}
    </Card>
  );
};
 
export default EventReviewListItem;
 
const Card = styled(Box)(({ theme }) => ({
  display: "flex",
  flexDirection: "column",
  width: "100%",
  borderRadius: `${theme?.customs?.radius?.medium}px`,
  border: `1px solid ${theme.palette.border?.primary}`,
  backgroundColor: theme.palette.background.paper,
  overflow: "hidden",
}));
 
const Section = styled(Box, {
  shouldForwardProp: (prop) => prop !== "withDivider",
})<{ withDivider?: boolean }>(({ theme, withDivider }) => ({
  display: "flex",
  flexDirection: "column",
  gap: "12px",
  padding: "20px",
  ...(withDivider
    ? { borderTop: `1px solid ${theme.palette.border?.primary}` }
    : {}),
}));