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: gift user modal #4091

Merged
merged 33 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
4637cf2
feat: gift user modal
sshanzel Jan 21, 2025
b6701f1
Merge branch 'main' into MI-751
sshanzel Jan 22, 2025
707572f
feat: search user with mention
sshanzel Jan 22, 2025
3f53473
tmp: show modal
sshanzel Jan 22, 2025
7797626
fix: on change handler
sshanzel Jan 22, 2025
c9c4e2d
fix: debounce
sshanzel Jan 22, 2025
6649903
fix: keyboard navigation
sshanzel Jan 22, 2025
4c43d6c
fix: arrow and on focus
sshanzel Jan 22, 2025
f1c525b
feat: on hover, set selected
sshanzel Jan 22, 2025
84091c1
fix: ref
sshanzel Jan 22, 2025
bbc0c94
fix: index
sshanzel Jan 22, 2025
92d8ee2
fix: overlay append
sshanzel Jan 22, 2025
f577767
fix: interactivity
sshanzel Jan 22, 2025
663a58c
fix: on close
sshanzel Jan 22, 2025
f6c79ff
fix: selected ui
sshanzel Jan 22, 2025
84ab16b
fix: gap
sshanzel Jan 22, 2025
3924acb
fix: alignment
sshanzel Jan 22, 2025
a3c0455
fix: roundness
sshanzel Jan 22, 2025
7a3fd26
fix: TODO
sshanzel Jan 22, 2025
e24a2fb
fix: checking disabled
sshanzel Jan 22, 2025
720a6f1
Revert "tmp: show modal"
sshanzel Jan 22, 2025
3726a1b
fix: missing context provider
sshanzel Jan 22, 2025
72a8eab
Merge branch 'main' into MI-751
sshanzel Jan 22, 2025
dee522c
fix: modal pricing
sshanzel Jan 22, 2025
f25e989
fix: missing preselected user
sshanzel Jan 22, 2025
5b435c1
Merge branch 'main' into MI-751
sshanzel Jan 22, 2025
058f1de
Merge branch 'main' into MI-751
sshanzel Jan 23, 2025
4318c3b
fix: extra label
sshanzel Jan 23, 2025
e055a80
refactor: variable name
sshanzel Jan 23, 2025
066ad9f
fix: loop condition
sshanzel Jan 23, 2025
d9e0f03
refactor: usage of enums
sshanzel Jan 23, 2025
eaa4ae2
chore: cleanup
sshanzel Jan 23, 2025
837a213
Merge branch 'main' into MI-751
sshanzel Jan 23, 2025
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
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
231 changes: 231 additions & 0 deletions packages/shared/src/components/plus/GiftPlusModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
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';

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 = ['ArrowUp', 'ArrowDown', 'Enter'];
Copy link
Contributor

Choose a reason for hiding this comment

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

We could use KeyboardCommand and ArrowKey enums wdyt?

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh, right. Forgot they existed. Let me update it!

Copy link
Contributor

Choose a reason for hiding this comment

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

@sshanzel ping

Copy link
Member Author

Choose a reason for hiding this comment

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

Totally forgot about this 🤦

Copy link
Member Author

Choose a reason for hiding this comment

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

@ilasw should be available now 🚀

if (!movement.includes(e.key)) {
return;
}

e.preventDefault();

if (e.key === 'ArrowDown') {
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 === 'ArrowUp') {
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
Copy link
Contributor

Choose a reason for hiding this comment

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

is this prop needed?

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah, should be not since it is always going to be true. Let me clean it up.

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} />{' '}
Copy link
Contributor

Choose a reason for hiding this comment

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

The space probably not necessary here ? 🤔

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep. I think it was linting that did this. I'll remove. Thank you!

</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}
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess ideally it would come from paddle but it's ok for now.

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
Loading