Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add invite modal #4009

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { Cardholder, GearSix, List, X } from '@phosphor-icons/react';
import { Cardholder, GearSix, List, UserPlus, X } from '@phosphor-icons/react';
import React, { useState, type FC } from 'react';
import { defineMessages } from 'react-intl';
import { usePopperTooltip } from 'react-popper-tooltip';

import { TourTargets } from '~common/Tours/enums.ts';
import { DEFAULT_NETWORK_INFO } from '~constants';
import { useActionSidebarContext } from '~context/ActionSidebarContext/ActionSidebarContext.ts';
import { useAppContext } from '~context/AppContext/AppContext.ts';
import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts';
import { usePageLayoutContext } from '~context/PageLayoutContext/PageLayoutContext.ts';
import { useMobile } from '~hooks/index.ts';
import useDisableBodyScroll from '~hooks/useDisableBodyScroll/index.ts';
import useGetCurrentNetwork from '~hooks/useGetCurrentNetwork.ts';
import { formatText } from '~utils/intl.ts';
import InviteMembersModal from '~v5/common/Modals/InviteMembersModal/index.ts';
import useNavigationSidebarContext from '~v5/frame/NavigationSidebar/partials/NavigationSidebarContext/hooks.ts';
import Button, { Hamburger } from '~v5/shared/Button/index.ts';

Expand All @@ -25,6 +28,10 @@ const MSG = defineMessages({
id: `${displayName}.unlockedToken`,
defaultMessage: `Please switch your wallet to the {correctNetworkName} network. More chains will be supported in future.`,
},
invite: {
id: `${displayName}.invite`,
defaultMessage: 'Invite',
},
});

