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