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