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 2 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,18 @@
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 { 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,20 +27,29 @@ 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
const UserNavigation: FC<UserNavigationProps> = ({
extra = null,
userHub,
txButton = null,
isInColony,
}) => {
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 isWalletConnected = !!wallet?.address;
const networkInfo = useGetCurrentNetwork();
Expand Down Expand Up @@ -79,6 +90,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 md:hover:!border-blue-400"
onClick={() => setIsInviteMembersModalOpen(true)}
/>
) : null}
{txButton}
{isWalletConnected ? (
<div className="flex gap-1">
Expand Down Expand Up @@ -121,6 +144,12 @@ const UserNavigation: FC<UserNavigationProps> = ({
/>
)}
{extra}
{isInColony && (
<InviteMembersModal
isOpen={isInviteMembersModalOpen}
onClose={() => setIsInviteMembersModalOpen(false)}
/>
)}
</div>
);
};
Expand Down
1 change: 1 addition & 0 deletions src/components/common/Extensions/UserNavigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export interface UserNavigationProps {
extra?: ReactNode;
userHub: ReactNode;
txButton?: ReactNode;
isInColony?: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const UserNavigationWrapper: FC<UserNavigationWrapperProps> = ({
extra,
isHidden,
className,
isInColony,
}) => {
const userHubComponent = userHub || <HeaderAvatar />;
const userNavigation = (
Expand All @@ -23,6 +24,7 @@ const UserNavigationWrapper: FC<UserNavigationWrapperProps> = ({
txButton={txButton}
userHub={userHubComponent}
extra={extra}
isInColony={isInColony}
/>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export interface UserNavigationWrapperProps {
extra?: ReactNode;
isHidden?: boolean;
className?: string;
isInColony?: boolean;
}
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,14 @@
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 { useMemberContext } from '~context/MemberContext/MemberContext.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 +19,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 people to join and follow this colony during early access. If you run out, you will be able to request more.',
},
Copy link
Contributor

@rumzledz rumzledz Jan 21, 2025

Choose a reason for hiding this comment

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

Checking with @JoinColony/product if you want "people" to be "person" if there's only 1 invite available

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 +63,46 @@ const MSG = defineMessages({

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

Copy link
Contributor

Choose a reason for hiding this comment

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

I think we could make use of the const { totalMemberCount } = useMemberContext(); here to compute how many invites are used and colonyMemberInvite?.invitesRemaining for deciding how many invites are available, thus replacing lines 74-79 with

  const inviteLink = useBaseUrl(
    `/invite/${colonyName}/${colonyMemberInvite?.id}`,
  );
  const { totalMemberCount } = useMemberContext();
  const invitesAvailable = colonyMemberInvite?.invitesRemaining || 0;
  const invitesUsed = totalMemberCount;
  const isOutOfInvites = invitesUsed >= invitesAvailable;

Copy link
Contributor

Choose a reason for hiding this comment

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

@arrenv do we want to hardcode the invites limit to 100?
I think it would be best to actually use the stored remaining invites, as well as the number of existing followers and decide based on that if we are or are not out of invites

Copy link
Member

@arrenv arrenv Jan 7, 2025

Choose a reason for hiding this comment

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

@CzarekDryl & @mmioana no, I was not previously aware this was hardcoded, this is a value that can be increased in the database. As a result of this and to minimise impact on the database, we updated the design last week to remove the need to show the upper limit and just show the remaining available.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for your response @arrenv 🙌

In this case and after looking over the code, I saw we actually have a lambda in place that will subtract 1 from the remaining invites once a user accept it. Initially, due to our dev env I thought we need to account also for the existing number of followers 😶‍🌫️

So, in this case @CzarekDryl, I believe isOutOfInvites should just check if invitesAvailable is 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,141 @@
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 CardWithCallout from '~v5/shared/CardWithCallout/index.ts';
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 people to join and follow this colony during early access. If you run out, you will be able to request more.',
Copy link
Contributor

Choose a reason for hiding this comment

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

Same question about "people" pluralisation here @JoinColony/product

},
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:
'{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 ✨

}`,
},
});

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

const inviteLink = useBaseUrl(
`/invite/${colonyName}/${colonyMemberInvite?.id}`,
);
const commonClassName = 'rounded-lg p-2 text-sm font-medium';
Copy link
Contributor

Choose a reason for hiding this comment

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

You can write this outside the component since it's just a constant, so it's not part of the re-render cycle


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