All files / src/features/Events/EventDetail/host InviteHostModal.tsx

96% Statements 24/25
100% Branches 12/12
85.71% Functions 6/7
95.83% Lines 23/24

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 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164                                    1x     1x               1x       40x                   1x 67x 67x 67x 67x   67x                     67x       67x 27x     67x 7x         3x 2x   1x             3x   3x 3x               4x                   67x                                                                                                                    
import { useEffect } from "react";
import NiceModal, { useModal } from "@ebay/nice-modal-react";
import { Stack } from "@mui/material";
import { FormProvider, useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as Yup from "yup";
import { useQueryClient } from "react-query";
import GiveBaseModal from "@shared/modals/GiveBaseModal";
import GiveButton from "@shared/Button/GiveButton";
import GiveText from "@shared/Text/GiveText";
import { HFGiveInput } from "@shared/HFInputs/HFGiveInput/HFGiveInput";
import { showMessage } from "@common/Toast";
import { QKEY_EVENT_HOSTS, QKEY_LIST_TEAM_MEMBERS } from "@constants/queryKeys";
import { useGetCurrentMerchantId } from "@hooks/common";
import { useInviteEventHost } from "@services/api/products/hosts";
import { isValidEmail } from "@validation/regex";
 
export const INVITE_HOST_HELPER =
  "An invitation will be sent to this email address, and the host will need to accept it and log in to the GiveCash mobile app.";
 
/** Frame 7117-39026 draws no label above the field, so this names it for AT. */
export const INVITE_HOST_FIELD_LABEL = "Host email address";
 
type Props = {
  eventId?: string;
};
 
type FormValues = { email: string };
 
const schema = Yup.object().shape({
  email: Yup.string()
    .required("Please enter an email.")
    .test("valid-email", "Please enter a valid email.", (value) =>
      isValidEmail(value || ""),
    ),
});
 
/**
 * "Invite Host" (frames 7117-39026 / 7117-39652).
 *
 * One email per invitation — the annotation on 7117-39652 is explicit, so this
 * is a plain field rather than the tag input the team-member modal uses.
 */
const InviteHostModal = NiceModal.create(({ eventId }: Props) => {
  const modal = useModal();
  const queryClient = useQueryClient();
  const { merchantId } = useGetCurrentMerchantId();
  const { mutate: invite, isLoading } = useInviteEventHost(merchantId, eventId);
 
  const methods = useForm<FormValues>({
    resolver: yupResolver(schema),
    defaultValues: { email: "" },
    mode: "onChange",
  });
 
  const {
    formState: { isValid },
    handleSubmit,
    setError,
    reset,
  } = methods;
 
  // NiceModal keeps the component mounted across hide/show, so without this the
  // previous attempt's email (and its field error) greets the next open.
  useEffect(() => {
    if (modal.visible) reset();
  }, [modal.visible, reset]);
 
  const onSubmit = ({ email }: FormValues) =>
    invite(email.trim(), {
      onSuccess: ({ invitationSent }) => {
        // An address that had already joined another merchant is seated
        // straight away, with no invitation to accept — saying one was sent
        // would send the merchant looking for an email that does not exist.
        if (invitationSent) {
          showMessage("Success", "", true, "Invitation Sent");
        } else {
          showMessage(
            "Success",
            "They already have a GiveCash account, so no invitation was needed.",
            true,
            "Host Added",
          );
        }
        queryClient.invalidateQueries([QKEY_EVENT_HOSTS]);
        // A host is an account member, so Settings → Team gained a row too.
        queryClient.invalidateQueries(QKEY_LIST_TEAM_MEMBERS);
        modal.hide();
      },
      // Surfaced on the field rather than as a toast: the most common failure
      // is an address that is already a member of this merchant (409), which
      // is about the value the merchant just typed. Field-level rejections
      // arrive in the `input` envelope, non-field ones in `message` — the same
      // pair `useInviteTeamMember` reads.
      onError: (error: any) =>
        setError("email", {
          type: "manual",
          message:
            error?.response?.data?.input?.[0]?.message ||
            error?.response?.data?.message ||
            error?.message ||
            "Could not send the invitation.",
        }),
    });
 
  return (
    <GiveBaseModal
      open={modal.visible}
      title="Invite Host"
      width="600px"
      height="fit-content"
      onClose={modal.hide}
      // `hide` only closes it; without the unmount on exit the next open still
      // holds the previous email and its error.
      TransitionProps={{ onExited: () => modal.remove() }}
      buttons={
        <Stack direction="row" gap="12px">
          <GiveButton
            variant="ghost"
            size="large"
            label="Cancel"
            onClick={modal.hide}
          />
          <GiveButton
            size="large"
            variant="filled"
            color="primary"
            label="Invite"
            onClick={handleSubmit(onSubmit)}
            disabled={!isValid || isLoading}
          />
        </Stack>
      }
    >
      <FormProvider {...methods}>
        <Stack gap="8px">
          {/* `disabled` is passed explicitly because HFGiveInput hard-sets it
              on the underlying GiveInput and only the caller's value overrides
              it — an omitted prop renders the field read-only.
 
              The mockup has no visible label, and an empty `label` renders no
              FormLabel at all, so the accessible name comes from aria-label. */}
          <HFGiveInput
            name="email"
            placeholder="Enter email"
            type="email"
            disabled={false}
            autoFocus
            inputProps={{ "aria-label": INVITE_HOST_FIELD_LABEL }}
          />
          {/* Deliberately its own line rather than the input's `helper` prop:
              HFGiveInput resolves helperText as `helper || error?.message`, so
              a permanent helper would swallow every validation and API error. */}
          <GiveText variant="bodyXS" color="secondary">
            {INVITE_HOST_HELPER}
          </GiveText>
        </Stack>
      </FormProvider>
    </GiveBaseModal>
  );
});
 
export default InviteHostModal;