diff --git a/packages/shared/package.json b/packages/shared/package.json index 542933dfec..5ceab81f9f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -103,7 +103,7 @@ "@growthbook/growthbook": "https://gitpkg.now.sh/dailydotdev/growthbook/packages/sdk-js?e354fcf41b2b3f67590294a0e2cdfb56044d7a1e", "@growthbook/growthbook-react": "^0.17.0", "@marsidev/react-turnstile": "^1.1.0", - "@paddle/paddle-js": "^1.3.2", + "@paddle/paddle-js": "1.4.0", "@tippyjs/react": "^4.2.6", "check-password-strength": "^2.0.10", "fetch-event-stream": "^0.1.5", diff --git a/packages/shared/src/components/ProfilePicture.tsx b/packages/shared/src/components/ProfilePicture.tsx index 9cda71e643..e8dd157c65 100644 --- a/packages/shared/src/components/ProfilePicture.tsx +++ b/packages/shared/src/components/ProfilePicture.tsx @@ -23,7 +23,7 @@ export enum ProfileImageSize { } type ProfileImageRoundSize = ProfileImageSize | 'full'; -type UserImageProps = Pick & +export type UserImageProps = Pick & Partial>; export interface ProfilePictureProps diff --git a/packages/shared/src/components/award/AwardButton.tsx b/packages/shared/src/components/award/AwardButton.tsx new file mode 100644 index 0000000000..f9c2c76c02 --- /dev/null +++ b/packages/shared/src/components/award/AwardButton.tsx @@ -0,0 +1,89 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { ButtonProps } from '../buttons/Button'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../buttons/Button'; +import { MedalBadgeIcon } from '../icons'; +import { SimpleTooltip } from '../tooltips'; +import { AuthTriggers } from '../../lib/auth'; +import { LazyModal } from '../modals/common/types'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useLazyModal } from '../../hooks/useLazyModal'; +import type { + AwardEntity, + AwardTypes, +} from '../../contexts/GiveAwardModalContext'; +import { useRequestProtocol } from '../../hooks/useRequestProtocol'; +import { getCompanionWrapper } from '../../lib/extension'; +import type { Post } from '../../graphql/posts'; + +type AwardButtonProps = { + appendTo?: 'parent' | Element | ((ref: Element) => Element); + type: AwardTypes; + className?: string; + entity: AwardEntity; + post?: Post; +} & Pick, 'pressed' | 'variant'>; +export const AwardButton = ({ + appendTo: appendToProps, + type, + className, + entity, + pressed, + variant = ButtonVariant.Tertiary, + post, +}: AwardButtonProps): ReactElement => { + const { isCompanion } = useRequestProtocol(); + const { user, showLogin } = useAuthContext(); + const { openModal } = useLazyModal(); + + const openGiveAwardModal = () => { + if (!user) { + return showLogin({ trigger: AuthTriggers.GiveAward }); + } + + return openModal({ + type: LazyModal.GiveAward, + props: { + type, + entity, + post, + }, + }); + }; + + if (user && entity.receiver?.id === user.id) { + return null; + } + + const defaultAppendTo = isCompanion ? getCompanionWrapper : 'parent'; + const appendTo = appendToProps || defaultAppendTo; + + return ( + +
+
+
+ ); +}; diff --git a/packages/shared/src/components/comments/CommentActionButtons.tsx b/packages/shared/src/components/comments/CommentActionButtons.tsx index 3b01563066..2fa411c58f 100644 --- a/packages/shared/src/components/comments/CommentActionButtons.tsx +++ b/packages/shared/src/components/comments/CommentActionButtons.tsx @@ -52,6 +52,12 @@ import { ContentPreferenceType } from '../../graphql/contentPreference'; import { isFollowingContent } from '../../hooks/contentPreference/types'; import { useIsSpecialUser } from '../../hooks/auth/useIsSpecialUser'; import { GiftIcon } from '../icons/gift'; +import { AwardButton } from '../award/AwardButton'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; export interface CommentActionProps { onComment: (comment: Comment, parentId: string | null) => void; @@ -350,6 +356,29 @@ export default function CommentActionButtons({ color={ButtonColor.BlueCheese} /> + + {!!comment.numAwards && ( + + {/* TODO feat/transactions show most expensive award image next to count */} + {largeNumberFormat(comment.numAwards)} Award + {comment.numAwards > 1 ? 's' : ''} + + )} + ); +}; + +export const CoreOptionButtonPlaceholder = (): ReactElement => { + return ( + + ); +}; diff --git a/packages/shared/src/components/cores/CoreOptionList.tsx b/packages/shared/src/components/cores/CoreOptionList.tsx new file mode 100644 index 0000000000..f35dd5f1fe --- /dev/null +++ b/packages/shared/src/components/cores/CoreOptionList.tsx @@ -0,0 +1,41 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + CoreOptionButton, + CoreOptionButtonPlaceholder, +} from './CoreOptionButton'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { transactionPricesQueryOptions } from '../../graphql/njord'; + +export const CoreOptionList = (): ReactElement => { + const { user, isLoggedIn } = useAuthContext(); + const { data: prices, isPending: isPendingPrices } = useQuery( + transactionPricesQueryOptions({ + user, + isLoggedIn, + }), + ); + + return ( +
    + {isPendingPrices && + new Array(9).fill(null).map((_, index) => { + // eslint-disable-next-line react/no-array-index-key + return ; + })} + {!isPendingPrices && + prices?.map((price) => { + return ( + + ); + })} +
+ ); +}; diff --git a/packages/shared/src/components/cores/FeaturedCoresWidget.tsx b/packages/shared/src/components/cores/FeaturedCoresWidget.tsx new file mode 100644 index 0000000000..cb77dabd4e --- /dev/null +++ b/packages/shared/src/components/cores/FeaturedCoresWidget.tsx @@ -0,0 +1,94 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import classNames from 'classnames'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { transactionPricesQueryOptions } from '../../graphql/njord'; +import { webappUrl } from '../../lib/constants'; +import { Button } from '../buttons/Button'; +import { ButtonVariant } from '../buttons/common'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../typography/Typography'; +import { WidgetContainer } from '../widgets/common'; +import { BuyCore } from './BuyCore'; +import type { LogStartBuyingCreditsProps } from '../../types'; +import type { Origin } from '../../lib/log'; + +export const FeaturedCoresWidget = ({ + className, + onClick, + origin, + amounts, +}: { + className?: string; + onClick: (props: LogStartBuyingCreditsProps) => void; + origin: Origin; + amounts: number[]; +}): ReactElement => { + const { user, isLoggedIn } = useAuthContext(); + + const { data: prices, isPending: isPendingPrices } = useQuery( + transactionPricesQueryOptions({ + user, + isLoggedIn, + }), + ); + + return ( + +
+ + Buy Cores + + + Stock up on Cores to engage, reward, and unlock more on daily.dev + +
+
+ {isPendingPrices && + amounts.map((itemAmount, index) => { + return ( + + ); + })} + {!isPendingPrices && + prices + ?.filter((item) => amounts.includes(item.coresValue)) + .map((item) => { + return ( + + ); + })} +
+ +
+ ); +}; diff --git a/packages/shared/src/components/cores/TransactionItem.tsx b/packages/shared/src/components/cores/TransactionItem.tsx new file mode 100644 index 0000000000..0fba9c17b7 --- /dev/null +++ b/packages/shared/src/components/cores/TransactionItem.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import classNames from 'classnames'; +import { + CoinIcon, + CreditCardIcon, + InfoIcon, + MinusIcon, + PlusIcon, +} from '../icons'; +import { Typography, TypographyType } from '../typography/Typography'; +import type { UserImageProps } from '../ProfilePicture'; +import { ProfileImageSize, ProfilePicture } from '../ProfilePicture'; +import { Separator } from '../cards/common/common'; +import { DateFormat } from '../utilities/DateFormat'; +import { TimeFormatType } from '../../lib/dateFormat'; +import { IconSize } from '../Icon'; +import type { TransactionItemType } from '../../lib/transaction'; +import { formatCoresCurrency } from '../../lib/utils'; + +export type TransactionItemProps = { + type: TransactionItemType; + user: UserImageProps; + date: Date; + amount: number; + label: ReactNode; +}; + +const TransactionTypeToIcon: Record< + TransactionItemProps['type'], + ReactElement +> = { + receive: ( +
+ +
+ ), + send: ( +
+ +
+ ), + purchase: ( +
+ +
+ ), + unknown: ( +
+ +
+ ), +}; + +export const TransactionItem = ({ + type, + user, + date, + amount, + label, +}: TransactionItemProps): ReactElement => { + return ( +
  • +
    + {TransactionTypeToIcon[type]} + {' '} +
    + + {user.name} + +
    + + {label} + +
    + + +
    +
    +
    +
    +
    + = 0 && 'text-accent-bun-default')} + /> + + {`${amount >= 0 ? '+' : ''}${formatCoresCurrency(amount)}`} + +
    +
  • + ); +}; diff --git a/packages/shared/src/components/credit/BuyCreditsButton.tsx b/packages/shared/src/components/credit/BuyCreditsButton.tsx new file mode 100644 index 0000000000..76d3d20055 --- /dev/null +++ b/packages/shared/src/components/credit/BuyCreditsButton.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import type { ReactElement } from 'react'; +import classNames from 'classnames'; +import { CoinIcon, PlusIcon } from '../icons'; + +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import Link from '../utilities/Link'; +import { webappUrl } from '../../lib/constants'; +import { anchorDefaultRel } from '../../lib/strings'; +import { isIOSNative } from '../../lib/func'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { largeNumberFormat } from '../../lib'; +import { LogEvent, Origin } from '../../lib/log'; +import { useLogContext } from '../../contexts/LogContext'; +import { useModalContext } from '../modals/common/types'; + +type BuyCreditsButtonProps = { + className?: string; + onPlusClick?: () => void; + hideBuyButton?: boolean; +}; +export const BuyCreditsButton = ({ + className, + onPlusClick, + hideBuyButton, +}: BuyCreditsButtonProps): ReactElement => { + const isInsideModal = useModalContext().onRequestClose !== null; + const { user } = useAuthContext(); + + const renderBuyButton = !isIOSNative() && !hideBuyButton; + const { logEvent } = useLogContext(); + const trackBuyCredits = () => { + logEvent({ + event_name: LogEvent.StartBuyingCredits, + extra: JSON.stringify({ origin: Origin.Award }), + }); + onPlusClick?.(); + }; + + return ( +
    + + + + {renderBuyButton ? ( + <> +
    +
    + ); +}; diff --git a/packages/shared/src/components/fields/ProgressBar.tsx b/packages/shared/src/components/fields/ProgressBar.tsx index bd2dd5cafc..f6acaa84ab 100644 --- a/packages/shared/src/components/fields/ProgressBar.tsx +++ b/packages/shared/src/components/fields/ProgressBar.tsx @@ -41,7 +41,7 @@ export function ProgressBar({ className?.bar, className?.barColor ?? 'bg-accent-cabbage-default', )} - style={{ width: `${percentage}%` }} + style={{ width: `${percentage >= 0 ? percentage : 0}%` }} /> ); diff --git a/packages/shared/src/components/icons/Coin/index.tsx b/packages/shared/src/components/icons/Coin/index.tsx new file mode 100644 index 0000000000..e0fa5cfa21 --- /dev/null +++ b/packages/shared/src/components/icons/Coin/index.tsx @@ -0,0 +1,9 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { IconProps } from '../../Icon'; +import Icon from '../../Icon'; +import MonoIcon from './mono.svg'; + +export const CoinIcon = (props: IconProps): ReactElement => ( + +); diff --git a/packages/shared/src/components/icons/Coin/mono.svg b/packages/shared/src/components/icons/Coin/mono.svg new file mode 100644 index 0000000000..70eac51080 --- /dev/null +++ b/packages/shared/src/components/icons/Coin/mono.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/shared/src/components/icons/CreditCard/filled.svg b/packages/shared/src/components/icons/CreditCard/filled.svg new file mode 100644 index 0000000000..e27a8abd60 --- /dev/null +++ b/packages/shared/src/components/icons/CreditCard/filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/shared/src/components/icons/CreditCard/index.tsx b/packages/shared/src/components/icons/CreditCard/index.tsx new file mode 100644 index 0000000000..d8a640c0d6 --- /dev/null +++ b/packages/shared/src/components/icons/CreditCard/index.tsx @@ -0,0 +1,10 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { IconProps } from '../../Icon'; +import Icon from '../../Icon'; +import OutlinedIcon from './outlined.svg'; +import FilledIcon from './filled.svg'; + +export const CreditCardIcon = (props: IconProps): ReactElement => ( + +); diff --git a/packages/shared/src/components/icons/CreditCard/outlined.svg b/packages/shared/src/components/icons/CreditCard/outlined.svg new file mode 100644 index 0000000000..aea2a01d73 --- /dev/null +++ b/packages/shared/src/components/icons/CreditCard/outlined.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts index 7a8cfee3dd..8786343c9d 100644 --- a/packages/shared/src/components/icons/index.ts +++ b/packages/shared/src/components/icons/index.ts @@ -14,6 +14,7 @@ export * from './BringForward'; export * from './Calendar'; export * from './Camera'; export * from './Card'; +export * from './Coin'; export * from './CardLayout'; export * from './ChecklistA'; export * from './ChecklistB'; @@ -134,3 +135,4 @@ export * from './EditPrompt'; export * from './CustomPrompt'; export * from './TLDR'; export * from './Privacy'; +export * from './CreditCard'; diff --git a/packages/shared/src/components/modals/award/BuyCoresModal.tsx b/packages/shared/src/components/modals/award/BuyCoresModal.tsx new file mode 100644 index 0000000000..ef64f91cd6 --- /dev/null +++ b/packages/shared/src/components/modals/award/BuyCoresModal.tsx @@ -0,0 +1,401 @@ +import classNames from 'classnames'; +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import { ModalKind } from '../common/types'; +import type { ModalProps } from '../common/Modal'; +import { Modal } from '../common/Modal'; +import { useViewSizeClient, ViewSize } from '../../../hooks'; +import { ModalBody } from '../common/ModalBody'; +import { + BuyCoresContextProvider, + useBuyCoresContext, +} from '../../../contexts/BuyCoresContext'; +import { BuyCreditsButton } from '../../credit/BuyCreditsButton'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { CoinIcon } from '../../icons'; +import { useGiveAwardModalContext } from '../../../contexts/GiveAwardModalContext'; +import { IconSize } from '../../Icon'; +import { CoreOptionList } from '../../cores/CoreOptionList'; +import { CoreAmountNeeded } from '../../cores/CoreAmountNeeded'; +import type { Product, UserTransaction } from '../../../graphql/njord'; +import { + getTransactionByProvider, + transactionRefetchIntervalMs, + UserTransactionStatus, +} from '../../../graphql/njord'; +import { generateQueryKey, RequestKey } from '../../../lib/query'; +import { oneMinute } from '../../../lib/dateFormat'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { + purchaseCoinsCheckoutVideoPoster, + purchaseCoinsCheckoutVideo, +} from '../../../lib/image'; +import { webappUrl } from '../../../lib/constants'; +import { Loader } from '../../Loader'; +import { useIsLightTheme } from '../../../hooks/utils'; +import type { Origin } from '../../../lib/log'; +import { formatCoresCurrency } from '../../../lib/utils'; + +export const CoreOptions = ({ + className, + title, + showCoresAtCheckout, +}: { + className?: string; + title?: ReactNode; + showCoresAtCheckout?: boolean; +}): ReactElement => { + return ( +
    + {title} +
    + + {!!showCoresAtCheckout && } +
    + +
    + ); +}; + +export const BuyCoresCheckout = ({ + className, +}: { + className?: string; +}): ReactElement => { + const isLightTheme = useIsLightTheme(); + + return ( +
    +
    +
    + ); +}; + +const statusToMessageMap: Record = { + [UserTransactionStatus.Created]: 'Checking your data...', + [UserTransactionStatus.Processing]: 'Processing your payment...', + [UserTransactionStatus.Success]: 'Almost done...', + [UserTransactionStatus.ErrorRecoverable]: 'There was an issue, retrying...', + [UserTransactionStatus.Error]: ( + <> + Something went wrong! +
    + + + check status here + + + + ), +}; + +const ProcessingLoading = ({ + transaction, +}: { + transaction?: UserTransaction; +}) => { + const statusMessage = + statusToMessageMap[transaction?.status] || + statusToMessageMap[UserTransactionStatus.Processing]; + const isError = transaction?.status === UserTransactionStatus.Error; + + return ( + <> + + + {statusMessage} + + {!isError && } + + ); +}; + +const ProcessingCompleted = () => { + const { onCompletion, selectedProduct } = useBuyCoresContext(); + + return ( + <> + + + {formatCoresCurrency(selectedProduct.value)} + + + You got your Cores! + + + Success! Your Cores are now available in your balance. + + + + ); +}; + +export const BuyCoresProcessing = ({ ...props }: ModalProps): ReactElement => { + const { user, updateUser } = useAuthContext(); + const { onCompletion, activeStep, setActiveStep } = useBuyCoresContext(); + const isProcessing = activeStep === 'PROCESSING'; + const queryClient = useQueryClient(); + + const { providerTransactionId } = useBuyCoresContext(); + + const { data: transaction } = useQuery({ + queryKey: [RequestKey.Transactions, { providerId: providerTransactionId }], + queryFn: async () => { + const result = await getTransactionByProvider({ + providerId: providerTransactionId, + }); + + if (result?.balance) { + updateUser({ + ...user, + balance: result.balance, + }); + } + + queryClient.invalidateQueries({ + queryKey: generateQueryKey(RequestKey.Transactions, user), + exact: false, + }); + + return result; + }, + enabled: !!providerTransactionId, + refetchInterval: (query) => { + const transactionStatus = query.state.data?.status; + + const retries = Math.max( + query.state.dataUpdateCount, + query.state.fetchFailureCount, + ); + + // transactions are mostly processed withing few seconds + // so for now we stop retrying after 1 minute + const maxRetries = (oneMinute * 1000) / transactionRefetchIntervalMs; + + if (retries > maxRetries) { + // TODO feat/transactions redirect user to /earnings to monitor their transaction there if they want + + return false; + } + + if ( + [ + UserTransactionStatus.Created, + UserTransactionStatus.Processing, + UserTransactionStatus.ErrorRecoverable, + ].includes(transactionStatus) + ) { + return transactionRefetchIntervalMs; + } + + if (transactionStatus === UserTransactionStatus.Error) { + // TODO show error message + + return false; + } + + return false; + }, + }); + + useEffect(() => { + if (transaction?.status === UserTransactionStatus.Success) { + setActiveStep({ + step: 'COMPLETED', + providerTransactionId, + }); + } + }, [transaction?.status, providerTransactionId, setActiveStep]); + + return ( + { + // TODO feat/transactions if processing interupt the modal close, tell user can check transaction in /earnings + + return onCompletion(); + }} + isDrawerOnMobile + > + + {isProcessing ? ( + + ) : ( + + )} + + + ); +}; + +const BuyCoresMobile = () => { + const { selectedProduct, openCheckout, paddle } = useBuyCoresContext(); + + useEffect(() => { + if (!paddle) { + return; + } + + if (selectedProduct) { + openCheckout({ priceId: selectedProduct.id }); + } + }, [openCheckout, selectedProduct, paddle]); + + return ( + + {selectedProduct ? : } + + ); +}; + +export const CorePageCheckoutVideo = (): ReactElement => { + return ( +
    +
    + ); +}; + +const BuyCoreDesktop = () => { + const { selectedProduct } = useBuyCoresContext(); + + return ( + +
    + + +
    + {!selectedProduct && } +
    +
    +
    + ); +}; + +const BuyFlow = ({ ...props }: ModalProps): ReactElement => { + const { setActiveModal } = useGiveAwardModalContext(); + const isMobile = useViewSizeClient(ViewSize.MobileL); + + return ( + + + setActiveModal('BUY_CORES')} + /> + {isMobile && ( + + )} + + {isMobile ? : } + + ); +}; + +export const TransactionStatusListener = (props: ModalProps): ReactElement => { + const { activeStep } = useBuyCoresContext(); + + if (['PROCESSING', 'COMPLETED'].includes(activeStep)) { + return ; + } + + return null; +}; + +const ModalRender = ({ ...props }: ModalProps) => { + const { activeStep } = useBuyCoresContext(); + + return ( + <> + + {activeStep === 'INTRO' && } + + ); +}; + +type BuyCoresModalProps = ModalProps & { + origin: Origin; + onCompletion?: () => void; + product: Product; +}; +export const BuyCoresModal = ({ + origin, + onCompletion, + product, + ...props +}: BuyCoresModalProps): ReactElement => { + return ( + + + + ); +}; diff --git a/packages/shared/src/components/modals/award/GiveAwardModal.tsx b/packages/shared/src/components/modals/award/GiveAwardModal.tsx new file mode 100644 index 0000000000..9d85bd9084 --- /dev/null +++ b/packages/shared/src/components/modals/award/GiveAwardModal.tsx @@ -0,0 +1,412 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import classNames from 'classnames'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import type { ModalProps } from '../common/Modal'; +import { Modal } from '../common/Modal'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../typography/Typography'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { ArrowIcon, CoinIcon } from '../../icons'; +import { Image } from '../../image/Image'; +import { cloudinaryAwardUnicorn } from '../../../lib/image'; +import type { + AwardEntity, + AwardTypes, +} from '../../../contexts/GiveAwardModalContext'; +import { + GiveAwardModalContextProvider, + maxNoteLength, + useGiveAwardModalContext, +} from '../../../contexts/GiveAwardModalContext'; +import { Justify } from '../../utilities'; +import MarkdownInput from '../../fields/MarkdownInput'; +import { termsOfService } from '../../../lib/constants'; +import { useToastNotification, useViewSize, ViewSize } from '../../../hooks'; +import { ModalKind } from '../common/types'; +import { IconSize } from '../../Icon'; +import { BuyCreditsButton } from '../../credit/BuyCreditsButton'; +import { BuyCoresModal } from './BuyCoresModal'; +import type { Product } from '../../../graphql/njord'; +import { award, getProducts } from '../../../graphql/njord'; +import { labels, largeNumberFormat } from '../../../lib'; +import type { ApiErrorResult } from '../../../graphql/common'; +import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { anchorDefaultRel } from '../../../lib/strings'; +import { Origin } from '../../../lib/log'; +import type { Post } from '../../../graphql/posts'; + +const AwardItem = ({ + item, + onClick: handleClick, +}: { + item: Product; + onClick: (props: { product: Product; event?: MouseEvent }) => void; +}) => { + const { logAwardEvent } = useGiveAwardModalContext(); + + return ( + + ); +}; + +const IntroScreen = () => { + const [showBuyCores, setShowBuyCores] = useState(false); + const { user } = useAuthContext(); + const { + onRequestClose, + type, + entity, + setActiveModal, + setActiveStep, + product, + } = useGiveAwardModalContext(); + const isMobile = useViewSize(ViewSize.MobileL); + + const { data: awards } = useQuery({ + queryKey: generateQueryKey(RequestKey.Products), + queryFn: () => getProducts(), + staleTime: StaleTime.Default, + }); + + const onBuyCores = () => { + setShowBuyCores(false); + setActiveModal('BUY_CORES'); + }; + + return ( + <> + + + {isMobile ? ( + + ) : null} + + + {type !== 'USER' && !!entity.numAwards && ( +
    + Award unicorn + + {largeNumberFormat(entity.numAwards)} Awards given + +
    + )} + + Show your appreciation! Pick an Award to send to{' '} + + {entity.receiver.name || `@${entity.receiver.username}`} + + ! + +
    + {awards?.edges?.map(({ node: item }) => ( + { + if (clickedProduct.value > user.balance.amount) { + setActiveStep({ screen: 'INTRO', product: clickedProduct }); + setShowBuyCores(true); + + return; + } + + setShowBuyCores(false); + setActiveStep({ screen: 'COMMENT', product: clickedProduct }); + }} + /> + ))} +
    +
    + {showBuyCores && !!product && ( + + + + Awards may include a revenue share with the recipient and are + subject to our{' '} + + Terms of Service + + . + + + )} + + ); +}; + +const CommentScreen = () => { + const { updateUser, user } = useAuthContext(); + const { + setActiveStep, + type, + entity, + product, + onRequestClose, + logAwardEvent, + } = useGiveAwardModalContext(); + const isMobile = useViewSize(ViewSize.MobileL); + const { displayToast } = useToastNotification(); + const [note, setNote] = useState(''); + + const { mutate: awardMutation, isPending } = useMutation({ + mutationKey: [ + 'awards', + { productId: product?.id, type, entityId: entity.id, note }, + ], + mutationFn: award, + onSuccess: (result) => { + // TODO feat/transactions animation show award + displayToast('Award sent successfully! ❤️'); + + updateUser({ + ...user, + balance: result.balance, + }); + + onRequestClose(undefined); + }, + onError: (data: ApiErrorResult) => { + displayToast( + data?.response?.errors?.[0]?.message || labels.error.generic, + ); + }, + }); + + const onAwardClick = useCallback(() => { + logAwardEvent({ awardEvent: 'AWARD', extra: { award: product.value } }); + awardMutation({ + productId: product.id, + type, + entityId: entity.id, + note, + }); + }, [ + awardMutation, + entity.id, + logAwardEvent, + note, + product.id, + product.value, + type, + ]); + + return ( + <> + + + + Awards may include a revenue share with the recipient and are subject + to our{' '} + + Terms of Service + + . + + + + ); +}; + +const ModalBody = () => { + const { activeStep } = useGiveAwardModalContext(); + return ( + <> + {activeStep === 'INTRO' ? : null} + {activeStep === 'COMMENT' ? : null} + + ); +}; + +const ModalRender = ({ ...props }: ModalProps) => { + const isMobile = useViewSize(ViewSize.MobileL); + const { activeModal, setActiveModal, product, logAwardEvent } = + useGiveAwardModalContext(); + + const trackingRef = useRef(false); + + useEffect(() => { + if (!trackingRef.current) { + trackingRef.current = true; + logAwardEvent({ awardEvent: 'START' }); + } + }, [logAwardEvent]); + + const onCompletion = useCallback(() => { + setActiveModal('AWARD'); + }, [setActiveModal]); + + return ( + <> + {activeModal === 'AWARD' ? ( + + + + ) : null} + {activeModal === 'BUY_CORES' ? ( + + ) : null} + + ); +}; + +type GiveAwardModalProps = ModalProps & { + type: AwardTypes; + entity: AwardEntity; + post?: Post; +}; +const GiveAwardModal = (props: GiveAwardModalProps): ReactElement => { + return ( + + + + ); +}; + +export default GiveAwardModal; diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index 702c6f9a09..2549cda284 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -258,6 +258,11 @@ const GiftReceivedPlusModal = dynamic( ), ); +const GiveAwardModal = dynamic( + () => + import(/* webpackChunkName: "giveAwardModal" */ './award/GiveAwardModal'), +); + export const modals = { [LazyModal.SquadMember]: SquadMemberModal, [LazyModal.UpvotedPopup]: UpvotedPopupModal, @@ -301,6 +306,7 @@ export const modals = { [LazyModal.PlusMarketing]: PlusMarketingModal, [LazyModal.GiftPlus]: GiftPlusModal, [LazyModal.GiftPlusReceived]: GiftReceivedPlusModal, + [LazyModal.GiveAward]: GiveAwardModal, }; type GetComponentProps = T extends diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index 2c4e7081f1..66f2aaae66 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -67,6 +67,7 @@ export enum LazyModal { SmartPrompt = 'smartPrompt', PlusMarketing = 'plusMarketing', MobileSmartPrompts = 'mobileSmartPrompts', + GiveAward = 'giveAward', } export type ModalTabItem = { diff --git a/packages/shared/src/components/plus/PlusList.tsx b/packages/shared/src/components/plus/PlusList.tsx index 5a5916dc70..548db2b575 100644 --- a/packages/shared/src/components/plus/PlusList.tsx +++ b/packages/shared/src/components/plus/PlusList.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import type { WithClassNameProps } from '../utilities'; import type { PlusItem, PlusListItemProps } from './PlusListItem'; import { PlusItemStatus, PlusListItem } from './PlusListItem'; +import { CoinIcon } from '../icons'; export const defaultFeatureList: Array = [ { @@ -34,6 +35,16 @@ export const defaultFeatureList: Array = [ ]; export const plusFeatureList: Array = [ + { + icon: CoinIcon, + typographyProps: { + bold: true, + }, + // TODO feat/transactions replace with real data + label: 'Get {{x}} Cores every {{month/year}}', + status: PlusItemStatus.Ready, + tooltip: `Unlock {{x}} Cores every {{month/year}} to access exclusive content, features, and benefits.`, + }, { label: 'Run prompts on any post', status: PlusItemStatus.Ready, @@ -95,7 +106,13 @@ export const PlusList = ({ return (
      {items.map((item) => ( - + ))}
    ); diff --git a/packages/shared/src/components/plus/PlusListItem.tsx b/packages/shared/src/components/plus/PlusListItem.tsx index 4e4a42697f..20530fe4c7 100644 --- a/packages/shared/src/components/plus/PlusListItem.tsx +++ b/packages/shared/src/components/plus/PlusListItem.tsx @@ -23,6 +23,8 @@ export interface PlusItem { label: string; status: PlusItemStatus; tooltip?: string; + icon?: FC; + typographyProps?: TypographyProps; } export interface PlusListItemProps { diff --git a/packages/shared/src/components/post/PostActions.tsx b/packages/shared/src/components/post/PostActions.tsx index e30a12eba2..50de692851 100644 --- a/packages/shared/src/components/post/PostActions.tsx +++ b/packages/shared/src/components/post/PostActions.tsx @@ -7,12 +7,13 @@ import { DiscussIcon as CommentIcon, DownvoteIcon, LinkIcon, + MedalBadgeIcon, } from '../icons'; import type { Post } from '../../graphql/posts'; import { UserVote } from '../../graphql/posts'; import { QuaternaryButton } from '../buttons/QuaternaryButton'; import type { PostOrigin } from '../../hooks/log/useLogContextData'; -import { useVotePost } from '../../hooks'; +import { useMutationSubscription, useVotePost } from '../../hooks'; import { Origin } from '../../lib/log'; import { Card } from '../cards/common/Card'; import ConditionalWrapper from '../ConditionalWrapper'; @@ -21,6 +22,13 @@ import { useBlockPostPanel } from '../../hooks/post/useBlockPostPanel'; import { useBookmarkPost } from '../../hooks/useBookmarkPost'; import { ButtonColor, ButtonVariant } from '../buttons/Button'; import { BookmarkButton } from '../buttons'; +import { AuthTriggers } from '../../lib/auth'; +import { LazyModal } from '../modals/common/types'; +import { useLazyModal } from '../../hooks/useLazyModal'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { SimpleTooltip } from '../tooltips'; +import type { AwardProps } from '../../graphql/njord'; +import { generateQueryKey, RequestKey, updatePostCache } from '../../lib/query'; interface PostActionsProps { post: Post; @@ -38,6 +46,8 @@ export function PostActions({ onComment, origin = Origin.ArticlePage, }: PostActionsProps): ReactElement { + const { showLogin, user } = useAuthContext(); + const { openModal } = useLazyModal(); const { data, onShowPanel, onClose } = useBlockPostPanel(post); const { showTagsPanel } = data; @@ -67,6 +77,52 @@ export function PostActions({ await toggleDownvote({ payload: post, origin }); }; + useMutationSubscription({ + matcher: ({ mutation }) => { + const [requestKey] = Array.isArray(mutation.options.mutationKey) + ? mutation.options.mutationKey + : []; + + return requestKey === 'awards'; + }, + callback: ({ + variables: mutationVariables, + queryClient: mutationQueryClient, + }) => { + const { entityId, type } = mutationVariables as AwardProps; + + mutationQueryClient.invalidateQueries({ + queryKey: generateQueryKey(RequestKey.Transactions, user), + exact: false, + }); + + if (type === 'POST') { + if (entityId !== post.id) { + return; + } + + updatePostCache(mutationQueryClient, post.id, { + userState: { + ...post.userState, + awarded: true, + }, + numAwards: (post.numAwards || 0) + 1, + }); + + return; + } + + if (type === 'COMMENT') { + mutationQueryClient.invalidateQueries({ + queryKey: generateQueryKey(RequestKey.PostComments, undefined, { + postId: post.id, + }), + exact: false, + }); + } + }, + }); + return ( Copy + {!!post.author && ( + { + return ( + +
    {children}
    +
    + ); + }} + > + { + if (!user) { + return showLogin({ trigger: AuthTriggers.GiveAward }); + } + + return openModal({ + type: LazyModal.GiveAward, + props: { + type: 'POST', + entity: { + id: post.id, + receiver: post.author, + numAwards: post.numAwards, + }, + post, + }, + }); + }} + icon={} + responsiveLabelClass={actionsClassName} + className={classNames( + 'btn-tertiary-cabbage', + post?.userState?.awarded && 'pointer-events-none', + )} + > + Award + +
    + )}
    diff --git a/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx b/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx index 11092525fc..9f4a5e03e5 100644 --- a/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx +++ b/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx @@ -15,10 +15,10 @@ export function PostUpvotesCommentsCount({ }: PostUpvotesCommentsCountProps): ReactElement { const upvotes = post.numUpvotes || 0; const comments = post.numComments || 0; - const hasUpvotesOrCommentsOrViews = - upvotes > 0 || comments > 0 || post.views > 0; + const awards = post.numAwards || 0; + const hasStats = upvotes > 0 || comments > 0 || post.views > 0 || awards > 0; - return !hasUpvotesOrCommentsOrViews ? ( + return !hasStats ? ( <> ) : (
    )} + {awards > 0 && ( + + {largeNumberFormat(awards)} + {` Award${awards === 1 ? '' : 's'}`} + + )}
    ); } diff --git a/packages/shared/src/components/profile/Awards.tsx b/packages/shared/src/components/profile/Awards.tsx new file mode 100644 index 0000000000..c4e17c0bf0 --- /dev/null +++ b/packages/shared/src/components/profile/Awards.tsx @@ -0,0 +1,62 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { ActivityContainer, ActivitySectionHeader } from './ActivitySection'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { topReaderBadgeDocs } from '../../lib/constants'; +import { ClickableText } from '../buttons/ClickableText'; +import { Image } from '../image/Image'; +import { cloudinaryAwardUnicorn } from '../../lib/image'; + +type AwardProps = { + image: string; + amount: number; +}; +const Award = ({ image, amount }: AwardProps): ReactElement => { + return ( +
    + Award unicorn + + {amount} + +
    + ); +}; + +export const Awards = (): ReactElement => { + return ( + + + + Learn more about how Awards are earned in the{' '} + + daily.dev docs + + +
    + + + + + + + + +
    +
    + ); +}; diff --git a/packages/shared/src/components/profile/Header.tsx b/packages/shared/src/components/profile/Header.tsx index 21c8743592..d1cecb97b3 100644 --- a/packages/shared/src/components/profile/Header.tsx +++ b/packages/shared/src/components/profile/Header.tsx @@ -16,7 +16,6 @@ import { ContentPreferenceStatus, ContentPreferenceType, } from '../../graphql/contentPreference'; -import { UpgradeToPlus } from '../UpgradeToPlus'; import { useContentPreferenceStatusQuery } from '../../hooks/contentPreference/useContentPreferenceStatusQuery'; import { usePlusSubscription } from '../../hooks/usePlusSubscription'; import { LogEvent, TargetId } from '../../lib/log'; @@ -27,6 +26,9 @@ import { LazyModal } from '../modals/common/types'; import { MenuIcon } from '../MenuIcon'; import { GiftIcon } from '../icons/gift'; import type { MenuItemProps } from '../fields/ContextMenu'; +import { AwardButton } from '../award/AwardButton'; +import { BuyCreditsButton } from '../credit/BuyCreditsButton'; +import { webappUrl } from '../../lib/constants'; export interface HeaderProps { user: PublicProfile; @@ -47,7 +49,6 @@ export function Header({ const { openModal } = useLazyModal(); const isMobile = useViewSize(ViewSize.MobileL); const [isMenuOpen, setIsMenuOpen] = useState(false); - const { isPlus } = usePlusSubscription(); const { follow, unfollow } = useContentPreference(); const router = useRouter(); const { data: contentPreference } = useContentPreferenceStatusQuery({ @@ -156,13 +157,6 @@ export function Header({ Edit profile )} - {isSameUser && !isPlus && ( - - )} {!blocked && ( )} + {isSameUser && ( + { + router.push(`${webappUrl}/cores`); + }} + /> + )} + {!isSameUser && ( + + )} {!isSameUser && ( diff --git a/packages/shared/src/components/profile/ProfileButton.tsx b/packages/shared/src/components/profile/ProfileButton.tsx index 927a5ce591..50b780e871 100644 --- a/packages/shared/src/components/profile/ProfileButton.tsx +++ b/packages/shared/src/components/profile/ProfileButton.tsx @@ -5,13 +5,15 @@ import dynamic from 'next/dynamic'; import { useAuthContext } from '../../contexts/AuthContext'; import { ProfileImageSize, ProfilePicture } from '../ProfilePicture'; import { SimpleTooltip } from '../tooltips/SimpleTooltip'; -import { SettingsIcon } from '../icons'; -import { Button, ButtonVariant } from '../buttons/Button'; +import { CoinIcon, SettingsIcon } from '../icons'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { useInteractivePopup } from '../../hooks/utils/useInteractivePopup'; import { ReputationUserBadge } from '../ReputationUserBadge'; import { IconSize } from '../Icon'; import { ReadingStreakButton } from '../streak/ReadingStreakButton'; import { useReadingStreak } from '../../hooks/streaks'; +import { webappUrl } from '../../lib/constants'; +import { largeNumberFormat } from '../../lib'; const ProfileMenu = dynamic( () => import(/* webpackChunkName: "profileMenu" */ '../ProfileMenu'), @@ -48,6 +50,17 @@ export default function ProfileButton({ className="pl-4" /> )} + + + +
    + +
    + + {children} + + ); +} + +export function getCoresLayout(page: ReactNode): ReactNode { + return {page}; +} diff --git a/packages/webapp/package.json b/packages/webapp/package.json index e94bfccdfa..8e338b5502 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -17,7 +17,7 @@ "@dailydotdev/react-contexify": "^5.0.2", "@dailydotdev/shared": "workspace:*", "@marsidev/react-turnstile": "1.1.0", - "@paddle/paddle-js": "^1.3.2", + "@paddle/paddle-js": "1.4.0", "@serwist/next": "^9.0.9", "@tanstack/react-query": "^5.59.16", "@tanstack/react-query-devtools": "^5.59.16", diff --git a/packages/webapp/pages/[userId]/index.tsx b/packages/webapp/pages/[userId]/index.tsx index e4e9127343..89f47371b8 100644 --- a/packages/webapp/pages/[userId]/index.tsx +++ b/packages/webapp/pages/[userId]/index.tsx @@ -18,6 +18,7 @@ import { useReadingStreak } from '@dailydotdev/shared/src/hooks/streaks'; import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; import { NextSeo } from 'next-seo'; import type { NextSeoProps } from 'next-seo/lib/types'; +import { Awards } from '@dailydotdev/shared/src/components/profile/Awards'; import type { ProfileLayoutProps } from '../../components/layouts/ProfileLayout'; import { getLayout as getProfileLayout, @@ -71,7 +72,7 @@ const ProfilePage = ({
    - + {isStreaksEnabled && readingHistory?.userStreakProfile && ( { + const { openCheckout, selectedProduct, paddle } = useBuyCoresContext(); + + const productFromQuery = useCoreProductOptionQuery(); + + useEffect(() => { + if (!paddle) { + return; + } + + const productForCheckout = selectedProduct || productFromQuery; + + if (productForCheckout) { + openCheckout({ priceId: productForCheckout.id }); + } + }, [openCheckout, selectedProduct, productFromQuery, paddle]); + + return ( + + + + ); +}; + +export const PageCoreOptions = (): ReactElement => { + return ( + + Get More Cores + + } + showCoresAtCheckout + /> + ); +}; + +const CorePageMobile = (): ReactElement => { + const { selectedProduct } = useBuyCoresContext(); + const router = useRouter(); + + useEffect(() => { + if (selectedProduct) { + router?.push(`${webappUrl}cores/payment?pid=${selectedProduct?.id}`); + } + }, [router, selectedProduct]); + + return ( + + + + ); +}; + +const CorePageDesktop = (): ReactElement => { + const { selectedProduct } = useBuyCoresContext(); + + return ( + <> +
    +
    + + +
    + {!selectedProduct && } +
    +
    + +
    + + ); +}; + +export const CorePageRenderer = ({ + children, +}: { + children: ReactNode; +}): ReactNode => { + const isLaptop = useViewSizeClient(ViewSize.Laptop); + const { setSelectedProduct, openCheckout, paddle } = useBuyCoresContext(); + const router = useRouter(); + + const productFromQuery = useCoreProductOptionQuery(); + + useEffect(() => { + if (!paddle) { + return; + } + + if (!productFromQuery) { + return; + } + + const params = new URLSearchParams(window.location.search); + params.delete('pid'); + router?.replace(getPathnameWithQuery(router?.pathname, params)); + + if (isLaptop) { + setSelectedProduct(productFromQuery); + + openCheckout({ priceId: productFromQuery.id }); + } + }, [ + isLaptop, + openCheckout, + productFromQuery, + router, + setSelectedProduct, + paddle, + ]); + + return children; +}; + +const CoresPage = (): ReactElement => { + const router = useRouter(); + const isLaptop = useViewSizeClient(ViewSize.Laptop); + const amountNeeded = +router?.query?.need; + + if (!router?.isReady) { + return null; + } + + return ( + // TODO: Take correct origin from referrer + { + router?.push(webappUrl); + }} + amountNeeded={amountNeeded || undefined} + > + + + {isLaptop ? : } + + + ); +}; + +CoresPage.getLayout = getCoresLayout; +CoresPage.layoutProps = { seo }; + +export default CoresPage; diff --git a/packages/webapp/pages/cores/payment.tsx b/packages/webapp/pages/cores/payment.tsx new file mode 100644 index 0000000000..46f306e126 --- /dev/null +++ b/packages/webapp/pages/cores/payment.tsx @@ -0,0 +1,64 @@ +import type { ReactElement } from 'react'; +import React, { useEffect } from 'react'; +import type { NextSeoProps } from 'next-seo/lib/types'; + +import { useRouter } from 'next/router'; +import { useViewSizeClient, ViewSize } from '@dailydotdev/shared/src/hooks'; + +import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; + +import { BuyCoresContextProvider } from '@dailydotdev/shared/src/contexts/BuyCoresContext'; +import { Origin } from '@dailydotdev/shared/src/lib/log'; +import { TransactionStatusListener } from '@dailydotdev/shared/src/components/modals/award/BuyCoresModal'; +import { getTemplatedTitle } from '../../components/layouts/utils'; +import { defaultOpenGraph } from '../../next-seo'; +import { getCoresLayout } from '../../components/layouts/CoresLayout'; +import { CorePageMobileCheckout } from './index'; + +const seo: NextSeoProps = { + title: getTemplatedTitle('TODO: Buy cores title'), + openGraph: { ...defaultOpenGraph }, + description: 'TODO: Buy cores description', +}; + +const CoresPaymentPage = (): ReactElement => { + const isLaptop = useViewSizeClient(ViewSize.Laptop); + const router = useRouter(); + const pid = router?.query?.pid; + + useEffect(() => { + if (!router?.isReady) { + return; + } + + if (isLaptop || !pid) { + router?.replace(`${webappUrl}cores`); + } + }, [pid, router, isLaptop]); + + if (!router?.isReady) { + return null; + } + + if (isLaptop) { + return null; + } + + return ( + // TODO: Take correct origin from referrer + { + router?.push(webappUrl); + }} + > + + + + ); +}; + +CoresPaymentPage.getLayout = getCoresLayout; +CoresPaymentPage.layoutProps = { seo }; + +export default CoresPaymentPage; diff --git a/packages/webapp/pages/earnings.tsx b/packages/webapp/pages/earnings.tsx new file mode 100644 index 0000000000..ac048f1fd3 --- /dev/null +++ b/packages/webapp/pages/earnings.tsx @@ -0,0 +1,452 @@ +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import type { NextSeoProps } from 'next-seo'; +import type { WithClassNameProps } from '@dailydotdev/shared/src/components/utilities'; +import { PageWidgets } from '@dailydotdev/shared/src/components/utilities'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + coresDocsLink, + termsOfService, + webappUrl, + withdrawLink, +} from '@dailydotdev/shared/src/lib/constants'; +import { + CoinIcon, + CreditCardIcon, + DevPlusIcon, + DocsIcon, + FeedbackIcon, + InfoIcon, + MinusIcon, + PlusIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { ListCardDivider } from '@dailydotdev/shared/src/components/cards/common/Card'; +import { WidgetContainer } from '@dailydotdev/shared/src/components/widgets/common'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import SimpleTooltip from '@dailydotdev/shared/src/components/tooltips/SimpleTooltip'; +import classNames from 'classnames'; +import classed from '@dailydotdev/shared/src/lib/classed'; +import { ProgressBar } from '@dailydotdev/shared/src/components/fields/ProgressBar'; + +import { LogEvent, Origin } from '@dailydotdev/shared/src/lib/log'; +import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { + formatCoresCurrency, + formatCurrency, +} from '@dailydotdev/shared/src/lib/utils'; +import { + getTransactionType, + getTransactionLabel, + coreApproxValueUSD, + minCoresEarningsThreshold, +} from '@dailydotdev/shared/src/lib/transaction'; +import { anchorDefaultRel } from '@dailydotdev/shared/src/lib/strings'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { + generateQueryKey, + getNextPageParam, + RequestKey, + StaleTime, +} from '@dailydotdev/shared/src/lib/query'; +import { + getTransactions, + getTransactionSummary, +} from '@dailydotdev/shared/src/graphql/njord'; +import InfiniteScrolling from '@dailydotdev/shared/src/components/containers/InfiniteScrolling'; +import type { LogStartBuyingCreditsProps } from '@dailydotdev/shared/src/types'; +import { FeaturedCoresWidget } from '@dailydotdev/shared/src/components/cores/FeaturedCoresWidget'; +import { TransactionItem } from '@dailydotdev/shared/src/components/cores/TransactionItem'; +import { usePlusSubscription } from '@dailydotdev/shared/src/hooks'; +import { ElementPlaceholder } from '@dailydotdev/shared/src/components/ElementPlaceholder'; +import { getLayout as getFooterNavBarLayout } from '../components/layouts/FooterNavBarLayout'; +import { getLayout } from '../components/layouts/MainLayout'; +import ProtectedPage from '../components/ProtectedPage'; + +type BalanceBlockProps = { + Icon: ReactElement; + title: string; + description: string; + balance: number; +} & WithClassNameProps; +const BalanceBlock = ({ + Icon, + balance, + title, + description, + className, +}: BalanceBlockProps): ReactElement => { + return ( +
    + {Icon} + +
    + {title} + +
    +
    + + {formatCoresCurrency(balance)} + +
    + ); +}; + +const Divider = classed('div', 'h-px w-full bg-border-subtlest-tertiary'); + +const Earnings = (): ReactElement => { + const { isLoggedIn, user } = useAuthContext(); + const { isPlus } = usePlusSubscription(); + const { logEvent } = useLogContext(); + const onBuyCoresClick = useCallback( + ({ + origin = Origin.EarningsPageCTA, + amount, + target_id, + }: Partial) => { + logEvent({ + event_name: LogEvent.StartBuyingCredits, + target_id, + extra: JSON.stringify({ origin, amount }), + }); + }, + [logEvent], + ); + + const { data: transactionSummary } = useQuery({ + queryKey: generateQueryKey(RequestKey.Transactions, user, 'summary'), + queryFn: getTransactionSummary, + enabled: isLoggedIn, + staleTime: StaleTime.Default, + }); + + const transactionsQuery = useInfiniteQuery({ + queryKey: generateQueryKey(RequestKey.Transactions, user, 'list', { + first: 20, + }), + queryFn: async ({ queryKey, pageParam }) => { + const [, , , queryVariables] = queryKey as [ + RequestKey.Transactions, + string, + 'list', + { first: number }, + ]; + + return getTransactions({ ...queryVariables, after: pageParam }); + }, + initialPageParam: '', + getNextPageParam: (data, allPages, lastPageParam) => { + const nextPageparam = getNextPageParam(data?.pageInfo); + + if (lastPageParam === nextPageparam) { + return null; + } + + return getNextPageParam(data?.pageInfo); + }, + enabled: isLoggedIn, + staleTime: StaleTime.Default, + }); + + const { data: transactions, isPending: isPendingTransactions } = + transactionsQuery; + + const hasTransactions = (transactions?.pages?.[0]?.edges?.length || 0) > 0; + + if (!user) { + return null; + } + + const earningsProgressPercentage = + user.balance.amount / (minCoresEarningsThreshold / 100); + const coresValueUSD = minCoresEarningsThreshold / coreApproxValueUSD; + + return ( + +
    +
    +
    + + Core wallet + + +
    +
    +
    + + } + title="Balance" + description="Your current balance" + balance={user.balance.amount} + className="bg-surface-float" + /> + + +
    + } + title="Purchased" + description="Amount of cores you have purchased" + balance={transactionSummary?.purchased || 0} + /> + + +
    + } + title="Received" + description="Amount of cores you have received" + balance={transactionSummary?.received || 0} + /> + + +
    + } + title="Spent" + description="Amount of cores you have spent" + balance={transactionSummary?.spent || 0} + /> + + +
    +
    + + Earn with daily.dev (beta) + + + Earn income by engaging with the daily.dev community, + contributing valuable content, and receiving Cores from + others. Once you reach{' '} + {formatCurrency(minCoresEarningsThreshold, { + minimumFractionDigits: 0, + })}{' '} + Cores, you can request a withdrawal. Monetization is still in + beta, so additional eligibility steps and requirements may + apply. + +
    +
    + +
    + + + {formatCoresCurrency(user.balance.amount)} /{' '} + + {formatCurrency(minCoresEarningsThreshold, { + minimumFractionDigits: 0, + })} + {' '} + Cores (≈ USD $ + {formatCurrency(coresValueUSD, { + minimumFractionDigits: 0, + })} + ) + +
    +
    + +
    + +
    + + Transaction history + + {isPendingTransactions && ( +
    + {new Array(5).fill(null).map((_, index) => { + return ( + + ); + })} +
    + )} + {!isPendingTransactions && ( + <> + {!hasTransactions && ( + + You have no transactions yet. + + )} + {hasTransactions && ( + +
      + {transactions?.pages.map((page) => { + return page.edges.map((edge) => { + const { node: transaction } = edge; + + const type = getTransactionType({ + transaction, + user, + }); + + return ( + + ); + }); + })} +
    +
    + )} + + )} +
    + + + + + {!isPlus && ( + +
    + + Plus + + 🎁 +
    + + {/* TODO feat/transactions replace with real data */} + Get {'{X}'} Cores every month with daily.dev Plus and access pro + features to fast-track your growth. + + +
    + )} + +
    + + + +
    +
    +
    + + + ); +}; + +const getEarningsLayout: typeof getLayout = (...props) => + getFooterNavBarLayout(getLayout(...props)); + +const seo: NextSeoProps = { title: 'Earnings', nofollow: true, noindex: true }; + +Earnings.getLayout = getEarningsLayout; +Earnings.layoutProps = { seo, screenCentered: false }; + +export default Earnings; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 052f4a5f78..fb86aaddf2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -386,8 +386,8 @@ importers: specifier: ^1.1.0 version: 1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@paddle/paddle-js': - specifier: ^1.3.2 - version: 1.3.2 + specifier: 1.4.0 + version: 1.4.0 '@tippyjs/react': specifier: ^4.2.6 version: 4.2.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -793,8 +793,8 @@ importers: specifier: 1.1.0 version: 1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@paddle/paddle-js': - specifier: ^1.3.2 - version: 1.3.2 + specifier: 1.4.0 + version: 1.4.0 '@serwist/next': specifier: ^9.0.9 version: 9.0.10(next@15.0.1(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.82.0))(typescript@5.6.3)(webpack@5.97.0) @@ -2409,8 +2409,8 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@paddle/paddle-js@1.3.2': - resolution: {integrity: sha512-N0/Vd3mmnk0zk1+xY+61xBD7bDW0gFAZjyMXvZvF6UDDAKIuNNkKYsImGvMSCG9zKvCMRGAw4fJBwe2mG01Bbg==} + '@paddle/paddle-js@1.4.0': + resolution: {integrity: sha512-pX6Yx+RswB1rHMuYl8RKcAAVZhVJ6nd5f8w8l4kVM63pM3HNeQ5/Xuk4sK/X9P5fUE2dmN0mTti7+gZ8cZtqvg==} '@parcel/watcher-android-arm64@2.5.0': resolution: {integrity: sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==} @@ -10323,7 +10323,7 @@ snapshots: '@open-draft/until@2.1.0': {} - '@paddle/paddle-js@1.3.2': {} + '@paddle/paddle-js@1.4.0': {} '@parcel/watcher-android-arm64@2.5.0': optional: true