diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index ef63d4e726a..04aff696dab 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -36,6 +36,8 @@ import { SpotlightProvider } from './spotlight/SpotlightContext'; import { SpotlightHost } from './spotlight/SpotlightHost'; import { FeedbackWidget } from './feedback'; import { isExtension } from '../lib/func'; +import { ExtensionStoreReviewPrompt } from './referral/ExtensionStoreReviewPrompt'; +import { ReferralGrowthTestingPanel } from './referral/ReferralGrowthTestingPanel'; const GoBackHeaderMobile = dynamic( () => @@ -186,6 +188,8 @@ function MainLayoutComponent({ + + diff --git a/packages/shared/src/components/modals/badges/TopReaderBadgeModal.tsx b/packages/shared/src/components/modals/badges/TopReaderBadgeModal.tsx index b58280e5945..c9180d6ef36 100644 --- a/packages/shared/src/components/modals/badges/TopReaderBadgeModal.tsx +++ b/packages/shared/src/components/modals/badges/TopReaderBadgeModal.tsx @@ -17,6 +17,14 @@ import { LogEvent, TargetId, TargetType } from '../../../lib/log'; import { formatDate, TimeFormatType } from '../../../lib/dateFormat'; import { useTopReader } from '../../../hooks/useTopReader'; import { ModalClose } from '../common/ModalClose'; +import { ContextualReferralLink } from '../../referral/ContextualReferralLink'; +import { ReferralCampaignKey } from '../../../lib/referral'; +import { + featureReferralGrowthLoops, + ReferralGrowthSurface, +} from '../../../lib/referralGrowth'; +import { webappUrl } from '../../../lib/constants'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; type TopReaderBadgeModalProps = { badgeId?: string; @@ -25,15 +33,24 @@ type TopReaderBadgeModalProps = { const TopReaderBadgeModal = ( props: ModalProps & TopReaderBadgeModalProps, -): ReactElement => { +): ReactElement | null => { const { onRequestClose, onAfterOpen, onAfterClose, badgeId, origin } = props; const { user } = useAuthContext(); const { logEvent } = useLogContext(); const isMobile = useViewSize(ViewSize.MobileL); - const { data: topReaders } = useTopReader({ user, limit: 1, badgeId }); + const { data: topReaders } = useTopReader({ + user: user!, + limit: 1, + badgeId, + }); const topReader = topReaders?.[0]; + const { value: showReferralGrowthLoop } = useConditionalFeature({ + feature: featureReferralGrowthLoops, + shouldEvaluate: !!user?.id, + }); + const profileUrl = user?.username ? `${webappUrl}${user.username}` : ''; const { mutateAsync: onDownloadUrl, isPending: downloading } = useMutation({ mutationFn: downloadUrl, @@ -55,7 +72,7 @@ const TopReaderBadgeModal = ( ); const onClickDownload = useCallback(async () => { - if (!topReader) { + if (!topReader?.image) { return; } @@ -101,7 +118,7 @@ const TopReaderBadgeModal = ( You've earned the top reader badge! @@ -116,6 +133,17 @@ const TopReaderBadgeModal = ( > Download badge + {showReferralGrowthLoop && ( + + )} ); diff --git a/packages/shared/src/components/modals/streaks/NewStreakModal.tsx b/packages/shared/src/components/modals/streaks/NewStreakModal.tsx index 1f79b0784a1..80134191cb2 100644 --- a/packages/shared/src/components/modals/streaks/NewStreakModal.tsx +++ b/packages/shared/src/components/modals/streaks/NewStreakModal.tsx @@ -18,6 +18,10 @@ import { useAuthContext } from '../../../contexts/AuthContext'; import { useActions } from '../../../hooks'; import { ActionType } from '../../../graphql/actions'; import StreakReminderSwitch from '../../streak/StreakReminderSwitch'; +import { StreakShareCallout } from '../../referral/StreakShareCallout'; +import { featureReferralGrowthLoops } from '../../../lib/referralGrowth'; +import { webappUrl } from '../../../lib/constants'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; const Paragraph = classed('p', 'text-center text-text-tertiary'); @@ -37,6 +41,11 @@ export default function NewStreakModal({ const shouldShowSplash = currentStreak >= maxStreak; const daysPlural = currentStreak === 1 ? 'day' : 'days'; const loggedImpression = useRef(false); + const { value: showReferralGrowthLoop } = useConditionalFeature({ + feature: featureReferralGrowthLoops, + shouldEvaluate: !!user?.id, + }); + const profileUrl = user?.username ? `${webappUrl}${user.username}` : ''; useEffect(() => { if (loggedImpression.current) { @@ -120,9 +129,16 @@ export default function NewStreakModal({ ? 'Epic win! You are in a league of your own' : `New milestone reached! You are unstoppable.`} + {showReferralGrowthLoop && ( + + )} diff --git a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx index d1cb33406be..dc066198c4b 100644 --- a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx +++ b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx @@ -35,8 +35,11 @@ export const BriefPostHeaderActions = ({ )} + + + + ); +} diff --git a/packages/shared/src/components/referral/ExtensionStoreReviewPrompt.tsx b/packages/shared/src/components/referral/ExtensionStoreReviewPrompt.tsx new file mode 100644 index 00000000000..1a63489036b --- /dev/null +++ b/packages/shared/src/components/referral/ExtensionStoreReviewPrompt.tsx @@ -0,0 +1,193 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useActions } from '../../hooks/useActions'; +import { ActionType } from '../../graphql/actions'; +import { isExtension } from '../../lib/func'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { featureExtensionStoreReviewPrompt } from '../../lib/referralGrowth'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../lib/log'; +import { chromeWebStoreReviewUrl } from '../../lib/constants'; +import { useLazyModal } from '../../hooks/useLazyModal'; +import { LazyModal } from '../modals/common/types'; +import { StarIcon, MiniCloseIcon } from '../icons'; +import { IconSize } from '../Icon'; + +const STAR_INDICES = [0, 1, 2, 3, 4] as const; + +export function ExtensionStoreReviewPrompt(): ReactElement | null { + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const { openModal } = useLazyModal(); + const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); + const [isPositive, setIsPositive] = useState(false); + const loggedImpression = useRef(false); + const { value: isFeatureEnabled } = useConditionalFeature({ + feature: featureExtensionStoreReviewPrompt, + shouldEvaluate: isExtension && !!user?.id, + }); + + const hasPositiveSignal = + checkHasCompleted(ActionType.StreakMilestone) || + checkHasCompleted(ActionType.VotePost) || + checkHasCompleted(ActionType.BookmarkPost); + const shouldShow = + isExtension && + isFeatureEnabled && + !!user?.id && + isActionsFetched && + hasPositiveSignal && + !checkHasCompleted(ActionType.ChromeStoreReviewPrompt); + + useEffect(() => { + if (!shouldShow || loggedImpression.current) { + return; + } + + logEvent({ + event_name: LogEvent.StoreReviewPromptImpression, + target_type: TargetType.StoreReviewPrompt, + }); + loggedImpression.current = true; + }, [logEvent, shouldShow]); + + if (!shouldShow) { + return null; + } + + const completePrompt = () => + completeAction(ActionType.ChromeStoreReviewPrompt); + + const onPositive = () => { + setIsPositive(true); + logEvent({ + event_name: LogEvent.StoreReviewPromptPositive, + target_type: TargetType.StoreReviewPrompt, + }); + }; + + const onNegative = () => { + logEvent({ + event_name: LogEvent.StoreReviewPromptNegative, + target_type: TargetType.StoreReviewPrompt, + }); + completePrompt(); + openModal({ type: LazyModal.Feedback }); + }; + + const onDismiss = () => { + logEvent({ + event_name: LogEvent.StoreReviewPromptDismiss, + target_type: TargetType.StoreReviewPrompt, + }); + completePrompt(); + }; + + const onReview = () => { + logEvent({ + event_name: LogEvent.StoreReviewPromptClick, + target_type: TargetType.StoreReviewPrompt, + }); + completePrompt(); + }; + + return ( +
+
+ {/* Star row β€” shows outline stars initially, filled gold stars in positive step */} +
+ {STAR_INDICES.map((i) => ( + + ))} +
+ + {/* Text */} +
+ + {isPositive + ? 'Would you leave a quick review?' + : 'Are you enjoying daily.dev on your new tab?'} + + + {isPositive + ? 'Honest reviews help other developers discover daily.dev β€” it takes 30 seconds.' + : 'Let us know if this is a good moment to ask.'} + +
+ + {/* Actions */} +
+ {isPositive ? ( + + ) : ( + <> + + + + )} + + {/* Dismiss */} + +
+
+
+ ); +} diff --git a/packages/shared/src/components/referral/ReferralGrowthTestingPanel.tsx b/packages/shared/src/components/referral/ReferralGrowthTestingPanel.tsx new file mode 100644 index 00000000000..89f151b52f6 --- /dev/null +++ b/packages/shared/src/components/referral/ReferralGrowthTestingPanel.tsx @@ -0,0 +1,416 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useLazyModal } from '../../hooks/useLazyModal'; +import { LazyModal } from '../modals/common/types'; +import { + chromeWebStoreReviewUrl, + isTesting, + settingsUrl, +} from '../../lib/constants'; +import { useGrowthBookContext } from '../GrowthBookProvider'; +import { MiniCloseIcon, ReadingStreakIcon, StarIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { StreakShareFlowDemo } from './StreakShareFlowDemo'; + +const STAR_INDICES = [0, 1, 2, 3, 4] as const; + +/* + * TEMPORARY QA PANEL + * Remove this component and its registration in MainLayout entirely + * after the referral/review growth experiment has been reviewed and approved. + */ + +export function ReferralGrowthTestingPanel(): ReactElement | null { + const { user } = useAuthContext(); + const { growthbook } = useGrowthBookContext(); + const { openModal } = useLazyModal(); + const [isCollapsed, setIsCollapsed] = useState(false); + const [flagsForced, setFlagsForced] = useState(false); + const [reviewOpen, setReviewOpen] = useState(false); + const [reviewPositive, setReviewPositive] = useState(false); + const [demoStreak, setDemoStreak] = useState(7); + const [demoOpen, setDemoOpen] = useState(false); + + if (isTesting) { + return null; + } + + const setFlagsForcedOn = (on: boolean) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const gb = growthbook as any; + if (on) { + gb?.setForcedFeatures?.( + new Map([ + ['referral_growth_loops', true], + ['extension_store_review_prompt', true], + ]), + ); + } else { + gb?.setForcedFeatures?.(new Map()); + } + setFlagsForced(on); + }; + + const launchStreakInvite = (currentStreak: number, maxStreak: number) => { + setFlagsForcedOn(true); + setDemoStreak(currentStreak); + openModal({ + type: LazyModal.NewStreak, + props: { currentStreak, maxStreak }, + }); + }; + + const buildDemoMessage = (streak: number) => { + const firstName = user?.name?.split(' ')[0] ?? 'I'; + if (streak >= 100) { + return `πŸ”₯ ${firstName} read dev content every day for ${streak} days straight on daily.dev. Worth a look:`; + } + if (streak >= 30) { + return `πŸ”₯ ${streak} days reading dev news on daily.dev β€” no skips. Want to give it a try?`; + } + return `πŸ”₯ ${streak}-day reading streak on daily.dev. Read with me:`; + }; + + const demoUsername = user?.username ?? 'you'; + const demoLink = user?.username + ? `daily.dev/${user.username}` + : 'daily.dev/your-profile'; + + const openFeedbackModal = () => openModal({ type: LazyModal.Feedback }); + + const toggleReview = () => { + setReviewOpen((prev) => !prev); + setReviewPositive(false); + }; + + const reviewBanner = reviewOpen ? ( +
+
+
+ {STAR_INDICES.map((i) => ( + + ))} +
+
+ + {reviewPositive + ? 'Would you leave a quick review?' + : 'Are you enjoying daily.dev on your new tab?'} + + + {reviewPositive + ? 'Honest reviews help other developers discover daily.dev β€” it takes 30 seconds.' + : 'Let us know if this is a good moment to ask.'} + +
+
+ {reviewPositive ? ( + + ) : ( + <> + + + + )} + +
+
+
+ ) : null; + + if (isCollapsed) { + return ( + <> + {reviewBanner} + + + ); + } + + return ( + <> + {reviewBanner} + + + ); +} diff --git a/packages/shared/src/components/referral/StreakShareCallout.tsx b/packages/shared/src/components/referral/StreakShareCallout.tsx new file mode 100644 index 00000000000..d72b690f703 --- /dev/null +++ b/packages/shared/src/components/referral/StreakShareCallout.tsx @@ -0,0 +1,407 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useLogContext } from '../../contexts/LogContext'; +import { useCopyLink } from '../../hooks/useCopy'; +import { useGetShortUrl } from '../../hooks/utils/useGetShortUrl'; +import { ReferralCampaignKey } from '../../lib/referral'; +import { ShareProvider } from '../../lib/share'; +import { Origin, LogEvent, TargetType } from '../../lib/log'; +import { ReferralGrowthSurface } from '../../lib/referralGrowth'; +import { + CopyIcon, + EyeIcon, + MailIcon, + ReadingStreakIcon, + TelegramIcon, + TwitterIcon, + VIcon, + WhatsappIcon, +} from '../icons'; +import { IconSize } from '../Icon'; + +type StreakShareCalloutProps = { + /** Profile or share URL the recipient will land on. */ + url: string; + currentStreak: number; + className?: string; +}; + +type ChannelKey = 'whatsapp' | 'twitter' | 'telegram' | 'email'; + +type Channel = { + key: ChannelKey; + provider: ShareProvider; + label: string; + icon: ReactElement; + /** + * Pill background uses the design system Button color tokens + * (`btn-primary-*` classes) so dark/light mode are handled by the theme. + */ + pillClassName: string; +}; + +/** + * Build provider URLs that include both the message AND the link. + * Works around `getWhatsappShareLink` only encoding the URL. + */ +const buildShareHref = ( + channel: ChannelKey, + message: string, + link: string, +): string => { + const fullText = `${message}\n${link}`; + + if (channel === 'whatsapp') { + return `https://wa.me/?text=${encodeURIComponent(fullText)}`; + } + if (channel === 'twitter') { + return `https://twitter.com/intent/tweet?text=${encodeURIComponent( + `${message} via @dailydotdev`, + )}&url=${encodeURIComponent(link)}`; + } + if (channel === 'telegram') { + return `https://t.me/share/url?url=${encodeURIComponent( + link, + )}&text=${encodeURIComponent(message)}`; + } + if (channel === 'email') { + return `mailto:?subject=${encodeURIComponent( + 'I read with daily.dev β€” you should too', + )}&body=${encodeURIComponent(`${message}\n\n${link}`)}`; + } + return link; +}; + +/** + * Pre-written, honest message: it tells the friend what they'll see when they + * tap the link (the inviter's profile + streak), not a misleading "challenge" + * that doesn't exist. + */ +const buildMessage = (currentStreak: number, name?: string): string => { + const who = name ? `${name}` : 'I'; + if (currentStreak >= 100) { + return `πŸ”₯ ${who} read dev content every day for ${currentStreak} days straight on daily.dev. Worth a look:`; + } + if (currentStreak >= 30) { + return `πŸ”₯ ${currentStreak} days reading dev news on daily.dev β€” no skips. Want to give it a try?`; + } + return `πŸ”₯ ${currentStreak}-day reading streak on daily.dev. Read with me:`; +}; + +const milestoneLabel = (streak: number): string => { + if (streak >= 100) { + return 'Streak royalty'; + } + if (streak >= 60) { + return 'Unstoppable'; + } + if (streak >= 30) { + return 'On fire'; + } + if (streak >= 14) { + return 'Two weeks strong'; + } + if (streak >= 7) { + return 'Week strong'; + } + return 'Building the habit'; +}; + +const channels: Channel[] = [ + { + key: 'whatsapp', + provider: ShareProvider.WhatsApp, + label: 'WhatsApp', + icon: , + pillClassName: 'btn-primary-whatsapp', + }, + { + key: 'twitter', + provider: ShareProvider.Twitter, + label: 'X', + icon: , + pillClassName: 'btn-primary-twitter', + }, + { + key: 'telegram', + provider: ShareProvider.Telegram, + label: 'Telegram', + icon: , + pillClassName: 'btn-primary-telegram', + }, + { + key: 'email', + provider: ShareProvider.Email, + label: 'Email', + icon: , + pillClassName: 'btn-primary-bacon', + }, +]; + +export function StreakShareCallout({ + url, + currentStreak, + className, +}: StreakShareCalloutProps): ReactElement | null { + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const loggedImpression = useRef(false); + const [showPreview, setShowPreview] = useState(false); + const { getTrackedUrl, shareLink, isLoading } = useGetShortUrl({ + query: { + url, + cid: ReferralCampaignKey.ShareProfile, + enabled: !!url && !!user?.id, + }, + }); + const trackedUrl = getTrackedUrl(url, ReferralCampaignKey.ShareProfile); + const link = shareLink || trackedUrl; + const message = useMemo( + () => buildMessage(currentStreak, user?.name?.split(' ')[0]), + [currentStreak, user?.name], + ); + const fullText = `${message}\n${link}`; + const [copied, copy] = useCopyLink(() => fullText); + + const canNativeShare = + typeof globalThis !== 'undefined' && + typeof globalThis.navigator?.share === 'function'; + + useEffect(() => { + if (!user?.id || !url || loggedImpression.current) { + return; + } + + logEvent({ + event_name: LogEvent.ReferralPromptImpression, + target_type: TargetType.ReferralPrompt, + target_id: ReferralGrowthSurface.StreakMilestone, + extra: JSON.stringify({ + campaign: ReferralCampaignKey.ShareProfile, + origin: Origin.PostContent, + streak: currentStreak, + }), + }); + loggedImpression.current = true; + }, [currentStreak, logEvent, url, user?.id]); + + if (!user?.id || !url) { + return null; + } + + const logShare = (provider: ShareProvider) => { + logEvent({ + event_name: LogEvent.ReferralPromptClick, + target_type: TargetType.ReferralPrompt, + target_id: ReferralGrowthSurface.StreakMilestone, + extra: JSON.stringify({ + campaign: ReferralCampaignKey.ShareProfile, + origin: Origin.PostContent, + provider, + streak: currentStreak, + }), + }); + }; + + const onCopy = () => { + copy(); + logShare(ShareProvider.CopyLink); + }; + + const onNativeShare = async () => { + try { + await globalThis.navigator.share({ + title: `${user?.name ?? 'I'} read with daily.dev`, + text: message, + url: link, + }); + logShare(ShareProvider.Native); + } catch { + // user cancelled + } + }; + + const username = user.username || 'you'; + const milestone = milestoneLabel(currentStreak); + + return ( +
+ {/* The "Trophy Card" β€” designed shareable visual */} +
+ {/* Decorative blobs */} +
+
+ +
+ + + + + daily.dev + +
+ +
+ + {currentStreak} + + + day streak + +
+ +
+ + @{username} + + + {milestone} + +
+
+ + {/* Share row */} +
+ + Send your streak to a friend + + +
+ + {/* Preview disclosure β€” shows the actual outgoing message */} + + + {showPreview && ( +
+ + {message} + + + {isLoading ? 'Preparing your link…' : link} + +
+ )} + + {canNativeShare && ( + + )} +
+ ); +} diff --git a/packages/shared/src/components/referral/StreakShareFlowDemo.tsx b/packages/shared/src/components/referral/StreakShareFlowDemo.tsx new file mode 100644 index 00000000000..d83bb6b9f27 --- /dev/null +++ b/packages/shared/src/components/referral/StreakShareFlowDemo.tsx @@ -0,0 +1,284 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ArrowIcon, ReadingStreakIcon, WhatsappIcon } from '../icons'; +import { IconSize } from '../Icon'; + +/** + * TEMPORARY QA helper β€” visual mock of the WhatsApp invite flow so reviewers + * can see *what the friend actually receives* without leaving the app. + * Three tiny screens stacked: outgoing message β†’ friend's WhatsApp β†’ tapped + * link landing on the inviter's daily.dev profile/streak. + * + * This file lives next to the QA panel β€” delete it together with the panel. + */ + +type Step = 'send' | 'receive' | 'land'; + +const STEPS: Step[] = ['send', 'receive', 'land']; + +type StreakShareFlowDemoProps = { + username: string; + currentStreak: number; + message: string; + link: string; +}; + +// --- Step 1: outgoing message in WhatsApp composer + +function SendStep({ + username, + message, + link, +}: { + username: string; + message: string; + link: string; +}): ReactElement { + return ( +
+
+ + + + + To: a friend + +
+
+
+ + {message} + + + {link} + + + sent Β· just now βœ“βœ“ + +
+ + You ({username}) just shared your streak. + +
+
+ ); +} + +// --- Step 2: friend's WhatsApp inbox + +function ReceiveStep({ + username, + message, + link, +}: { + username: string; + message: string; + link: string; +}): ReactElement { + return ( +
+
+ + + + + From: {username} + +
+
+
+ + {message} + + + {link} + + + received Β· just now + +
+
+ + + Friend taps the link + +
+
+
+ ); +} + +// --- Step 3: friend lands on inviter's profile/streak + +function LandStep({ + username, + currentStreak, +}: { + username: string; + currentStreak: number; +}): ReactElement { + return ( +
+
+ + πŸ”’ daily.dev/{username} + +
+
+ + + + + @{username} + +
+ + {currentStreak} + + + day reading streak + +
+ + + They sign up Β· their streak starts Β· you both keep reading + +
+
+ ); +} + +export function StreakShareFlowDemo({ + username, + currentStreak, + message, + link, +}: StreakShareFlowDemoProps): ReactElement { + const [step, setStep] = useState('send'); + const stepIndex = STEPS.indexOf(step); + + const goNext = () => + setStep(STEPS[Math.min(stepIndex + 1, STEPS.length - 1)] ?? 'send'); + const goPrev = () => setStep(STEPS[Math.max(stepIndex - 1, 0)] ?? 'send'); + + return ( +
+ {/* Step indicator */} +
+ + {step === 'send' && '1 of 3 β€” You hit β€œSend”'} + {step === 'receive' && '2 of 3 β€” Your friend opens WhatsApp'} + {step === 'land' && '3 of 3 β€” They tap the link'} + +
+ {STEPS.map((s, i) => ( + + ))} +
+
+ + {/* Mock device frame */} +
+ {step === 'send' && ( + + )} + {step === 'receive' && ( + + )} + {step === 'land' && ( + + )} +
+ +
+ + +
+
+ ); +} diff --git a/packages/shared/src/components/squads/SquadHeaderBar.tsx b/packages/shared/src/components/squads/SquadHeaderBar.tsx index 7dba4c90f9a..3ef19f9ee2d 100644 --- a/packages/shared/src/components/squads/SquadHeaderBar.tsx +++ b/packages/shared/src/components/squads/SquadHeaderBar.tsx @@ -42,7 +42,7 @@ const SquadSlackButton = ({ }: SquadBarButtonProps) => { const { user } = useAuthContext(); const { data: sourceIntegration, isPending } = useSourceIntegrationQuery({ - sourceId: squad.id, + sourceId: squad.id!, userIntegrationType: UserIntegrationType.Slack, }); @@ -59,7 +59,7 @@ const SquadSlackButton = ({ return 'Connect to Slack'; } - if (sourceIntegration?.userIntegration.userId === user.id) { + if (sourceIntegration?.userIntegration.userId === user?.id) { return 'Manage'; } @@ -87,7 +87,7 @@ const SquadAwardButton = ({ }: Pick) => { const { user } = useAuthContext(); const eligibleAdmin = useGetSquadAwardAdmin({ - sendingUser: user, + sendingUser: user!, squad, }); const canAwardSquad = !!eligibleAdmin; @@ -99,11 +99,11 @@ const SquadAwardButton = ({ ({ icon={} {...props} > - Invitation link + Invite colleagues ); }; diff --git a/packages/shared/src/graphql/actions.ts b/packages/shared/src/graphql/actions.ts index 47b47d13edc..6f6d8b81963 100644 --- a/packages/shared/src/graphql/actions.ts +++ b/packages/shared/src/graphql/actions.ts @@ -62,6 +62,7 @@ export enum ActionType { DisableAchievementCompletion = 'disable_achievement_completion', DismissInstallExtension = 'dismiss_install_extension', DismissShortcutsExtensionPromo = 'dismiss_shortcuts_extension_promo', + ChromeStoreReviewPrompt = 'chrome_store_review_prompt', DismissBriefCard = 'dismiss_brief_card', DigestUpsell = 'digest_upsell', AskUpsellSearch = 'ask_upsell_search', diff --git a/packages/shared/src/hooks/useSquadInvitation.ts b/packages/shared/src/hooks/useSquadInvitation.ts index 7dc522c19c4..e40e6f3f75a 100644 --- a/packages/shared/src/hooks/useSquadInvitation.ts +++ b/packages/shared/src/hooks/useSquadInvitation.ts @@ -5,6 +5,7 @@ import { LogEvent } from '../lib/log'; import type { Squad } from '../graphql/sources'; import { useActions } from './useActions'; import { ActionType } from '../graphql/actions'; +import { ReferralGrowthSurface } from '../lib/referralGrowth'; export interface UseSquadInvitationProps { squad: Squad; @@ -30,7 +31,11 @@ export const useSquadInvitation = ({ const logAndCopyLink = () => { logEvent({ event_name: LogEvent.ShareSquadInvitation, - extra: JSON.stringify({ origin, squad: squad.id }), + extra: JSON.stringify({ + origin, + squad: squad.id, + surface: ReferralGrowthSurface.Squad, + }), }); completeAction(ActionType.SquadInvite); diff --git a/packages/shared/src/lib/constants.ts b/packages/shared/src/lib/constants.ts index c7907deefda..c8b7239e430 100644 --- a/packages/shared/src/lib/constants.ts +++ b/packages/shared/src/lib/constants.ts @@ -29,6 +29,8 @@ export const firstNotificationLink = 'https://r.daily.dev/notifications'; export const reportSquadMember = 'https://r.daily.dev/report-squad-member'; export const squadFeedback = 'https://r.daily.dev/squad-feedback'; export const downloadBrowserExtension = 'https://r.daily.dev/extension'; +export const chromeWebStoreReviewUrl = + 'https://chromewebstore.google.com/detail/dailydev-where-developers/jlmpjdjjbgclbocgajdjefcidcncaied/reviews'; export const twitter = 'https://r.daily.dev/twitter'; export const slackIntegration = 'https://r.daily.dev/slack'; export const statusPage = 'https://r.daily.dev/status'; diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 06227beaf07..cdcfbaf8e27 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -204,6 +204,13 @@ export enum LogEvent { // Referral campaign CopyReferralLink = 'copy referral link', InviteReferral = 'invite referral', + ReferralPromptImpression = 'impression referral prompt', + ReferralPromptClick = 'click referral prompt', + StoreReviewPromptImpression = 'impression store review prompt', + StoreReviewPromptPositive = 'positive store review prompt', + StoreReviewPromptNegative = 'negative store review prompt', + StoreReviewPromptClick = 'click store review prompt', + StoreReviewPromptDismiss = 'dismiss store review prompt', // Shortcuts RevokeShortcutAccess = 'revoke shortcut access', SaveShortcutAccess = 'save shortcut access', @@ -491,6 +498,8 @@ export enum TargetType { InviteFriendsPage = 'invite friends page', ProfilePage = 'profile page', GenericReferralPopup = 'generic referral popup', + ReferralPrompt = 'referral prompt', + StoreReviewPrompt = 'store review prompt', Shortcuts = 'shortcuts', VerifyEmail = 'verify email', ResendVerificationCode = 'resend verification code', diff --git a/packages/shared/src/lib/referralGrowth.ts b/packages/shared/src/lib/referralGrowth.ts new file mode 100644 index 00000000000..3ecfac6f7ba --- /dev/null +++ b/packages/shared/src/lib/referralGrowth.ts @@ -0,0 +1,60 @@ +import { ReferralCampaignKey } from './referral'; + +export const featureReferralGrowthLoops = { + id: 'referral_growth_loops', + defaultValue: false, +}; + +export const featureExtensionStoreReviewPrompt = { + id: 'extension_store_review_prompt', + defaultValue: false, +}; + +export enum ReferralGrowthSurface { + PostUpvote = 'post_upvote', + StreakMilestone = 'streak_milestone', + TopReaderBadge = 'top_reader_badge', + DevCard = 'devcard', + Squad = 'squad', + Brief = 'brief', +} + +export const referralGrowthTriggerMatrix: Record< + ReferralGrowthSurface, + { + campaignKey: ReferralCampaignKey; + trigger: string; + shareUnit: string; + } +> = { + [ReferralGrowthSurface.PostUpvote]: { + campaignKey: ReferralCampaignKey.SharePost, + trigger: 'post_upvote', + shareUnit: 'post_link', + }, + [ReferralGrowthSurface.StreakMilestone]: { + campaignKey: ReferralCampaignKey.ShareProfile, + trigger: 'streak_milestone', + shareUnit: 'profile_link', + }, + [ReferralGrowthSurface.TopReaderBadge]: { + campaignKey: ReferralCampaignKey.ShareProfile, + trigger: 'top_reader_badge', + shareUnit: 'profile_link', + }, + [ReferralGrowthSurface.DevCard]: { + campaignKey: ReferralCampaignKey.ShareProfile, + trigger: 'devcard_generated', + shareUnit: 'devcard_profile_link', + }, + [ReferralGrowthSurface.Squad]: { + campaignKey: ReferralCampaignKey.Generic, + trigger: 'squad_invite', + shareUnit: 'squad_invitation_link', + }, + [ReferralGrowthSurface.Brief]: { + campaignKey: ReferralCampaignKey.SharePost, + trigger: 'brief_share', + shareUnit: 'brief_post_link', + }, +}; diff --git a/packages/shared/src/lib/share.ts b/packages/shared/src/lib/share.ts index b6bb78125b7..58c577d74ce 100644 --- a/packages/shared/src/lib/share.ts +++ b/packages/shared/src/lib/share.ts @@ -96,7 +96,14 @@ export function addLogQueryParams({ return link; } - const url = new URL(link); + let url: URL; + + try { + url = new URL(link); + } catch { + return link; + } + url.searchParams.set('userid', userId); url.searchParams.set('cid', cid); diff --git a/packages/webapp/__tests__/SquadFeedPage.tsx b/packages/webapp/__tests__/SquadFeedPage.tsx index c9d07d015ae..ded57a595e9 100644 --- a/packages/webapp/__tests__/SquadFeedPage.tsx +++ b/packages/webapp/__tests__/SquadFeedPage.tsx @@ -464,7 +464,7 @@ describe('squad header bar', () => { permissions: [SourcePermissions.Invite], }; renderComponent(); - const invite = await screen.findByText('Invitation link'); + const invite = await screen.findByText('Invite colleagues'); mockGraphQL({ request: { @@ -494,7 +494,7 @@ describe('squad header bar', () => { createBasicSourceMembersMock(), createTopMembersBySquadMock(), ]); - const invite = await screen.findByText('Invitation link'); + const invite = await screen.findByText('Invite colleagues'); mockGraphQL({ request: { diff --git a/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx b/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx index 3b1f4b1572c..ce0a19654db 100644 --- a/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx +++ b/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx @@ -22,7 +22,7 @@ import { } from '@dailydotdev/shared/src/lib/query'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; -import { LogEvent } from '@dailydotdev/shared/src/lib/log'; +import { LogEvent, Origin } from '@dailydotdev/shared/src/lib/log'; import { Button } from '@dailydotdev/shared/src/components/buttons/Button'; import { ClickableText } from '@dailydotdev/shared/src/components/buttons/ClickableText'; import { @@ -37,7 +37,7 @@ import { TwitterIcon, } from '@dailydotdev/shared/src/components/icons'; import { DevCardFetchWrapper } from '@dailydotdev/shared/src/components/profile/devcard/DevCardFetchWrapper'; -import { devCard } from '@dailydotdev/shared/src/lib/constants'; +import { devCard, webappUrl } from '@dailydotdev/shared/src/lib/constants'; import { checkLowercaseEquality } from '@dailydotdev/shared/src/lib/strings'; import classNames from 'classnames'; import { isNullOrUndefined } from '@dailydotdev/shared/src/lib/func'; @@ -49,6 +49,14 @@ import { TypographyType, } from '@dailydotdev/shared/src/components/typography/Typography'; import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; +import { ContextualReferralLink } from '@dailydotdev/shared/src/components/referral/ContextualReferralLink'; +import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral'; +import { + featureReferralGrowthLoops, + ReferralGrowthSurface, +} from '@dailydotdev/shared/src/lib/referralGrowth'; +import { addLogQueryParams } from '@dailydotdev/shared/src/lib/share'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; import { GENERATE_DEVCARD_MUTATION } from '../../../../../graphql/devcard'; import type { GenerateDevCardParams } from '../../../../../graphql/devcard'; @@ -61,7 +69,11 @@ export const DevCardStep2 = ({ }: Step2Props): ReactElement => { const [type, setType] = useState(DevCardType.Vertical); const { user } = useAuthContext(); - const { devcard } = useDevCard(user?.id); + const userId = user?.id ?? ''; + const username = user?.username ?? ''; + const userName = user?.name ?? username; + const reputation = user?.reputation ?? 0; + const { devcard } = useDevCard(userId); const { theme, showBorder, isProfileCover } = devcard ?? { theme: DevCardTheme.Default, showBorder: true, @@ -72,23 +84,32 @@ export const DevCardStep2 = ({ const [devCardSrc, setDevCardSrc] = useState( initialDevCardSrc ?? - `${process.env.NEXT_PUBLIC_API_URL}/devcards/v2/${user.id}.png?type=default&r=${randomStr}`, + `${process.env.NEXT_PUBLIC_API_URL}/devcards/v2/${userId}.png?type=default&r=${randomStr}`, ); const client = useQueryClient(); const { logEvent } = useLogContext(); const key = useMemo( - () => generateQueryKey(RequestKey.DevCard, { id: user?.id }), - [user], + () => generateQueryKey(RequestKey.DevCard, { id: userId }), + [userId], ); + const profileUrl = username ? `${webappUrl}${username}` : webappUrl; + const trackedProfileUrl = + addLogQueryParams({ + link: profileUrl, + userId, + cid: ReferralCampaignKey.ShareProfile, + }) ?? profileUrl; + const { value: showReferralGrowthLoop } = useConditionalFeature({ + feature: featureReferralGrowthLoops, + shouldEvaluate: !!userId, + }); const embedCode = useMemo( () => - `${user?.name}'s Dev Card`, - [user?.name, user?.username, devCardSrc, type], + }" alt="${userName}'s Dev Card"/>`, + [userName, devCardSrc, trackedProfileUrl, type], ); const [copyingEmbed, copyEmbed] = useCopyLink(() => embedCode); const [selectedTab, setSelectedTab] = useState(0); @@ -98,7 +119,7 @@ export const DevCardStep2 = ({ const downloadImage = async (url?: string): Promise => { const finalUrl = url ?? devCardSrc; - await onDownloadUrl({ url: finalUrl, filename: `${user.username}.png` }); + await onDownloadUrl({ url: finalUrl, filename: `${username}.png` }); }; const { mutateAsync: onGenerate, isPending: isLoading } = useMutation({ @@ -111,7 +132,7 @@ export const DevCardStep2 = ({ }, onSuccess: (data, vars) => { - if (!data?.devCard?.imageUrl || vars.type === DevCardType.Twitter) { + if (!data?.devCard?.imageUrl || vars?.type === DevCardType.Twitter) { return; } @@ -158,7 +179,10 @@ export const DevCardStep2 = ({ logEvent({ event_name: LogEvent.DownloadDevcard, extra: JSON.stringify({ - format: devcardTypeToEventFormat[type], + format: + devcardTypeToEventFormat[ + type as keyof typeof devcardTypeToEventFormat + ], }), }); } @@ -213,7 +237,7 @@ export const DevCardStep2 = ({ style={{ transformStyle: 'preserve-3d' }} > @@ -275,6 +299,17 @@ export const DevCardStep2 = ({ Add your DevCard to GitHub, your website, or use it as your X header image. + {showReferralGrowthLoop && ( + + )}
{Object.keys(themeToLinearGradient).map((value) => { - const isLocked = user?.reputation < requiredPoints[value]; + const themeValue = value as DevCardTheme; + const isLocked = reputation < requiredPoints[themeValue]; return ( {value} ) @@ -396,11 +432,13 @@ export const DevCardStep2 = ({ className={classNames( 'h-10 w-10 rounded-full', isLocked && 'opacity-32', - checkLowercaseEquality(theme, value) && - 'border-4 border-accent-cabbage-default', + checkLowercaseEquality( + theme ?? DevCardTheme.Default, + value, + ) && 'border-4 border-accent-cabbage-default', )} style={{ - background: themeToLinearGradient[value], + background: themeToLinearGradient[themeValue], }} onClick={() => onUpdatePreference({ @@ -441,7 +479,7 @@ export const DevCardStep2 = ({ onUpdatePreference({ isProfileCover: true })}