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

95% Statements 19/20
87.5% Branches 7/8
100% Functions 5/5
100% Lines 16/16

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                  6x   6x     6x                                                   6x         64x 64x 64x 64x   64x 3x 3x 3x     64x                     4x                                                               64x                 15x                    
import { useState } from "react";
import { Box, Stack } from "@mui/material";
import { ArrowUpIcon } from "@phosphor-icons/react";
import { GiveInput } from "@shared/GiveInputs/GiveInput";
import GiveIconButton from "@shared/IconButton/GiveIconButton";
import GiveText from "@shared/Text/GiveText";
import { styled, useAppTheme } from "@theme/v2/Provider";
 
/** The API caps the reply body. */
export const REPLY_MAX_LENGTH = 1000;
 
export const REPLY_PLACEHOLDER = "Thank the guest, or answer what they raised…";
 
/** The field's resting height in the frames, before the reply grows it. */
const MIN_ROWS = 3;
 
type Props = {
  /** Resolves to whether the reply published; a false keeps the draft. */
  onSubmit: (body: string) => Promise<boolean> | boolean;
  isSubmitting?: boolean;
  reviewId: number;
};
 
/**
 * The inline reply box on an unanswered review (frames 7501-72341 / 7506-74389).
 *
 * The send button only appears once there is something to send, and the field
 * grows with the reply rather than scrolling inside a fixed height — both from
 * the annotation on frame 7506-74389. Growth is `GiveInput`'s own multiline
 * autosize, so the typography comes from the theme rather than from a hand-styled
 * textarea.
 *
 * The draft is cleared only once the POST has resolved successfully: a 409
 * (already replied), a 400 (past the 30-day window) or a timeout leaves the
 * merchant with what they typed rather than an emptied field.
 *
 * The caller decides whether to render this at all: it is withheld once a reply
 * exists and once the 30-day window has closed, so there is no disabled state
 * to design for.
 */
const EventReviewReplyComposer = ({
  onSubmit,
  isSubmitting,
  reviewId,
}: Props) => {
  const { palette } = useAppTheme();
  const [body, setBody] = useState("");
  const trimmed = body.trim();
  const canSend = trimmed.length > 0 && !isSubmitting;
 
  const submit = async () => {
    Iif (!canSend) return;
    const published = await onSubmit(trimmed);
    if (published) setBody("");
  };
 
  return (
    <Stack gap="8px" data-testid={`event-review-composer-${reviewId}`}>
      <GiveText variant="bodyS" color="secondary">
        Reply
      </GiveText>
      <Field>
        <GiveInput
          name={`event-review-reply-${reviewId}`}
          multiline
          minRows={MIN_ROWS}
          value={body}
          onChange={(e) => setBody(e.target.value)}
          placeholder={REPLY_PLACEHOLDER}
          maxLength={REPLY_MAX_LENGTH}
          inputProps={{
            maxLength: REPLY_MAX_LENGTH,
            "aria-label": "Reply to this review",
          }}
          sx={{
            "& .MuiOutlinedInput-root": { paddingRight: "48px !important" },
          }}
        />
        {/* Only once there is something to send (annotation, frame 7506-74389). */}
        {trimmed.length > 0 && (
          <SendButton
            Icon={ArrowUpIcon}
            size="extraSmall"
            weight="bold"
            color={palette.primitive?.neutral?.["0"]}
            bgColor={palette.primitive?.blue?.["100"]}
            onClick={submit}
            disabled={!canSend}
            aria-label="Publish reply"
            data-testid={`event-review-send-${reviewId}`}
          />
        )}
      </Field>
    </Stack>
  );
};
 
export default EventReviewReplyComposer;
 
const Field = styled(Box)(() => ({
  position: "relative",
  display: "flex",
  width: "100%",
}));
 
// Pinned to the field's bottom-right corner, where the frames put it. MUI's
// IconButton keeps the focus ring and the disabled semantics a bare <button>
// would have had to re-declare.
const SendButton = styled(GiveIconButton)(({ theme }) => ({
  position: "absolute",
  right: "12px",
  bottom: "12px",
  width: "28px",
  height: "28px",
  backgroundColor: theme.palette.primitive?.blue?.["100"],
  "&:hover": { backgroundColor: theme.palette.primitive?.blue?.["100"] },
  "&.Mui-disabled": { opacity: 0.6 },
}));