// @TODO: Rename this to something more explanatory
Expand All @@ -33,12 +40,19 @@ const UserNavigation: FC<UserNavigationProps> = ({
userHub,
txButton = null,
}) => {
const { wallet, connectWallet } = useAppContext();
const { wallet, connectWallet, user } = useAppContext();
const isMobile = useMobile();
const { setOpenItemIndex, mobileMenuToggle } = useNavigationSidebarContext();
const [, { toggleOff }] = mobileMenuToggle;
const { setShowTabletColonyPicker, setShowTabletSidebar } =
usePageLayoutContext();
const { actionSidebarToggle } = useActionSidebarContext();
const [isInviteMembersModalOpen, setIsInviteMembersModalOpen] =
useState(false);
const [isActionSidebarOpen] = actionSidebarToggle;

const colonyContext = useColonyContext({ nullableContext: true });
const isInColony = !!colonyContext;

const isWalletConnected = !!wallet?.address;
const networkInfo = useGetCurrentNetwork();
Expand Down Expand Up @@ -79,6 +93,18 @@ const UserNavigation: FC<UserNavigationProps> = ({

return (
<div data-tour={TourTargets.UserMenu} className="flex gap-1 md:relative">
{!isActionSidebarOpen && isWalletConnected && user && isInColony ? (
<Button
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that this isInColony boolean is prop drilled at least 3 levels deep. I think it's better if you just use the context within this component:

const colonyContext = useColonyContext({ nullableContext: true });

const isInColony = !!colonyContext;

...

{!isActionSidebarOpen && isWalletConnected && user && isInColony ? (

text={isMobile ? undefined : MSG.invite}
mode="tertiary"
icon={UserPlus}
iconSize={16}
size="small"
isFullRounded
className="gap-1 px-3.5 sm:px-2.5 md:hover:!border-blue-400"
onClick={() => setIsInviteMembersModalOpen(true)}
/>
) : null}
{txButton}
{isWalletConnected ? (
<div className="flex gap-1">
Expand Down Expand Up @@ -121,6 +147,12 @@ const UserNavigation: FC<UserNavigationProps> = ({
/>
)}
{extra}
{isInColony && (
<InviteMembersModal
isOpen={isInviteMembersModalOpen}
onClose={() => setIsInviteMembersModalOpen(false)}
/>
)}
</div>
);
};
Expand Down
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually let's please refactor this component a bit, given we have lots of conditional content based on the isOutOfInvites flag

So my proposal would be the following

const InviteMembersModal = ({ isOpen, onClose }: Props) => {
  const {
    colony: { colonyMemberInvite, name: colonyName },
  } = useColonyContext();
  const inviteLink = useBaseUrl(
    `/invite/${colonyName}/${colonyMemberInvite?.id}`,
  );
  const { totalMemberCount } = useMemberContext();
  const invitesAvailable = colonyMemberInvite?.invitesRemaining || 0;
  const invitesUsed = totalMemberCount;
  const isOutOfInvites = invitesUsed >= invitesAvailable;

  const { handleClipboardCopy, isCopied } = useCopyToClipboard();

  const commonClassName = 'rounded-lg p-2 text-sm font-medium';

  const getModalSubtitle = () => {
    if (isOutOfInvites) {
      <FormattedMessage
        {...MSG.modalDescriptionReached}
        values={{ invitesAvailable }}
      />;
    }

    return (
      <FormattedMessage
        {...MSG.modalDescription}
        values={{ invitesAvailable }}
      />
    );
  };

  const getModalContent = () => {
    if (isOutOfInvites) {
      return (
        <CardWithCallout
          title={
            <span
              className={clsx(
                commonClassName,
                'bg-negative-100 text-negative-400',
              )}
            >
              <FormattedMessage
                {...MSG.invitesUsed}
                values={{ invitesAvailable }}
              />
            </span>
          }
          subtitle={<FormattedMessage {...MSG.limitReached} />}
          button={
            <Link
              to={getRequestInvitesLink(colonyName)}
              target="_blank"
              className="flex min-h-8.5 items-center justify-center gap-2 whitespace-nowrap 
              rounded-lg border border-gray-900 bg-base-white px-2.5 py-1.5 text-sm font-medium text-gray-900 
              transition-all duration-normal disabled:border-gray-300 disabled:text-gray-300 md:hover:border-gray-900 
              md:hover:bg-gray-900 md:hover:!text-base-white"
            >
              {formatText(MSG.requestInvites)}
            </Link>
          }
        >
          <FormattedMessage {...MSG.requestMoreInvites} />
        </CardWithCallout>
      );
    }

    return (
      <CardWithCallout
        title={
          <span className={clsx(commonClassName, 'bg-blue-100 text-blue-400')}>
            <FormattedMessage
              {...MSG.invitesUsed}
              values={{ invitesAvailable }}
            />
          </span>
        }
        subtitle={<FormattedMessage {...MSG.inviteLinkHeading} />}
        button={
          <Button
            text={MSG.buttonText}
            mode={isCopied ? 'completed' : 'quinary'}
            icon={isCopied ? undefined : CopySimple}
            onClick={() => handleClipboardCopy(inviteLink)}
            size="small"
            textValues={{ isCopied }}
          />
        }
      >
        {inviteLink}
      </CardWithCallout>
    );
  };

  return (
    <Modal isOpen={isOpen} onClose={() => onClose()}>
      <div className="mb-8 mt-10 flex flex-col items-center">
        <SmileySticker size={42} className="mb-3" />
        <Heading3
          appearance={{ theme: 'dark' }}
          className="font-semibold text-gray-900"
          text={MSG.modalTitle}
        />
        <p className="mt-1 text-center text-sm text-gray-600">
          {getModalSubtitle()}
        </p>
      </div>
      {getModalContent()}
    </Modal>
  );
};

Of course, bonus points if you feel like splitting the code up into partial components 💯

Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { CopySimple } from '@phosphor-icons/react';
import { SmileySticker } from '@phosphor-icons/react';
import React from 'react';
import { FormattedMessage, defineMessages } from 'react-intl';

import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts';
import useBaseUrl from '~hooks/useBaseUrl.ts';
import useCopyToClipboard from '~hooks/useCopyToClipboard.ts';
import { Heading3 } from '~shared/Heading/index.ts';
import Button from '~v5/shared/Button/index.ts';
import CardWithCallout from '~v5/shared/CardWithCallout/index.ts';
import Modal from '~v5/shared/Modal/index.ts';

import { ModalContent } from './partials/ModalContent.tsx';

const displayName = 'v5.common.Modals.InviteMembersModal';

interface Props {
Expand All @@ -20,20 +18,38 @@ interface Props {
const MSG = defineMessages({
modalTitle: {
id: `${displayName}.modalTitle`,
defaultMessage: 'Invite members to the private beta',
defaultMessage: 'Invite people to this colony',
},
modalDescription: {
id: `${displayName}.modalDescription`,
defaultMessage:
'You can invite up to 100 members to your Colony during the private beta using the invite link below.',
'You can invite {invitesAvailable} more {invitesAvailable, plural, one {person} other {people}} to join and follow this colony during early access. If you run out, you will be able to request more.',
},
modalDescriptionReached: {
id: `${displayName}.modalDescriptionReached`,
defaultMessage:
'You have reached your invite limit during early access. If you need more, please make a request.',
},
invitesUsed: {
id: `${displayName}.invitesUsed`,
defaultMessage: '{invitesUsed}/{invitesAvailable} invites used',
defaultMessage:
'{invitesAvailable} {invitesAvailable, plural, one {invite} other {invites}} remaining',
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inviteLinkHeading: {
id: `${displayName}.inviteLinkHeading`,
defaultMessage: 'Your unique invite link:',
defaultMessage: 'Unique colony invite link:',
},
limitReached: {
id: `${displayName}.limitReached`,
defaultMessage: 'Invite limit reached',
},
requestInvites: {
id: `${displayName}.requestInvites`,
defaultMessage: 'Request invites',
},
requestMoreInvites: {
id: `${displayName}.requestMoreInvites`,
defaultMessage: 'Request more invites for your colony',
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove these unused descriptions

buttonText: {
id: `${displayName}.buttonText`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also this one

Expand All @@ -46,52 +62,44 @@ const MSG = defineMessages({

const InviteMembersModal = ({ isOpen, onClose }: Props) => {
const {
colony: { colonyMemberInvite, name: colonyName },
colony: { colonyMemberInvite },
} = useColonyContext();
const invitesAvailable = colonyMemberInvite?.invitesRemaining || 0;
const isOutOfInvites = invitesAvailable === 0;

const invitesAvailable = 100;
const inviteLink = useBaseUrl(
`/invite/${colonyName}/${colonyMemberInvite?.id}`,
);
const invitesUsed = 100 - (colonyMemberInvite?.invitesRemaining || 0);
const getModalSubtitle = () => {
if (isOutOfInvites) {
<FormattedMessage
{...MSG.modalDescriptionReached}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dropped a return here, we have a tiny bug because of it 👀

values={{ invitesAvailable }}
/>;
}

const { handleClipboardCopy, isCopied } = useCopyToClipboard();
return (
<FormattedMessage
{...MSG.modalDescription}
values={{ invitesAvailable }}
/>
);
};

return (
<Modal isOpen={isOpen} onClose={() => onClose()}>
<div className="mb-8 mt-10 flex flex-col items-center">
<SmileySticker size={42} className="mb-3" />
<Heading3
appearance={{ theme: 'dark' }}
className="font-semibold text-gray-900"
text={MSG.modalTitle}
/>
<p className="mt-1 text-center text-sm text-gray-600">
<FormattedMessage {...MSG.modalDescription} />
{getModalSubtitle()}
</p>
</div>
<CardWithCallout
title={
<span className="rounded-lg bg-gray-100 p-2 text-sm font-medium text-gray-900">
<FormattedMessage
{...MSG.invitesUsed}
values={{ invitesAvailable, invitesUsed }}
/>
</span>
}
subtitle={<FormattedMessage {...MSG.inviteLinkHeading} />}
button={
<Button
text={MSG.buttonText}
mode={isCopied ? 'completed' : 'quinary'}
icon={isCopied ? undefined : CopySimple}
onClick={() => handleClipboardCopy(inviteLink)}
size="small"
textValues={{ isCopied }}
/>
}
>
{inviteLink}
</CardWithCallout>
<ModalContent
isOutOfInvites={isOutOfInvites}
invitesAvailable={invitesAvailable}
/>
</Modal>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { CopySimple } from '@phosphor-icons/react';
import clsx from 'clsx';
import React, { type FC } from 'react';
import { FormattedMessage, defineMessages } from 'react-intl';

import { getRequestInvitesLink } from '~constants/externalUrls.ts';
import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts';
import useBaseUrl from '~hooks/useBaseUrl.ts';
import useCopyToClipboard from '~hooks/useCopyToClipboard.ts';
import { formatText } from '~utils/intl.ts';
import Button from '~v5/shared/Button/index.ts';
import Card from '~v5/shared/Card/Card.tsx';
import Link from '~v5/shared/Link/Link.tsx';

const displayName = 'v5.common.Modals.InviteMembersModal.partials.ModalContent';

interface Props {
isOutOfInvites: boolean;
invitesAvailable: number;
}

const MSG = defineMessages({
modalTitle: {
id: `${displayName}.modalTitle`,
defaultMessage: 'Invite people to this colony',
},
modalDescription: {
id: `${displayName}.modalDescription`,
defaultMessage:
'You can invite {invitesAvailable} more {invitesAvailable, plural, one {person} other {people}} to join and follow this colony during early access. If you run out, you will be able to request more.',
},
modalDescriptionReached: {
id: `${displayName}.modalDescriptionReached`,
defaultMessage:
'You have reached your invite limit during early access. If you need more, please make a request.',
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove these unused translations

invitesUsed: {
id: `${displayName}.invitesUsed`,
defaultMessage:
'{invitesAvailable} {invitesAvailable, plural, one {invite} other {invites}} remaining',
},
inviteLinkHeading: {
id: `${displayName}.inviteLinkHeading`,
defaultMessage: 'Unique colony invite link:',
},
limitReached: {
id: `${displayName}.limitReached`,
defaultMessage: 'Invite limit reached',
},
requestInvites: {
id: `${displayName}.requestInvites`,
defaultMessage: 'Request invites',
},
requestMoreInvites: {
id: `${displayName}.requestMoreInvites`,
defaultMessage: 'Request more invites for your colony',
},
buttonText: {
id: `${displayName}.buttonText`,
defaultMessage: `{isCopied, select,
true {Link copied}
other {Copy link}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noice ✨

}`,
},
});

const commonClassName = 'rounded-lg p-2 text-sm font-medium';

export const ModalContent: FC<Props> = ({
isOutOfInvites,
invitesAvailable,
}) => {
const {
colony: { colonyMemberInvite, name: colonyName },
} = useColonyContext();
const { handleClipboardCopy, isCopied } = useCopyToClipboard();

const inviteLink = useBaseUrl(
`/invite/${colonyName}/${colonyMemberInvite?.id}`,
);

if (isOutOfInvites) {
return (
<Card>
<div className="mb-1.5 flex items-center gap-x-2">
<h2
className={clsx(
commonClassName,
'bg-negative-100 text-negative-400',
)}
>
<FormattedMessage
{...MSG.invitesUsed}
values={{ invitesAvailable }}
/>
</h2>
</div>
<div>
<div className="flex w-full items-center justify-between gap-3">
<div>
<h3 className="mb-1 text-md font-medium">
<FormattedMessage {...MSG.limitReached} />
</h3>
<p className="text-sm text-gray-600">
<FormattedMessage {...MSG.requestMoreInvites} />
</p>
</div>
<Link
to={getRequestInvitesLink(colonyName)}
target="_blank"
className="flex min-h-8.5 items-center justify-center gap-2 whitespace-nowrap
rounded-lg border border-gray-900 bg-base-white px-2.5 py-1.5 text-sm font-medium text-gray-900
transition-all duration-normal disabled:border-gray-300 disabled:text-gray-300 md:hover:border-gray-900
md:hover:bg-gray-900 md:hover:!text-base-white"
>
{formatText(MSG.requestInvites)}
</Link>
</div>
</div>
</Card>
);
}

return (
<Card>
<div className="mb-1.5 flex items-center gap-x-2">
<h2 className={clsx(commonClassName, 'bg-blue-100 text-blue-400')}>
<FormattedMessage
{...MSG.invitesUsed}
values={{ invitesAvailable }}
/>
</h2>
</div>
<div>
<div className="flex items-center gap-3">
<div>
<h3 className="mb-1 text-md font-medium">
<FormattedMessage {...MSG.inviteLinkHeading} />
</h3>
<p className="break-all text-sm text-gray-600">{inviteLink}</p>
</div>
<Button
text={MSG.buttonText}
mode={isCopied ? 'completed' : 'quinary'}
icon={isCopied ? undefined : CopySimple}
onClick={() => handleClipboardCopy(inviteLink)}
size="small"
textValues={{ isCopied }}
/>
</div>
</div>
</Card>
);
};
2 changes: 2 additions & 0 deletions src/constants/externalUrls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export const TERMS_AND_CONDITIONS = `https://colony.io/terms-of-service`;
export const ADVANCED_SETTINGS = `https://docs.colony.io/use/advanced-features/`;
export const REQUEST_ACCESS = `https://colony.io/request-access`;
export const REQUEST_INVITES = `https://colony.io/request-invite`;
export const getRequestInvitesLink = (colonyName: string) =>
`https://colony.io/request-member-invites?colony=${colonyName}`;

/*
* Utils
Expand Down
Loading