Skip to content

Commit

Permalink
feat: gift user modal (#4091)
Browse files Browse the repository at this point in the history
  • Loading branch information
sshanzel authored Jan 24, 2025
1 parent aa05492 commit 82928d4
Show file tree
Hide file tree
Showing 11 changed files with 343 additions and 25 deletions.
23 changes: 12 additions & 11 deletions packages/shared/src/components/RecommendedMention.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,22 @@ import classNames from 'classnames';
import type { ReactElement } from 'react';
import React from 'react';
import { UserShortInfo } from './profile/UserShortInfo';

interface RecommendedUser {
id: string;
image: string;
name: string;
username: string;
permalink: string;
}
import type { UserShortProfile } from '../lib/user';

interface RecommendedMentionProps {
users: RecommendedUser[];
users: UserShortProfile[];
selected: number;
onClick?: (username: string) => unknown;
onClick?: (user: UserShortProfile) => unknown;
onHover?: (index: number) => unknown;
checkIsDisabled?: (user: UserShortProfile) => unknown;
}

export function RecommendedMention({
users,
selected,
onClick,
onHover,
checkIsDisabled,
}: RecommendedMentionProps): ReactElement {
if (!users?.length) {
return null;
Expand All @@ -38,16 +35,20 @@ export function RecommendedMention({
className={{
container: classNames(
'cursor-pointer p-3',
checkIsDisabled?.(user)
? 'pointer-events-none opacity-64'
: 'cursor-pointer',
index === selected && 'bg-theme-active',
),
}}
imageSize="large"
tag="li"
onClick={() => onClick(user.username)}
onClick={() => onClick(user)}
aria-selected={index === selected}
role="option"
disableTooltip
showDescription={false}
onHover={() => onHover?.(index)}
/>
))}
</ul>
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/components/modals/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,10 @@ const ReportUserModal = dynamic(
),
);

const GiftPlusModal = dynamic(
() => import(/* webpackChunkName: "giftPlusModal" */ '../plus/GiftPlusModal'),
);

export const modals = {
[LazyModal.SquadMember]: SquadMemberModal,
[LazyModal.UpvotedPopup]: UpvotedPopupModal,
Expand Down Expand Up @@ -266,6 +270,7 @@ export const modals = {
[LazyModal.AddToCustomFeed]: AddToCustomFeedModal,
[LazyModal.CookieConsent]: CookieConsentModal,
[LazyModal.ReportUser]: ReportUserModal,
[LazyModal.GiftPlus]: GiftPlusModal,
};

type GetComponentProps<T> = T extends
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/components/modals/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export enum LazyModal {
AddToCustomFeed = 'addToCustomFeed',
CookieConsent = 'cookieConsent',
ReportUser = 'reportUser',
GiftPlus = 'giftPlus',
}

export type ModalTabItem = {
Expand Down
232 changes: 232 additions & 0 deletions packages/shared/src/components/plus/GiftPlusModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import type { ReactElement } from 'react';
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { ModalProps } from '../modals/common/Modal';
import { Modal } from '../modals/common/Modal';
import {
Typography,
TypographyColor,
TypographyType,
} from '../typography/Typography';
import CloseButton from '../CloseButton';
import { ButtonSize, ButtonVariant } from '../buttons/common';
import { Button } from '../buttons/Button';
import { PlusTitle } from './PlusTitle';
import {
PaymentContextProvider,
usePaymentContext,
} from '../../contexts/PaymentContext';
import { plusUrl } from '../../lib/constants';
import { TextField } from '../fields/TextField';
import { UserIcon } from '../icons';
import { gqlClient } from '../../graphql/common';
import { RECOMMEND_MENTIONS_QUERY } from '../../graphql/comments';
import { RecommendedMention } from '../RecommendedMention';
import { BaseTooltip } from '../tooltips/BaseTooltip';
import type { UserShortProfile } from '../../lib/user';
import useDebounceFn from '../../hooks/useDebounceFn';
import { ProfileImageSize, ProfilePicture } from '../ProfilePicture';
import { PlusLabelColor, PlusPlanExtraLabel } from './PlusPlanExtraLabel';
import { ArrowKey, KeyboardCommand } from '../../lib/element';

interface SelectedUserProps {
user: UserShortProfile;
onClose: () => void;
}

const SelectedUser = ({ user, onClose }: SelectedUserProps) => {
const { username, name } = user;

return (
<div className="flex w-full max-w-full flex-row items-center gap-2 rounded-10 bg-surface-float p-2">
<ProfilePicture user={user} size={ProfileImageSize.Medium} />
<span className="flex w-full flex-1 flex-row items-center gap-2 truncate">
<Typography bold type={TypographyType.Callout}>
{name}
</Typography>
<Typography
type={TypographyType.Footnote}
color={TypographyColor.Secondary}
>
{username}
</Typography>
</span>
<CloseButton type="button" onClick={onClose} size={ButtonSize.XSmall} />
</div>
);
};

interface GiftPlusModalProps extends ModalProps {
preselected?: UserShortProfile;
}

export function GiftPlusModalComponent({
preselected,
...props
}: GiftPlusModalProps): ReactElement {
const [overlay, setOverlay] = useState<HTMLElement>();
const { onRequestClose } = props;
const { oneTimePayment } = usePaymentContext();
const [selected, setSelected] = useState(preselected);
const [index, setIndex] = useState(0);
const [query, setQuery] = useState('');
const [onSearch] = useDebounceFn(setQuery, 500);
const { data: users } = useQuery<UserShortProfile[]>({
queryKey: ['search', 'users', query],
queryFn: async () => {
const result = await gqlClient.request(RECOMMEND_MENTIONS_QUERY, {
query,
});

return result.recommendedMentions;
},
enabled: !!query?.length,
});
const isVisible = !!users?.length && !!query?.length;
const onKeyDown = (e: React.KeyboardEvent) => {
const movement = [ArrowKey.Up, ArrowKey.Down, KeyboardCommand.Enter];
if (!movement.includes(e.key as (typeof movement)[number])) {
return;
}

e.preventDefault();

if (e.key === ArrowKey.Down) {
setIndex((prev) => {
let next = prev + 1;
let counter = 0;
while (users[next % users.length]?.isPlus) {
next += 1;
counter += 1;

if (counter > users.length) {
return -1;
}
}
return next % users.length;
});
} else if (e.key === ArrowKey.Up) {
setIndex((prev) => {
let next = prev - 1;
let counter = 0;
while (users[(next + users.length) % users.length]?.isPlus) {
next -= 1;
counter += 1;

if (counter > users.length) {
return -1;
}
}
return (next + users.length) % users.length;
});
} else {
setSelected(users[index]);
}
};

const onSelect = (user: UserShortProfile) => {
setSelected(user);
setIndex(0);
setQuery('');
};

return (
<Modal
{...props}
isOpen
kind={Modal.Kind.FixedCenter}
size={Modal.Size.Small}
overlayRef={setOverlay}
>
<Modal.Body className="gap-4">
<div className="flex flex-row justify-between">
<PlusTitle type={TypographyType.Callout} bold />
<CloseButton
type="button"
size={ButtonSize.Small}
onClick={onRequestClose}
/>
</div>
<Typography bold type={TypographyType.Title1}>
Gift daily.dev Plus 🎁
</Typography>
{selected ? (
<SelectedUser user={selected} onClose={() => setSelected(null)} />
) : (
<div className="flex flex-col">
<BaseTooltip
appendTo={overlay}
onClickOutside={() => setQuery('')}
visible={isVisible}
showArrow={false}
interactive
content={
<RecommendedMention
users={users}
selected={index}
onClick={onSelect}
onHover={setIndex}
checkIsDisabled={(user) => user.isPlus}
/>
}
container={{
className: 'shadow',
paddingClassName: 'p-0',
roundedClassName: 'rounded-16',
bgClassName: 'bg-accent-pepper-subtlest',
}}
>
<TextField
leftIcon={<UserIcon />}
inputId="search_user"
fieldType="tertiary"
autoComplete="off"
label="Select a recipient by name or handle"
onKeyDown={onKeyDown}
onChange={(e) => onSearch(e.currentTarget.value.trim())}
onFocus={(e) => setQuery(e.currentTarget.value.trim())}
/>
</BaseTooltip>
</div>
)}
<div className="flex w-full flex-row items-center gap-2 rounded-10 bg-surface-float p-2">
<Typography bold type={TypographyType.Callout}>
One-year plan
</Typography>
<PlusPlanExtraLabel
color={PlusLabelColor.Success}
label={oneTimePayment.extraLabel}
typographyProps={{ color: TypographyColor.StatusSuccess }}
/>
<Typography type={TypographyType.Body} className="ml-auto mr-1">
<strong className="mr-1">{oneTimePayment?.price}</strong>
{oneTimePayment?.currencyCode}
</Typography>
</div>
<Typography type={TypographyType.Callout}>
Gift one year of daily.dev Plus for {oneTimePayment?.price}. Once the
payment is processed, they’ll be notified of your gift. This is a
one-time purchase, not a recurring subscription.
</Typography>
<Button
tag="a"
variant={ButtonVariant.Primary}
href={`${plusUrl}?giftToUserId=${selected?.id}`}
disabled={!selected}
>
Gift & Pay {oneTimePayment?.price}
</Button>
</Modal.Body>
</Modal>
);
}

export function GiftPlusModal(props: ModalProps): ReactElement {
return (
<PaymentContextProvider>
<GiftPlusModalComponent {...props} />
</PaymentContextProvider>
);
}

export default GiftPlusModal;
16 changes: 7 additions & 9 deletions packages/shared/src/components/plus/PlusComparingCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { IconSize } from '../Icon';
import { useFeature } from '../GrowthBookProvider';
import { feature } from '../../lib/featureManagement';
import { PlusPriceType } from '../../lib/featureValues';
import { PlusLabelColor, PlusPlanExtraLabel } from './PlusPlanExtraLabel';

export enum OnboardingPlans {
Free = 'Free',
Expand Down Expand Up @@ -122,15 +123,12 @@ const PlusCard = ({
{heading.label}
</Typography>
{hasDiscount && discountPlan?.extraLabel && (
<Typography
tag={TypographyTag.Span}
type={TypographyType.Caption1}
color={TypographyColor.StatusHelp}
className="ml-3 rounded-10 bg-action-help-float px-2 py-1"
bold
>
{discountPlan.extraLabel}
</Typography>
<PlusPlanExtraLabel
color={PlusLabelColor.Help}
label={discountPlan.extraLabel}
className="ml-3"
typographyProps={{ color: TypographyColor.StatusHelp }}
/>
)}
</div>
<div className="flex items-baseline gap-0.5">
Expand Down
40 changes: 40 additions & 0 deletions packages/shared/src/components/plus/PlusPlanExtraLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { ReactElement } from 'react';
import React from 'react';
import classNames from 'classnames';
import type { AllowedTags, TypographyProps } from '../typography/Typography';
import {
Typography,
TypographyTag,
TypographyType,
} from '../typography/Typography';

interface PlusPlanExtraLabelProps {
label: string;
color: PlusLabelColor;
className?: string;
typographyProps?: Omit<TypographyProps<AllowedTags>, 'className'>;
}

export enum PlusLabelColor {
Success = 'bg-action-upvote-float',
Help = 'bg-action-help-float',
}

export function PlusPlanExtraLabel({
label,
color,
className,
typographyProps = {},
}: PlusPlanExtraLabelProps): ReactElement {
return (
<Typography
tag={TypographyTag.Span}
type={TypographyType.Caption1}
{...typographyProps}
className={classNames('rounded-10 px-2 py-1', color, className)}
bold
>
{label}
</Typography>
);
}
Loading

0 comments on commit 82928d4

Please sign in to comment.