diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index 18f8e7a42cc..17533799383 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -48,6 +48,8 @@ import { useFeedContentPreferenceMutationSubscription } from './feeds/useFeedCon import { useFeedBookmarkPost } from '../hooks/bookmark/useFeedBookmarkPost'; import type { AdActions } from '../lib/ads'; import usePlusEntry from '../hooks/usePlusEntry'; +import { FeedItemType } from './cards/common/common'; +import { FeedCardContext } from '../features/posts/FeedCardContext'; const FeedErrorScreen = dynamic( () => import(/* webpackChunkName: "feedErrorScreen" */ './FeedErrorScreen'), @@ -432,33 +434,39 @@ export default function Feed({ ) : ( <> {items.map((item, index) => ( - + value={{ + isBoostedAdPost: item.type === FeedItemType.Ad && !!item.post, + }} + > + + ))} {!isFetching && !isInitialLoading && !isHorizontal && ( diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index 90cd6ec8b5d..f987b880f49 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -197,86 +197,90 @@ export default function FeedItemComponent({ postType: (item as PostItem).post?.type, }); - switch (item.type) { - case FeedItemType.Post: { - if ( - !!item.post.pinnedAt && - item.post.source?.currentMember?.flags?.collapsePinnedPosts - ) { - return null; - } - - return ( - { - toggleUpvote({ - payload: post, - origin, - opts: { - columns, - column, - row, - }, - }); - }} - onDownvoteClick={(post, origin = Origin.Feed) => { - toggleDownvote({ - payload: post, - origin, - opts: { - columns, - column, - row, - }, - }); - }} - onPostClick={(post) => onPostClick(post, index, row, column)} - onPostAuxClick={(post) => onPostClick(post, index, row, column, true)} - onReadArticleClick={() => - onReadArticleClick(item.post, index, row, column) - } - onShare={(post) => onShare(post, row, column)} - onBookmarkClick={(post, origin = Origin.Feed) => { - toggleBookmark({ - post, - origin, - opts: { - columns, - column, - row, - }, - }); - }} - openNewTab={openNewTab} - enableMenu={!!user} - onMenuClick={(event) => onMenuClick(event, index, row, column)} - onCopyLinkClick={(event, post) => - onCopyLinkClick(event, post, index, row, column) - } - menuOpened={postMenuIndex === index} - onCommentClick={(post) => onCommentClick(post, index, row, column)} - eagerLoadImage={row === 0 && column === 0} - > - {showCommentPopupId === item.post.id && ( - setShowCommentPopupId(null)} - onSubmit={(content) => - comment({ post: item.post, content, row, column, columns }) - } - loading={isSendingComment} - /> - )} - - ); + if ( + item.type === FeedItemType.Post || + (item.type === FeedItemType.Ad && item.post) + ) { + if ( + !!item.post.pinnedAt && + item.post.source?.currentMember?.flags?.collapsePinnedPosts + ) { + return null; } + + return ( + { + toggleUpvote({ + payload: post, + origin, + opts: { + columns, + column, + row, + }, + }); + }} + onDownvoteClick={(post, origin = Origin.Feed) => { + toggleDownvote({ + payload: post, + origin, + opts: { + columns, + column, + row, + }, + }); + }} + onPostClick={(post) => onPostClick(post, index, row, column)} + onPostAuxClick={(post) => onPostClick(post, index, row, column, true)} + onReadArticleClick={() => + onReadArticleClick(item.post, index, row, column) + } + onShare={(post) => onShare(post, row, column)} + onBookmarkClick={(post, origin = Origin.Feed) => { + toggleBookmark({ + post, + origin, + opts: { + columns, + column, + row, + }, + }); + }} + openNewTab={openNewTab} + enableMenu={!!user} + onMenuClick={(event) => onMenuClick(event, index, row, column)} + onCopyLinkClick={(event, post) => + onCopyLinkClick(event, post, index, row, column) + } + menuOpened={postMenuIndex === index} + onCommentClick={(post) => onCommentClick(post, index, row, column)} + eagerLoadImage={row === 0 && column === 0} + > + {showCommentPopupId === item.post.id && ( + setShowCommentPopupId(null)} + onSubmit={(content) => + comment({ post: item.post, content, row, column, columns }) + } + loading={isSendingComment} + /> + )} + + ); + } + + switch (item.type) { case FeedItemType.Ad: return ( { @@ -28,6 +29,7 @@ export default function PostMetadata({ }: PostMetadataProps): ReactElement { const timeActionContent = isVideoType ? 'watch' : 'read'; const showReadTime = isVideoType ? Number.isInteger(readTime) : !!readTime; + const { isBoostedAdPost } = useFeedCardContext(); return (
+ {isBoostedAdPost && Boosted} + {isBoostedAdPost && } {!!description && ( {description} )} diff --git a/packages/shared/src/components/icons/Before/filled.svg b/packages/shared/src/components/icons/Before/filled.svg index 69c1b848523..b084898869e 100644 --- a/packages/shared/src/components/icons/Before/filled.svg +++ b/packages/shared/src/components/icons/Before/filled.svg @@ -1,4 +1,8 @@ -a - - + + + + + + + diff --git a/packages/shared/src/components/icons/Before/index.tsx b/packages/shared/src/components/icons/Before/index.tsx index d226e1f5db3..358a402abf0 100644 --- a/packages/shared/src/components/icons/Before/index.tsx +++ b/packages/shared/src/components/icons/Before/index.tsx @@ -3,7 +3,8 @@ import React from 'react'; import type { IconProps } from '../../Icon'; import Icon from '../../Icon'; import FilledIcon from './filled.svg'; +import OutlinedIcon from './outlined.svg'; export const BeforeIcon = (props: IconProps): ReactElement => ( - + ); diff --git a/packages/shared/src/components/icons/Before/outlined.svg b/packages/shared/src/components/icons/Before/outlined.svg new file mode 100644 index 00000000000..d1853b4ebe7 --- /dev/null +++ b/packages/shared/src/components/icons/Before/outlined.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/shared/src/components/modals/award/GiveAwardModal.tsx b/packages/shared/src/components/modals/award/GiveAwardModal.tsx index e163fcb961d..7dcaf802025 100644 --- a/packages/shared/src/components/modals/award/GiveAwardModal.tsx +++ b/packages/shared/src/components/modals/award/GiveAwardModal.tsx @@ -27,24 +27,14 @@ import { } from '../../../contexts/GiveAwardModalContext'; import { Justify } from '../../utilities'; import MarkdownInput from '../../fields/MarkdownInput'; -import { useToastNotification, useViewSize, ViewSize } from '../../../hooks'; +import { useViewSize, ViewSize } from '../../../hooks'; import { LazyModal, 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, - getProductsQueryOptions, - UserTransactionStatus, -} from '../../../graphql/njord'; -import { labels, largeNumberFormat } from '../../../lib'; -import type { - ApiErrorResult, - ApiResponseError, - ApiUserTransactionErrorExtension, -} from '../../../graphql/common'; -import { ApiError } from '../../../graphql/common'; +import { award, getProductsQueryOptions } from '../../../graphql/njord'; +import { largeNumberFormat } from '../../../lib'; import { useAuthContext } from '../../../contexts/AuthContext'; import { Origin } from '../../../lib/log'; import type { Post } from '../../../graphql/posts'; @@ -53,6 +43,7 @@ import { formatCoresCurrency } from '../../../lib/utils'; import { useCanPurchaseCores } from '../../../hooks/useCoresFeature'; import { AnimatedAward } from '../../AnimatedAward'; import { useLazyModal } from '../../../hooks/useLazyModal'; +import { useTransactionError } from '../../../hooks/useTransactionError'; const AwardItem = ({ item, @@ -272,7 +263,6 @@ const CommentScreen = () => { const { setActiveStep, type, entity, product, flags, logAwardEvent } = useGiveAwardModalContext(); const isMobile = useViewSize(ViewSize.MobileL); - const { displayToast } = useToastNotification(); const [note, setNote] = useState(''); const { mutate: awardMutation, isPending } = useMutation({ @@ -289,30 +279,7 @@ const CommentScreen = () => { setActiveStep({ screen: AWARD_SCREENS.SUCCESS, product }); }, - onError: async (data: ApiErrorResult) => { - if ( - data.response.errors?.[0]?.extensions?.code === - ApiError.BalanceTransactionError - ) { - const errorExtensions = data.response - .errors[0] as ApiResponseError; - - if ( - errorExtensions.extensions.status === - UserTransactionStatus.InsufficientFunds && - errorExtensions.extensions.balance - ) { - await updateUser({ - ...user, - balance: errorExtensions.extensions.balance, - }); - } - } - - displayToast( - data?.response?.errors?.[0]?.message || labels.error.generic, - ); - }, + onError: useTransactionError(), }); const onAwardClick = useCallback(() => { diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index be0881962b9..4dfaaf84e7b 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -289,6 +289,18 @@ const BoostPostModal = dynamic( ), ); +const BoostedPostViewModal = dynamic(() => + import( + /* webpackChunkName: "boostedPostViewModal" */ './post/boost/BoostedPostViewModal' + ).then((mod) => mod.BoostedPostViewModal), +); + +const FetchBoostedPostViewModal = dynamic(() => + import( + /* webpackChunkName: "fetchBoostedPostViewModal" */ './post/boost/BoostedPostViewModal' + ).then((mod) => mod.FetchBoostedViewModal), +); + const OrganizationInviteMemberModal = dynamic(() => import( /* webpackChunkName: "inviteMemberModal" */ '../../features/organizations/components/InviteMemberModal' @@ -349,6 +361,8 @@ export const modals = { [LazyModal.ListAwards]: ListAwardsModal, [LazyModal.AdsDashboard]: AdsDashboardModal, [LazyModal.BoostPost]: BoostPostModal, + [LazyModal.BoostedPostView]: BoostedPostViewModal, + [LazyModal.FetchBoostedPostView]: FetchBoostedPostViewModal, [LazyModal.OrganizationInviteMember]: OrganizationInviteMemberModal, [LazyModal.OrganizationManageSeats]: OrganizationManageSeatsModal, }; diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index 5d7aa2ed626..7b724ed972b 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -72,6 +72,8 @@ export enum LazyModal { ListAwards = 'listAwards', AdsDashboard = 'adsDashboard', BoostPost = 'boostPost', + BoostedPostView = 'boostedPostView', + FetchBoostedPostView = 'fetchBoostedPostView', OrganizationInviteMember = 'organizationInviteMember', OrganizationManageSeats = 'organizationManageSeats', } diff --git a/packages/shared/src/components/modals/post/boost/AdsDashboardModal.tsx b/packages/shared/src/components/modals/post/boost/AdsDashboardModal.tsx index 2f4a373966d..9070f455c99 100644 --- a/packages/shared/src/components/modals/post/boost/AdsDashboardModal.tsx +++ b/packages/shared/src/components/modals/post/boost/AdsDashboardModal.tsx @@ -1,18 +1,58 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Modal } from '../../common/Modal'; import type { ModalProps } from '../../common/Modal'; import { CoreIcon } from '../../../icons'; import { IconSize } from '../../../Icon'; -import type { PostCampaign } from '../../../../hooks/post/usePostBoost'; import { usePostBoost } from '../../../../hooks/post/usePostBoost'; import { DataTile } from '../../../../features/boost/DataTile'; import { BoostHistoryLoading } from '../../../../features/boost/BoostHistoryLoading'; import { CampaignList } from '../../../../features/boost/CampaignList'; +import type { BoostedPostData } from '../../../../graphql/post/boost'; +import { BoostedPostViewModal } from './BoostedPostViewModal'; +import usePostById from '../../../../hooks/usePostById'; +import type { Post } from '../../../../graphql/posts'; +import { useLazyModal } from '../../../../hooks/useLazyModal'; +import { LazyModal } from '../../common/types'; -export function AdsDashboardModal(props: ModalProps): ReactElement { - const { data, isLoading } = usePostBoost(); - const [campaign, setCampaign] = React.useState(null); +interface AdsDashboardModalProps extends ModalProps { + initialBoostedPost?: BoostedPostData; +} + +export function AdsDashboardModal({ + initialBoostedPost, + ...props +}: AdsDashboardModalProps): ReactElement { + const { openModal } = useLazyModal(); + const { data, isLoading, stats } = usePostBoost(); + const [toBoost, setToBoost] = useState(); + const { post } = usePostById({ id: toBoost }); + const [boosted, setBoosted] = useState(initialBoostedPost); + const list = useMemo(() => { + return data?.pages.flatMap((page) => page.edges.map((edge) => edge.node)); + }, [data]); + + useEffect(() => { + if (post) { + openModal({ type: LazyModal.BoostPost, props: { post } }); + } + }, [openModal, post]); + + if (toBoost) { + return null; + } + + if (boosted) { + return ( + setBoosted(null)} + /> + ); + } return ( - + - Overview{!campaign && ' all time'} + Overview all time
} /> - - - + + +
Running ads {isLoading ? ( ) : ( - + )} diff --git a/packages/shared/src/components/modals/post/boost/BoostPostModal.tsx b/packages/shared/src/components/modals/post/boost/BoostPostModal.tsx index 3c6226ecc9c..ef32185d9d9 100644 --- a/packages/shared/src/components/modals/post/boost/BoostPostModal.tsx +++ b/packages/shared/src/components/modals/post/boost/BoostPostModal.tsx @@ -1,7 +1,6 @@ import type { ReactElement } from 'react'; import dynamic from 'next/dynamic'; import React, { useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Modal } from '../../common/Modal'; import type { ModalProps } from '../../common/Modal'; import { @@ -14,17 +13,16 @@ import { useAuthContext } from '../../../../contexts/AuthContext'; import { CoreIcon, PlusIcon } from '../../../icons'; import type { Post } from '../../../../graphql/posts'; import { Image } from '../../../image/Image'; -import { generateQueryKey, RequestKey } from '../../../../lib/query'; import useDebounceFn from '../../../../hooks/useDebounceFn'; import { largeNumberFormat } from '../../../../lib'; import { IconSize } from '../../../Icon'; -import { AdsDashboardModal } from './AdsDashboardModal'; import { Origin } from '../../../../lib/log'; import { BuyCoresModal } from '../../award/BuyCoresModal'; import { usePostImage } from '../../../../hooks/post/usePostImage'; -import type { TransactionCreated } from '../../../../graphql/njord'; -import { isNullOrUndefined } from '../../../../lib/func'; import { BoostPostSuccessModal } from './BoostPostSuccessModal'; +import { usePostBoostMutation } from '../../../../hooks/post/usePostBoostMutations'; +import { useLazyModal } from '../../../../hooks/useLazyModal'; +import { LazyModal } from '../../common/types'; const Slider = dynamic( () => import('../../../fields/Slider').then((mod) => mod.Slider), @@ -38,7 +36,6 @@ interface BoostPostModalProps extends ModalProps { const SCREENS = { FORM: 'FORM', BUY_CORES: 'BUY_CORES', - DASHBOARD: 'DASHBOARD', SUCCESS: 'SUCCESS', } as const; @@ -48,7 +45,8 @@ export function BoostPostModal({ post, ...props }: BoostPostModalProps): ReactElement { - const { user, updateUser } = useAuthContext(); + const { user } = useAuthContext(); + const { openModal } = useLazyModal(); const [activeScreen, setActiveScreen] = useState(SCREENS.FORM); const [coresPerDay, setCoresPerDay] = React.useState(5000); const [totalDays, setTotalDays] = React.useState(7); @@ -56,36 +54,16 @@ export function BoostPostModal({ coresPerDay, totalDays, }); - const { data } = useQuery<{ min: number; max: number }>({ - queryKey: generateQueryKey( - RequestKey.PostBoostReach, - user, - post.id, - queryProps.coresPerDay, - queryProps.totalDays, - ), - }); - const client = useQueryClient(); const [debounceSet] = useDebounceFn(setQueryProps, 220); const totalSpendInt = coresPerDay * totalDays; const totalSpend = largeNumberFormat(totalSpendInt); - const { mutateAsync: onBoost } = useMutation<{ - startPostBoost: TransactionCreated; - }>({ - onSuccess: (result) => { - setActiveScreen(SCREENS.SUCCESS); - const balance = result?.startPostBoost?.balance; - - if (!isNullOrUndefined(balance)) { - updateUser({ ...user, balance }); - } - - // invalidate wallet queries - client.invalidateQueries({ - queryKey: generateQueryKey(RequestKey.Transactions, user), - exact: false, - }); + const { estimatedReach, onBoostPost } = usePostBoostMutation({ + toEstimate: { + duration: queryProps.totalDays, + budget: queryProps.coresPerDay, + id: post.id, }, + onBoostSuccess: () => setActiveScreen(SCREENS.SUCCESS), }); const image = usePostImage(post); @@ -94,7 +72,11 @@ export function BoostPostModal({ return setActiveScreen(SCREENS.BUY_CORES); } - return onBoost(); + return onBoostPost({ + duration: totalDays, + budget: coresPerDay, + id: post.id, + }); }; if (activeScreen === SCREENS.BUY_CORES) { @@ -113,14 +95,13 @@ export function BoostPostModal({ return ( setActiveScreen(SCREENS.DASHBOARD)} + onBackToDashboard={() => openModal({ type: LazyModal.AdsDashboard })} /> ); } - if (activeScreen === SCREENS.DASHBOARD) { - return ; - } + // just to avoid any edge case where the min, for some reason is greater than max + const maxReach = Math.max(estimatedReach.min, estimatedReach.max); return ( - {data?.min ?? 0} - {data?.max ?? 0} + {estimatedReach.min} - {maxReach} - Estimated reach + Potential reach
diff --git a/packages/shared/src/components/modals/post/boost/BoostPostSuccessModal.tsx b/packages/shared/src/components/modals/post/boost/BoostPostSuccessModal.tsx index b999a2c32ee..d46257264de 100644 --- a/packages/shared/src/components/modals/post/boost/BoostPostSuccessModal.tsx +++ b/packages/shared/src/components/modals/post/boost/BoostPostSuccessModal.tsx @@ -10,6 +10,7 @@ import { import type { ModalProps } from '../../common/Modal'; import { Modal } from '../../common/Modal'; import { Image } from '../../../image/Image'; +import { ModalClose } from '../../common/ModalClose'; export function BoostPostSuccessModal({ onBackToDashboard, @@ -23,15 +24,19 @@ export function BoostPostSuccessModal({ size={Modal.Size.Small} isDrawerOnMobile > - - -
- + +
+ + +
+
+ Post boosted successfully! Your post is now being promoted and will start reaching more developers shortly. You can track its performance anytime from the @@ -46,6 +51,14 @@ export function BoostPostSuccessModal({ > Ads dashboard + ); diff --git a/packages/shared/src/components/modals/post/boost/BoostedPostViewModal.tsx b/packages/shared/src/components/modals/post/boost/BoostedPostViewModal.tsx new file mode 100644 index 00000000000..c453201ae26 --- /dev/null +++ b/packages/shared/src/components/modals/post/boost/BoostedPostViewModal.tsx @@ -0,0 +1,131 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { BoostStatus } from '../../../../features/boost/CampaignListItem'; +import { + CampaignListView, + CampaignStatsGrid, +} from '../../../../features/boost/CampaignListView'; +import { capitalize } from '../../../../lib/strings'; +import type { ModalProps } from '../../common/Modal'; +import { Modal } from '../../common/Modal'; +import type { BoostedPostData } from '../../../../graphql/post/boost'; +import { getBoostedPostByCampaignId } from '../../../../graphql/post/boost'; +import { usePostBoostMutation } from '../../../../hooks/post/usePostBoostMutations'; +import type { Post } from '../../../../graphql/posts'; +import { generateQueryKey, RequestKey, StaleTime } from '../../../../lib/query'; +import { useAuthContext } from '../../../../contexts/AuthContext'; + +interface BoostedPostViewModalProps extends ModalProps { + data: BoostedPostData; + isLoading?: boolean; + onBoostAgain?: (id: Post['id']) => void; +} + +export function BoostedPostViewModal({ + data, + isLoading, + onBoostAgain, + ...props +}: BoostedPostViewModalProps): ReactElement { + const { onCancelBoost, isLoadingCancel } = usePostBoostMutation({ + onCancelSuccess: () => props.onRequestClose(null), + }); + + const handleBoostClick = () => { + if (data.campaign.status === 'ACTIVE') { + return onCancelBoost(data.post.id); + } + + return onBoostAgain(data.post.id); + }; + + return ( + + + + + Overview + + {capitalize(data.campaign.status)} + + + + + + ); +} + +export function FetchBoostedViewModal({ + campaignId, + ...props +}: Omit & { + campaignId: string; +}): ReactElement { + const { user } = useAuthContext(); + const { data, isLoading } = useQuery({ + queryKey: generateQueryKey(RequestKey.PostCampaigns, user, campaignId), + queryFn: () => getBoostedPostByCampaignId(campaignId), + staleTime: StaleTime.Default, + enabled: !!campaignId && !!user, + }); + + if (isLoading) { + return ( + + +
+ + Fetching data, please hold... + + +
+
+ ); + } + + if (!data) { + return ( + + +
+ + No campaign found + + +
+
+ ); + } + + return ; +} diff --git a/packages/shared/src/components/post/PostHeaderActions.tsx b/packages/shared/src/components/post/PostHeaderActions.tsx index 8f23529dcdd..4d6aff71df5 100644 --- a/packages/shared/src/components/post/PostHeaderActions.tsx +++ b/packages/shared/src/components/post/PostHeaderActions.tsx @@ -1,6 +1,7 @@ import type { ReactElement } from 'react'; import React, { useCallback, useContext } from 'react'; import classNames from 'classnames'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { OpenLinkIcon } from '../icons'; import { getReadPostButtonText, @@ -18,6 +19,7 @@ import { CollectionSubscribeButton } from './collection/CollectionSubscribeButto import { useViewSizeClient, ViewSize } from '../../hooks'; import { BoostPostButton } from '../../features/boost/BoostPostButton'; import { Tooltip } from '../tooltip/Tooltip'; +import { getPostByIdKey } from '../../lib/query'; const Container = classed('div', 'flex flex-row items-center'); @@ -31,6 +33,13 @@ export function PostHeaderActions({ isFixedNavigation, ...props }: PostHeaderActionsProps): ReactElement { + const key = getPostByIdKey(post?.id); + const client = useQueryClient(); + const { isSuccess } = useQuery({ + queryKey: key, + queryFn: () => client.getQueryData(key), + staleTime: Infinity, + }); const { openNewTab } = useContext(SettingsContext); const isLaptop = useViewSizeClient(ViewSize.Laptop); const isMobile = useViewSizeClient(ViewSize.MobileXL); @@ -73,7 +82,9 @@ export function PostHeaderActions({ return ( {!isInternalReadType(post) && !!onReadArticle && } - {!post.flags?.boosted && canBoost && } + {!post.flags?.campaignId && canBoost && isSuccess && ( + + )} {isCollection && } void; + list: BoostedPostData[]; + onClick?: (campaign: BoostedPostData) => void; } export function CampaignList({ @@ -44,11 +44,11 @@ export function CampaignList({ return (
- {list.map((campaign) => ( + {list.map((data) => ( onClick(campaign)} + key={data.campaign.campaignId} + data={data} + onClick={() => onClick(data)} /> ))}
diff --git a/packages/shared/src/features/boost/CampaignListItem.tsx b/packages/shared/src/features/boost/CampaignListItem.tsx index 42142ecd375..efbe8fe8ac1 100644 --- a/packages/shared/src/features/boost/CampaignListItem.tsx +++ b/packages/shared/src/features/boost/CampaignListItem.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import type { MouseEventHandler, ReactElement } from 'react'; +import type { MouseEventHandler, PropsWithChildren, ReactElement } from 'react'; import React from 'react'; import { IconSize, iconSizeToClassName } from '../../components/Icon'; import { ArrowIcon } from '../../components/icons'; @@ -9,36 +9,56 @@ import { TypographyColor, TypographyTag, } from '../../components/typography/Typography'; -import type { PostCampaign } from '../../hooks/post/usePostBoost'; import { Image } from '../../components/image/Image'; import { getAbsoluteDifferenceInDays } from './utils'; +import type { BoostedPostData, PromotedPost } from '../../graphql/post/boost'; -const statusToColor: Record = { - active: 'bg-action-upvote-active text-action-upvote-default', - completed: 'bg-action-share-active text-action-share-default', - cancelled: 'bg-action-downvote-active text-action-downvote-default', +const statusToColor: Record = { + ACTIVE: 'bg-action-upvote-active text-action-upvote-default', + COMPLETED: 'bg-action-share-active text-action-share-default', + CANCELLED: 'bg-action-downvote-active text-action-downvote-default', }; +export const BoostStatus = ({ + children, + status, +}: PropsWithChildren<{ + status: PromotedPost['status']; +}>) => ( + + {children} + +); + interface CampaignListItemProps { - campaign: PostCampaign; + data: BoostedPostData; onClick: MouseEventHandler; } export function CampaignListItem({ - campaign, + data, onClick, }: CampaignListItemProps): ReactElement { + const { campaign, post } = data; + const getCaption = () => { - if (campaign.status === 'completed') { + if (campaign.status === 'COMPLETED') { return 'Completed'; } - if (campaign.status === 'cancelled') { + if (campaign.status === 'CANCELLED') { return 'Cancelled'; } const remainingDays = getAbsoluteDifferenceInDays( - new Date(campaign.boostedUntil), + new Date(campaign.endedAt), new Date(), ); @@ -52,9 +72,9 @@ export function CampaignListItem({ className="flex w-full flex-row items-center gap-4" > - {campaign.image && ( + {post.image && ( - {campaign.title} + {post.title}
- - {getCaption()} - + {getCaption()} ); diff --git a/packages/shared/src/features/boost/CampaignListView.tsx b/packages/shared/src/features/boost/CampaignListView.tsx index ef858c68483..9d9a2cb54dc 100644 --- a/packages/shared/src/features/boost/CampaignListView.tsx +++ b/packages/shared/src/features/boost/CampaignListView.tsx @@ -1,11 +1,11 @@ import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; +import classNames from 'classnames'; import { Typography, TypographyColor, TypographyType, } from '../../components/typography/Typography'; -import type { PostCampaign } from '../../hooks/post/usePostBoost'; import { Image } from '../../components/image/Image'; import { Button, @@ -18,54 +18,87 @@ import { DataTile } from './DataTile'; import { BeforeIcon } from '../../components/icons/Before'; import { ProgressBar } from '../../components/fields/ProgressBar'; import { getAbsoluteDifferenceInDays } from './utils'; +import type { BoostedPostData } from '../../graphql/post/boost'; interface CampaignListViewProps { - campaign: PostCampaign; + data: BoostedPostData; + isLoading: boolean; + onBoostClick: () => void; } +interface CampaignStatsGridProps { + impressions: number; + engagements: number; + clicks: number; + cores: number; + className?: string; +} + +export const CampaignStatsGrid = ({ + className, + cores, + clicks, + engagements, + impressions, +}: CampaignStatsGridProps) => ( +
+ } + /> + + + +
+); + export function CampaignListView({ - campaign, + data, + isLoading, + onBoostClick, }: CampaignListViewProps): ReactElement { + const { campaign, post } = data; const date = useMemo(() => { + const startedAt = new Date(campaign.startedAt); + const endedAt = new Date(campaign.endedAt); + const totalDays = getAbsoluteDifferenceInDays(endedAt, startedAt); + const getEndsIn = () => { - if (campaign.status === 'active') { - return getAbsoluteDifferenceInDays(campaign.boostedUntil, new Date()); + if (campaign.status === 'ACTIVE') { + return getAbsoluteDifferenceInDays(endedAt, new Date()); } - return getAbsoluteDifferenceInDays( - campaign.boostedUntil, - campaign.createdAt, - ); + return totalDays; }; - const totalDays = getAbsoluteDifferenceInDays( - campaign.boostedUntil, - campaign.createdAt, - ); - return { endsIn: getEndsIn(), - startedIn: getAbsoluteDifferenceInDays(new Date(), campaign.createdAt), + startedIn: getAbsoluteDifferenceInDays(new Date(), startedAt), totalDays, }; }, [campaign]); return ( -
-
+
+
- {campaign.title} + {post.title} - +
- + -
- } - /> - - - -
+
Summary - {campaign.cost} | {date.totalDays}{' '} - days + {campaign.budget} |{' '} + {date.totalDays} days
); diff --git a/packages/shared/src/features/posts/FeedCardContext.tsx b/packages/shared/src/features/posts/FeedCardContext.tsx new file mode 100644 index 00000000000..d06dae26a69 --- /dev/null +++ b/packages/shared/src/features/posts/FeedCardContext.tsx @@ -0,0 +1,12 @@ +import { createContext, useContext } from 'react'; + +interface FeedCardContextData { + // a boosted post can surface organically, and we want to show the boosted label only if the post surfaced as an ad + isBoostedAdPost: boolean; +} + +export const FeedCardContext = createContext({ + isBoostedAdPost: false, +}); + +export const useFeedCardContext = () => useContext(FeedCardContext); diff --git a/packages/shared/src/features/posts/PostOptionButton.tsx b/packages/shared/src/features/posts/PostOptionButton.tsx index 448d0fb2d24..2b1eba23dd9 100644 --- a/packages/shared/src/features/posts/PostOptionButton.tsx +++ b/packages/shared/src/features/posts/PostOptionButton.tsx @@ -153,7 +153,7 @@ const PostOptionButtonContent = ({ const isModerator = user?.roles?.includes(Roles.Moderator); const isCustomFeed = feedQueryKey?.[0] === 'custom'; const customFeedId = isCustomFeed ? (feedQueryKey?.[2] as string) : undefined; - const post = loadedPost ?? initialPost; + const post = loadedPost ?? (initialPost as Post); const { isPlus, logSubscriptionEvent } = usePlusSubscription(); const { feedSettings, advancedSettings, checkSettingsEnabledState } = useFeedSettings({ @@ -436,11 +436,18 @@ const PostOptionButtonContent = ({ }); }; + const onManageBoost = async () => { + openModal({ + type: LazyModal.FetchBoostedPostView, + props: { campaignId: post.flags.campaignId }, + }); + }; + if (canBoost) { postOptions.push({ - icon: , - label: 'Boost post', - action: onBoostPost, + icon: , + label: post?.flags?.campaignId ? 'Manage ad' : 'Boost post', + action: post?.flags?.campaignId ? onManageBoost : onBoostPost, }); } diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index b83abbc867b..1f9617f7e23 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -226,6 +226,9 @@ export const FEED_POST_INFO_FRAGMENT = gql` numComments numAwards summary + flags { + campaignId + } bookmark { remindAt } @@ -321,6 +324,7 @@ export const SHARED_POST_INFO_FRAGMENT = gql` flags { promoteToPublic coverVideo + campaignId } userState { vote diff --git a/packages/shared/src/graphql/post/boost.ts b/packages/shared/src/graphql/post/boost.ts new file mode 100644 index 00000000000..39a0dfa215c --- /dev/null +++ b/packages/shared/src/graphql/post/boost.ts @@ -0,0 +1,213 @@ +import { gql } from 'graphql-request'; +import type { Connection, RequestQueryParams } from '../common'; +import { gqlClient } from '../common'; +import type { Post } from '../posts'; + +export const BOOSTED_POST_CAMPAIGNS = gql` + query PostCampaigns($first: Int, $after: String) { + postCampaigns(first: $first, after: $after) { + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + cursor + node { + post { + id + title + image + shortId + permalink + engagements + } + campaign { + campaignId + postId + status + budget + currentBudget + impressions + clicks + startedAt + endedAt + } + } + } + stats { + impressions + clicks + totalSpend + engagements + } + } + } +`; + +export interface PromotedPost { + campaignId: string; + postId: string; + status: 'COMPLETED' | 'CANCELLED' | 'ACTIVE'; + budget: number; + currentBudget: number; + startedAt: Date; + endedAt: Date; + impressions: number; + clicks: number; +} + +export interface PromotedPostList { + promotedPosts: PromotedPost[]; + impressions: number; + clicks: number; + totalSpend: number; + postIds: string[]; +} + +export interface BoostedPostStats + extends Pick { + engagements: number; +} + +interface CampaignBoostedPost extends Pick { + image: string; + permalink: string; + engagements: number; +} + +export interface BoostedPostData { + campaign: PromotedPost; + post: CampaignBoostedPost; +} + +export interface BoostedPostConnection extends Connection { + stats?: BoostedPostStats; +} + +export const getBoostedPostCampaigns = async ({ + first, + after, +}: RequestQueryParams): Promise => { + const result = await gqlClient.request(BOOSTED_POST_CAMPAIGNS, { + first, + after, + }); + + return result.postCampaigns; +}; + +export const BOOSTED_POST_CAMPAIGN_BY_ID = gql` + query PostCampaignById($id: ID!) { + postCampaignById(id: $id) { + post { + id + title + image + shortId + permalink + engagements + } + campaign { + campaignId + postId + status + budget + currentBudget + impressions + clicks + startedAt + endedAt + } + } + } +`; + +export const getBoostedPostByCampaignId = async ( + id: string, +): Promise => { + const result = await gqlClient.request(BOOSTED_POST_CAMPAIGN_BY_ID, { id }); + + return result.postCampaignById; +}; + +export const BOOST_ESTIMATED_REACH = gql` + query BoostEstimatedReach($postId: ID!, $duration: Int!, $budget: Int!) { + boostEstimatedReach(postId: $postId, duration: $duration, budget: $budget) { + min + max + } + } +`; + +export interface BoostPostProps { + id: string; + budget: number; + duration: number; +} + +export interface BoostEstimatedReach { + min: number; + max: number; +} + +export const getBoostEstimatedReach = async ({ + id, + budget, + duration, +}: BoostPostProps): Promise => { + const result = await gqlClient.request(BOOST_ESTIMATED_REACH, { + postId: id, + budget, + duration, + }); + + return result.boostEstimatedReach; +}; + +export const START_POST_BOOST = gql` + mutation StartPostBoost($postId: ID!, $duration: Int!, $budget: Int!) { + startPostBoost(postId: $postId, duration: $duration, budget: $budget) { + transactionId + balance { + amount + } + } + } +`; + +export const startPostBoost = async ({ + id, + budget, + duration, +}: BoostPostProps): Promise<{ + transactionId: string; + balance: { amount: number }; +}> => { + const result = await gqlClient.request(START_POST_BOOST, { + postId: id, + budget, + duration, + }); + + return result.startPostBoost; +}; + +export const CANCEL_POST_BOOST = gql` + mutation CancelPostBoost($postId: ID!) { + cancelPostBoost(postId: $postId) { + _ + } + } +`; + +export const cancelPostBoost = async ( + id: string, +): Promise => { + const result = await gqlClient.request(CANCEL_POST_BOOST, { + postId: id, + }); + + return result.cancelPostBoost; +}; diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts index 22c7032b389..16eb21ae67f 100644 --- a/packages/shared/src/graphql/posts.ts +++ b/packages/shared/src/graphql/posts.ts @@ -15,10 +15,7 @@ import { import type { Bookmark, BookmarkFolder } from './bookmarks'; import type { SourcePostModeration } from './squads'; import type { FeaturedAward } from './njord'; -import { - useCanPurchaseCores, - useHasAccessToCores, -} from '../hooks/useCoresFeature'; +import { useCanPurchaseCores } from '../hooks/useCoresFeature'; import { useAuthContext } from '../contexts/AuthContext'; export const ACCEPTED_TYPES = 'image/png,image/jpeg'; @@ -84,7 +81,7 @@ type PostFlags = { showOnFeed: boolean; promoteToPublic: number; coverVideo?: string; - boosted: boolean; + campaignId: string | null; }; export enum UserVote { @@ -949,9 +946,8 @@ export const checkCanBoostByUser = (post: Post, userId: string) => export const useCanBoostPost = (post: Post) => { const { user } = useAuthContext(); - const hasAccess = useHasAccessToCores(); const canBuy = useCanPurchaseCores(); - const canBoost = hasAccess && canBuy && checkCanBoostByUser(post, user?.id); + const canBoost = canBuy && checkCanBoostByUser(post, user?.id); return { canBoost }; }; diff --git a/packages/shared/src/hooks/post/usePostBoost.ts b/packages/shared/src/hooks/post/usePostBoost.ts index 34c971c5f89..246d3e53ae3 100644 --- a/packages/shared/src/hooks/post/usePostBoost.ts +++ b/packages/shared/src/hooks/post/usePostBoost.ts @@ -1,95 +1,65 @@ -import { useQuery } from '@tanstack/react-query'; - -export interface PostCampaign { - id: string; - title: string; - description?: string; - image?: string; // Optional image URL - cost: number; - views: number; - upvotes: number; - comments: number; - link: string; - boostedUntil?: Date; // Optional date when the boost ends - createdAt: Date; - status: 'completed' | 'cancelled' | 'active'; -} - -// interface PostCampaign { -// id: string; -// cost: number; -// boostedUntil?: Date; // Optional date when the boost ends -// createdAt: Date; -// status: 'completed' | 'cancelled' | 'active'; -// post: { -// permalink: string; -// title: string; -// content: string; -// titleHtml: string; -// contentHtml: string; -// image: string; -// numUpvotes: number; -// numComments: number; -// views: number; -// tags: string[]; -// createdAt: Date; -// readTime: number; -// author: { -// id: string; -// name: string; -// image: string; -// }; -// sharedPost: { -// id: string; -// title: string; -// content: string; -// image: string; -// }; -// }; -// } +import type { InfiniteData } from '@tanstack/react-query'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useAuthContext } from '../../contexts/AuthContext'; +import type { + BoostedPostConnection, + BoostedPostStats, +} from '../../graphql/post/boost'; +import { getBoostedPostCampaigns } from '../../graphql/post/boost'; +import { + generateQueryKey, + RequestKey, + getNextPageParam, + StaleTime, +} from '../../lib/query'; interface UsePostBoost { - data?: PostCampaign[]; + stats: BoostedPostStats; + data?: InfiniteData; isLoading: boolean; } -const dummyData: PostCampaign[] = [ - { - id: '1', - title: 'Boost Your Post', - description: 'Get more visibility for your posts', - cost: 100, - views: 5000, - upvotes: 300, - comments: 50, - link: 'https://example.com/boost-your-post', - boostedUntil: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // Boost lasts for 7 days - status: 'active', - image: 'https://example.com/image1.jpg', // Example image URL - createdAt: new Date(), // Current date - }, - { - id: '2', - title: 'Promote Your Content', - description: 'Reach a wider audience with our promotion tools', - cost: 200, - views: 10000, - upvotes: 600, - comments: 80, - link: 'https://example.com/promote-your-content', - boostedUntil: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000), // Boost lasts for 5 days - status: 'completed', - image: 'https://example.com/image2.jpg', // Example image URL - createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), // Created 3 days ago - }, -]; +const FIRST_DEFAULT_VALUE = 20; +const defaultStats = { + totalSpend: 0, + clicks: 0, + impressions: 0, + engagements: 0, +}; export const usePostBoost = (): UsePostBoost => { - const { data, isPending, isFetched } = useQuery({ - queryKey: ['postBoost'], - // queryFn: async () => { - initialData: dummyData, + const { user } = useAuthContext(); + const key = generateQueryKey(RequestKey.PostCampaigns, user, { + first: FIRST_DEFAULT_VALUE, + }); + const { + data: campaigns, + isPending, + isFetched, + } = useInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => + getBoostedPostCampaigns({ + first: FIRST_DEFAULT_VALUE, + after: pageParam, + }), + initialPageParam: '', + getNextPageParam: (data, _, lastPageParam) => { + const nextPageparam = getNextPageParam(data?.pageInfo); + + if (lastPageParam === nextPageparam) { + return null; + } + + return getNextPageParam(data?.pageInfo); + }, + enabled: !!user, + staleTime: StaleTime.Default, }); - return { data, isLoading: isPending && !isFetched }; + return { + data: campaigns, + stats: campaigns?.pages?.[0]?.stats ?? defaultStats, + isLoading: isPending && !isFetched, + }; }; diff --git a/packages/shared/src/hooks/post/usePostBoostMutations.ts b/packages/shared/src/hooks/post/usePostBoostMutations.ts new file mode 100644 index 00000000000..ee82deb32b8 --- /dev/null +++ b/packages/shared/src/hooks/post/usePostBoostMutations.ts @@ -0,0 +1,94 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { + BoostEstimatedReach, + BoostPostProps, +} from '../../graphql/post/boost'; +import { + getBoostEstimatedReach, + startPostBoost, + cancelPostBoost, +} from '../../graphql/post/boost'; +import { generateQueryKey, RequestKey } from '../../lib/query'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { isNullOrUndefined } from '../../lib/func'; +import { useTransactionError } from '../useTransactionError'; +import { useToastNotification } from '../useToastNotification'; + +interface UsePostBoostMutationProps { + toEstimate?: BoostPostProps; + onBoostSuccess?: () => void; + onCancelSuccess?: () => void; +} + +interface UsePostBoostMutation { + estimatedReach: BoostEstimatedReach; + onBoostPost: typeof startPostBoost; + onCancelBoost: typeof cancelPostBoost; + isLoadingCancel: boolean; +} + +export const usePostBoostMutation = ({ + toEstimate, + onBoostSuccess, + onCancelSuccess, +}: UsePostBoostMutationProps = {}): UsePostBoostMutation => { + const client = useQueryClient(); + const { displayToast } = useToastNotification(); + const { user, updateUser } = useAuthContext(); + const { data: estimatedReach, isPending } = useQuery({ + queryKey: generateQueryKey( + RequestKey.PostCampaigns, + user, + 'estimate', + toEstimate, + ), + queryFn: () => getBoostEstimatedReach(toEstimate), + enabled: !!toEstimate, + initialData: { min: 0, max: 0 }, + }); + + const { mutateAsync: onBoostPost } = useMutation({ + mutationFn: startPostBoost, + onSuccess: (data) => { + if (data.transactionId) { + const balance = data?.balance; + + if (!isNullOrUndefined(balance)) { + updateUser({ ...user, balance }); + } + + client.invalidateQueries({ + queryKey: generateQueryKey(RequestKey.Transactions, user), + exact: false, + }); + client.invalidateQueries({ + queryKey: generateQueryKey(RequestKey.PostCampaigns, user), + exact: false, + }); + + onBoostSuccess?.(); + } + }, + onError: useTransactionError(), + }); + + const { mutateAsync: onCancelBoost } = useMutation({ + mutationFn: cancelPostBoost, + onSuccess: async () => { + await client.invalidateQueries({ + queryKey: generateQueryKey(RequestKey.PostCampaigns, user), + exact: false, + }); + displayToast('Post boost canceled!'); + + onCancelSuccess?.(); + }, + }); + + return { + estimatedReach, + onBoostPost, + onCancelBoost, + isLoadingCancel: isPending, + }; +}; diff --git a/packages/shared/src/hooks/useFeed.ts b/packages/shared/src/hooks/useFeed.ts index 36158e40211..63f00169d1a 100644 --- a/packages/shared/src/hooks/useFeed.ts +++ b/packages/shared/src/hooks/useFeed.ts @@ -35,6 +35,7 @@ interface FeedItemBase { interface AdItem extends FeedItemBase { ad: Ad; + post?: Post; index: number; updatedAt: number; } @@ -256,7 +257,7 @@ export default function useFeed( ad: nextAd, index: adPage, updatedAt: adsUpdatedAt, - }; + } as AdItem; }, [ adsData, diff --git a/packages/shared/src/hooks/useTransactionError.ts b/packages/shared/src/hooks/useTransactionError.ts new file mode 100644 index 00000000000..285d22e9a77 --- /dev/null +++ b/packages/shared/src/hooks/useTransactionError.ts @@ -0,0 +1,39 @@ +import user from '../../__tests__/fixture/loggedUser'; +import { useAuthContext } from '../contexts/AuthContext'; +import type { + ApiErrorResult, + ApiResponseError, + ApiUserTransactionErrorExtension, +} from '../graphql/common'; +import { ApiError } from '../graphql/common'; +import { UserTransactionStatus } from '../graphql/njord'; +import { labels } from '../lib'; +import { useToastNotification } from './useToastNotification'; + +export const useTransactionError = () => { + const { displayToast } = useToastNotification(); + const { updateUser } = useAuthContext(); + + return async (data: ApiErrorResult) => { + if ( + data.response.errors?.[0]?.extensions?.code === + ApiError.BalanceTransactionError + ) { + const errorExtensions = data.response + .errors[0] as ApiResponseError; + + if ( + errorExtensions.extensions.status === + UserTransactionStatus.InsufficientFunds && + errorExtensions.extensions.balance + ) { + await updateUser({ + ...user, + balance: errorExtensions.extensions.balance, + }); + } + } + + displayToast(data?.response?.errors?.[0]?.message || labels.error.generic); + }; +}; diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index f76bd1b275d..b178092d1e1 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -202,6 +202,7 @@ export enum RequestKey { PriceMetadata = 'price_metadata', Products = 'products', Transactions = 'transactions', + PostCampaigns = 'post_campaigns', CheckCoresRole = 'check_cores_role', Awards = 'awards', PostBoostReach = 'postBoostReach',