diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index ca573c3e0a6..7345daa0e20 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -16,6 +16,7 @@ import { buildPersonalizedCategories } from './feeds/exploreCategories'; import { useFeedTagsList } from '../hooks/useFeedTagsList'; import ReadingReminderHero from './marketing/banners/ReadingReminderHero'; import { WebappShortcutsRow } from '../features/shortcuts/components/WebappShortcutsRow'; +import { InviteLedgerStrip } from '../features/inviteLedger/components/InviteLedgerStrip'; import { AskSearchBanner } from './marketing/banners/AskSearchBanner'; import AuthContext from '../contexts/AuthContext'; import type { LoggedUser } from '../lib/user'; @@ -717,6 +718,7 @@ export default function MainFeedLayout({ {!isExtension && isHomePage && ( )} + {isHomePage && } {shouldUseCommentFeedLayout ? ( {!hideFeedbackWidget && } + ); } diff --git a/packages/shared/src/components/modals/BootPopups.tsx b/packages/shared/src/components/modals/BootPopups.tsx index 64bfe4390bb..98dc6edbdad 100644 --- a/packages/shared/src/components/modals/BootPopups.tsx +++ b/packages/shared/src/components/modals/BootPopups.tsx @@ -20,6 +20,11 @@ import { isNullOrUndefined } from '../../lib/func'; import useProfileForm from '../../hooks/useProfileForm'; import { useConditionalFeature } from '../../hooks/useConditionalFeature'; import { featureGenericReferralPopupV2 } from '../../lib/featureManagement'; +import { useInviteLedgerEnabled } from '../../features/inviteLedger/useInviteLedgerEnabled'; +import { + hasSeenInviteLedgerPromoThisSession, + isInviteLedgerPromoDismissed, +} from '../../features/inviteLedger/debug'; const REP_TRESHOLD = 250; @@ -195,14 +200,16 @@ export const BootPopups = (): ReactElement => { } }, [marketingCtaPopoverSmall]); - const shouldShowGenericReferral = alerts?.showGenericReferral === true; + const isInviteLedgerEnabled = useInviteLedgerEnabled(); + const shouldShowGenericReferral = + alerts?.showGenericReferral === true && !isInviteLedgerEnabled; const { value: isGenericReferralV2 } = useConditionalFeature({ feature: featureGenericReferralPopupV2, shouldEvaluate: shouldShowGenericReferral, }); /** * - * Boot popup for generic referral campaign + * Boot popup for generic referral campaign (legacy — suppressed when invite ledger is on) */ useEffect(() => { if (!shouldShowGenericReferral) { @@ -222,6 +229,32 @@ export const BootPopups = (): ReactElement => { }); }, [shouldShowGenericReferral, isGenericReferralV2, updateLastBootPopup]); + /** + * Boot popup for the Invite Ledger promo (replaces legacy referral popup when enabled). + * Shows once per session, suppressed forever after explicit "don't show again". + */ + useEffect(() => { + if (!isInviteLedgerEnabled) { + return; + } + if (isInviteLedgerPromoDismissed()) { + return; + } + if (hasSeenInviteLedgerPromoThisSession()) { + return; + } + + addBootPopup({ + type: LazyModal.InviteLedgerPromo, + props: { + onAfterOpen: () => { + updateLastBootPopup(); + }, + isDrawerOnMobile: true, + }, + }); + }, [isInviteLedgerEnabled, updateLastBootPopup]); + /** * Streak recovery modal */ diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index b75b7d6d95b..6c8c43c75e0 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -98,6 +98,13 @@ const GenericReferralModalV2 = dynamic( ), ); +const InviteLedgerPromoModal = dynamic( + () => + import( + /* webpackChunkName: "inviteLedgerPromoModal" */ '../../features/inviteLedger/components/InviteLedgerPromoModal' + ), +); + const NewStreakModal = dynamic( () => import(/* webpackChunkName: "newStreakModal" */ './streaks/NewStreakModal'), @@ -502,6 +509,7 @@ export const modals = { [LazyModal.VerifySession]: VerifySession, [LazyModal.GenericReferral]: GenericReferralModal, [LazyModal.GenericReferralV2]: GenericReferralModalV2, + [LazyModal.InviteLedgerPromo]: InviteLedgerPromoModal, [LazyModal.Video]: VideoModal, [LazyModal.NewStreak]: NewStreakModal, [LazyModal.ReputationPrivileges]: ReputationPrivilegesModal, diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index e0f59663375..851796c5be1 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -40,6 +40,7 @@ export enum LazyModal { VerifySession = 'verifySession', GenericReferral = 'genericReferral', GenericReferralV2 = 'genericReferralV2', + InviteLedgerPromo = 'inviteLedgerPromo', Video = 'video', NewStreak = 'newStreak', RecoverStreak = 'recoverStreak', diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx index 8ac86736106..8b87ed421d4 100644 --- a/packages/shared/src/components/profile/ProfileHeader.tsx +++ b/packages/shared/src/components/profile/ProfileHeader.tsx @@ -22,6 +22,7 @@ import { VerifiedCompanyUserBadge } from '../VerifiedCompanyUserBadge'; import { locationToString } from '../../lib/utils'; import { IconSize } from '../Icon'; import { fallbackImages } from '../../lib/config'; +import { InviteLedgerCounter } from '../../features/inviteLedger/components/InviteLedgerCounter'; import { ElementPlaceholder } from '../ElementPlaceholder'; @@ -99,7 +100,7 @@ const ProfileHeader = ({
{bio && {bio}}
- {user?.companies?.length > 0 && ( + {(user?.companies?.length ?? 0) > 0 && ( )} - {user?.companies?.length > 0 && user?.location && ( + {(user?.companies?.length ?? 0) > 0 && user?.location && ( )} {user?.location && ( @@ -122,7 +123,7 @@ const ProfileHeader = ({ )}
-
+
+ {isSameUser && ( + <> + + + + )}
{!isSameUser && ( diff --git a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx index a1b2dd22d3b..7dd511f2939 100644 --- a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx +++ b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx @@ -61,6 +61,7 @@ import { ProfileImageSize } from '../ProfilePicture'; import { useViewSize, ViewSize } from '../../hooks'; import { TypographyColor, TypographyType } from '../typography/Typography'; import { useHasAccessToCores } from '../../hooks/useCoresFeature'; +import { useInviteLedgerEnabled } from '../../features/inviteLedger/useInviteLedgerEnabled'; import { useLazyModal } from '../../hooks/useLazyModal'; import { LazyModal } from '../modals/common/types'; import { useLogContext } from '../../contexts/LogContext'; @@ -84,6 +85,7 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { const { openModal } = useLazyModal(); const { logEvent } = useLogContext(); const { user } = useAuthContext(); + const isInviteLedgerEnabled = useInviteLedgerEnabled(); const items = useMemo( () => @@ -132,6 +134,13 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { icon: InviteIcon, href: `${settingsUrl}/invite`, }, + ...(isInviteLedgerEnabled && { + referrals: { + title: 'Referrals', + icon: InviteIcon, + href: `${settingsUrl}/referrals`, + }, + }), }, }, feed: { @@ -337,7 +346,7 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { }, }, }), - [logEvent, onClose, openModal, user?.username], + [logEvent, onClose, openModal, user?.username, isInviteLedgerEnabled], ); return { items }; diff --git a/packages/shared/src/features/inviteLedger/components/InviteLedgerCounter.tsx b/packages/shared/src/features/inviteLedger/components/InviteLedgerCounter.tsx new file mode 100644 index 00000000000..8150797011f --- /dev/null +++ b/packages/shared/src/features/inviteLedger/components/InviteLedgerCounter.tsx @@ -0,0 +1,77 @@ +import type { ReactElement } from 'react'; +import React, { useContext } from 'react'; +import classNames from 'classnames'; +import Link from '../../../components/utilities/Link'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../../lib/log'; +import AuthContext from '../../../contexts/AuthContext'; +import { useInviteLedgerEnabled } from '../useInviteLedgerEnabled'; +import { useInviteLedger } from '../useInviteLedger'; +import { formatStep, getCurrentInviteTier } from '../milestones'; + +/** + * The Stamp. + * + * A tiny monospace pill that lives next to the user's handle in their + * own profile header. Reads like a stamp on a field report: + * + * LEDGER №3 · 5 IN + */ +export const InviteLedgerCounter = (): ReactElement | null => { + const { user } = useContext(AuthContext); + const isEnabled = useInviteLedgerEnabled(); + const ledger = useInviteLedger(); + const { logEvent } = useLogContext(); + + if (!user?.id || !isEnabled) { + return null; + } + + const tier = getCurrentInviteTier(ledger.invitesAccepted); + + const handleClick = () => { + logEvent({ + event_name: LogEvent.InviteLedgerCounterClick, + target_type: TargetType.InviteLedgerCounter, + extra: JSON.stringify({ + invites: ledger.invitesAccepted, + tier: tier?.step ?? 0, + }), + }); + }; + + return ( + + + Ledger + {tier && ( + <> + + · + + №{formatStep(tier.step)} + + )} + + · + + + {ledger.invitesAccepted} in + + + + ); +}; diff --git a/packages/shared/src/features/inviteLedger/components/InviteLedgerNavigator.tsx b/packages/shared/src/features/inviteLedger/components/InviteLedgerNavigator.tsx new file mode 100644 index 00000000000..abfc1e1daf7 --- /dev/null +++ b/packages/shared/src/features/inviteLedger/components/InviteLedgerNavigator.tsx @@ -0,0 +1,272 @@ +import type { ReactElement } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { useRouter } from 'next/router'; +import AuthContext from '../../../contexts/AuthContext'; +import { useLazyModal } from '../../../hooks/useLazyModal'; +import { LazyModal } from '../../../components/modals/common/types'; +import { + getInviteLedgerDemoMode, + isInviteLedgerDebugEnabled, + resetInviteLedgerPromoSeen, + setInviteLedgerDebugEnabled, + setInviteLedgerDemoMode, + setInviteLedgerPromoDismissed, +} from '../debug'; +import type { InviteLedgerDemoMode } from '../debug'; + +type SurfaceEntry = { + id: string; + label: string; + description: string; + demoMode?: NonNullable; + buildHref: (username?: string) => string; +}; + +const SURFACES: SurfaceEntry[] = [ + { + id: 'ledger-full', + label: 'Ledger \u00b7 full', + description: 'Joined + pending + expired rows, all pills', + demoMode: 'full', + buildHref: () => '/settings/referrals', + }, + { + id: 'ledger-single', + label: 'Ledger \u00b7 single', + description: 'One joined row, drives minimal strip', + demoMode: 'single', + buildHref: () => '/settings/referrals', + }, + { + id: 'ledger-empty', + label: 'Ledger \u00b7 empty', + description: 'No invites copy, no strip, no counter', + demoMode: 'empty', + buildHref: () => '/settings/referrals', + }, + { + id: 'ledger-real', + label: 'Ledger \u00b7 real data', + description: 'Clear fixtures, use account data', + demoMode: undefined, + buildHref: () => '/settings/referrals', + }, + { + id: 'feed-strip', + label: 'Feed strip', + description: 'Home feed with the +2 joined strip', + demoMode: 'full', + buildHref: () => '/', + }, + { + id: 'feed-strip-single', + label: 'Feed strip \u00b7 single', + description: 'Home feed with minimal "+1 joined" variant', + demoMode: 'single', + buildHref: () => '/', + }, + { + id: 'profile-counter', + label: 'Profile counter', + description: 'Pill next to your handle (own profile)', + demoMode: 'full', + buildHref: (username) => (username ? `/${username}` : '/'), + }, + { + id: 'settings-menu', + label: 'Settings menu entry', + description: 'Sidebar "Referrals" link', + demoMode: 'full', + buildHref: () => '/settings/profile', + }, + { + id: 'legacy-invite', + label: 'Legacy /settings/invite', + description: 'Existing invite page (untouched)', + demoMode: undefined, + buildHref: () => '/settings/invite', + }, +]; + +const STRIP_DISMISS_PREFIX = 'inviteLedgerStripDismissed:'; + +const clearStripDismissals = () => { + if (typeof window === 'undefined') { + return; + } + const toRemove: string[] = []; + for (let i = 0; i < window.localStorage.length; i += 1) { + const key = window.localStorage.key(i); + if (key?.startsWith(STRIP_DISMISS_PREFIX)) { + toRemove.push(key); + } + } + toRemove.forEach((key) => window.localStorage.removeItem(key)); +}; + +/** + * Discreet pinned navigator visible only when the invite ledger demo flag + * is sticky in localStorage. Lets reviewers jump to every surface state + * with one click instead of typing URL params. + */ +export const InviteLedgerNavigator = (): ReactElement | null => { + const router = useRouter(); + const { user } = useContext(AuthContext); + const { openModal } = useLazyModal(); + const [visible, setVisible] = useState(false); + const [open, setOpen] = useState(false); + const [activeMode, setActiveMode] = useState(null); + + const openPromoModal = () => { + setInviteLedgerPromoDismissed(false); + resetInviteLedgerPromoSeen(); + openModal({ + type: LazyModal.InviteLedgerPromo, + props: { isDrawerOnMobile: true }, + }); + setOpen(false); + }; + + useEffect(() => { + const sync = () => { + setVisible(isInviteLedgerDebugEnabled()); + setActiveMode(getInviteLedgerDemoMode()); + }; + sync(); + window.addEventListener('invite-ledger:debug-change', sync); + window.addEventListener('invite-ledger:demo-mode-change', sync); + return () => { + window.removeEventListener('invite-ledger:debug-change', sync); + window.removeEventListener('invite-ledger:demo-mode-change', sync); + }; + }, [router?.asPath]); + + if (!user?.id || !visible) { + return null; + } + + const goTo = (entry: SurfaceEntry) => { + if (entry.demoMode !== activeMode) { + setInviteLedgerDemoMode(entry.demoMode ?? null); + } + clearStripDismissals(); + const href = entry.buildHref(user?.username); + if (router.asPath === href) { + window.location.reload(); + return; + } + router.push(href); + }; + + return ( +
+ {open && ( +
+
+
+
+ Invite ledger · demo +
+
+ mode:{' '} + + {activeMode ?? 'real'} + +
+
+ +
+
    + {SURFACES.map((entry) => { + const isActive = + router.asPath.split('?')[0] === + entry.buildHref(user?.username) && + entry.demoMode === activeMode; + return ( +
  • + +
  • + ); + })} +
+
+ + Promo modal + + +
+
+ + +
+
+ )} + +
+ ); +}; diff --git a/packages/shared/src/features/inviteLedger/components/InviteLedgerPromoModal.tsx b/packages/shared/src/features/inviteLedger/components/InviteLedgerPromoModal.tsx new file mode 100644 index 00000000000..4dcc0fa90f7 --- /dev/null +++ b/packages/shared/src/features/inviteLedger/components/InviteLedgerPromoModal.tsx @@ -0,0 +1,186 @@ +import type { ReactElement } from 'react'; +import React, { useContext, useEffect, useRef } from 'react'; +import { useRouter } from 'next/router'; +import type { ModalProps } from '../../../components/modals/common/Modal'; +import { Modal } from '../../../components/modals/common/Modal'; +import { ModalClose } from '../../../components/modals/common/ModalClose'; +import { + Button, + ButtonIconPosition, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { LogEvent, TargetType } from '../../../lib/log'; +import { useLogContext } from '../../../contexts/LogContext'; +import AlertContext from '../../../contexts/AlertContext'; +import AuthContext from '../../../contexts/AuthContext'; +import { + markInviteLedgerPromoSeen, + setInviteLedgerPromoDismissed, +} from '../debug'; +import { useInviteLedger } from '../useInviteLedger'; +import { + INVITE_LEDGER_CORES_PER_INVITE, + INVITE_LEDGER_PLUS_DAYS_PER_INVITE, +} from '../types'; +import { + getCurrentInviteTier, + getInvitesUntilNextTier, + getNextInviteMilestone, +} from '../milestones'; +import { SectionRule } from './parts/SectionRule'; +import { Ladder } from './parts/Ladder'; +import { InviteLinkFiling } from './parts/InviteLinkFiling'; +import { ArrowIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; + +/** + * The Dispatch. + * + * A short edition of the Field Report. Same identity, smaller print run. + * Dateline, lead, link, mini-ladder, single CTA. No noise. + */ +function InviteLedgerPromoModal({ + onRequestClose, + ...props +}: ModalProps): ReactElement { + const router = useRouter(); + const { user } = useContext(AuthContext); + const { updateLastReferralReminder } = useContext(AlertContext); + const { logEvent } = useLogContext(); + const ledger = useInviteLedger(); + const isLogged = useRef(false); + + const { invitesAccepted } = ledger; + const current = getCurrentInviteTier(invitesAccepted); + const next = getNextInviteMilestone(invitesAccepted); + const invitesAway = getInvitesUntilNextTier(invitesAccepted); + + useEffect(() => { + if (isLogged.current) { + return; + } + isLogged.current = true; + markInviteLedgerPromoSeen(); + updateLastReferralReminder?.(); + logEvent({ + event_name: LogEvent.Impression, + target_type: TargetType.InviteLedgerPage, + extra: JSON.stringify({ + surface: 'dispatch', + invites: invitesAccepted, + tier: current?.step ?? 0, + }), + }); + }, [logEvent, updateLastReferralReminder, invitesAccepted, current]); + + const handleOpenLedger = (event?: React.MouseEvent) => { + logEvent({ + event_name: LogEvent.InviteLedgerStripClick, + target_type: TargetType.InviteLedgerPage, + extra: JSON.stringify({ surface: 'dispatch', cta: 'open_ledger' }), + }); + router.push('/settings/referrals'); + onRequestClose?.(event ?? (null as unknown as React.MouseEvent)); + }; + + const handleDismissForever = (event?: React.MouseEvent) => { + setInviteLedgerPromoDismissed(true); + logEvent({ + event_name: LogEvent.InviteLedgerStripDismiss, + target_type: TargetType.InviteLedgerPage, + extra: JSON.stringify({ surface: 'dispatch', forever: true }), + }); + onRequestClose?.(event ?? (null as unknown as React.MouseEvent)); + }; + + const firstName = user?.name?.split(/\s+/)[0] ?? user?.username ?? null; + const greeting = firstName ? `${firstName}, ` : ''; + const nextReward = next?.rewards.map((r) => r.label).join(' and '); + + let statusLine: string | null = null; + if (invitesAccepted > 0 && next) { + const remaining = + invitesAway === 1 ? 'One more bring-in' : `${invitesAway} more bring-ins`; + statusLine = `${greeting}you're at ${ + current?.title ?? 'the start' + }. ${remaining} to ${next.title} — ${nextReward}.`; + } else if (invitesAccepted === 0) { + statusLine = `${greeting}your filing is open. First bring-in pays 200 Cores and unlocks 100 more on the house.`; + } + + return ( + + + +
+
+ Dispatch + + · + + Invite ledger +
+ +

+ Send the link. We pay {INVITE_LEDGER_CORES_PER_INVITE} Cores per + developer who signs up. +

+

+ They get a week of Plus on the house —{' '} + {INVITE_LEDGER_PLUS_DAYS_PER_INVITE} days, no card asked. Six reward + tiers stack on top. +

+ + {statusLine && ( +

{statusLine}

+ )} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ ); +} + +export default InviteLedgerPromoModal; diff --git a/packages/shared/src/features/inviteLedger/components/InviteLedgerStrip.tsx b/packages/shared/src/features/inviteLedger/components/InviteLedgerStrip.tsx new file mode 100644 index 00000000000..0747cdcc97d --- /dev/null +++ b/packages/shared/src/features/inviteLedger/components/InviteLedgerStrip.tsx @@ -0,0 +1,181 @@ +import type { ReactElement } from 'react'; +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { useRouter } from 'next/router'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../../lib/log'; +import AuthContext from '../../../contexts/AuthContext'; +import { useInviteLedgerEnabled } from '../useInviteLedgerEnabled'; +import { useInviteLedger } from '../useInviteLedger'; +import { INVITE_LEDGER_CORES_PER_INVITE } from '../types'; +import { getInvitesUntilNextTier, getNextInviteMilestone } from '../milestones'; + +const DISMISS_KEY_PREFIX = 'inviteLedgerStripDismissed:'; + +const safeWindow = (): Window | null => + typeof window === 'undefined' ? null : window; + +const getDismissedCohort = (): string | null => { + const win = safeWindow(); + if (!win) { + return null; + } + return win.localStorage.getItem(`${DISMISS_KEY_PREFIX}cohort`); +}; + +const setDismissedCohort = (cohort: string): void => { + const win = safeWindow(); + if (!win) { + return; + } + win.localStorage.setItem(`${DISMISS_KEY_PREFIX}cohort`, cohort); +}; + +const formatNames = (names: string[]): string => { + if (names.length === 0) { + return ''; + } + if (names.length === 1) { + return names[0]; + } + if (names.length === 2) { + return `${names[0]} and ${names[1]}`; + } + return `${names[0]}, ${names[1]} and ${names.length - 2} more`; +}; + +/** + * The Wire. + * + * A single editorial sentence at the top of the home feed when a friend + * has signed up recently. Reads like a wire-service bulletin: + * + * WIRE · @yael.dev and @petraq joined through your link · + * +400 Cores · 5 to Double-digit territory → + */ +export const InviteLedgerStrip = (): ReactElement | null => { + const router = useRouter(); + const { user } = useContext(AuthContext); + const { logEvent } = useLogContext(); + const isEnabled = useInviteLedgerEnabled(); + const ledger = useInviteLedger(); + const impressionLogged = useRef(false); + + const [dismissedCohort, setDismissedCohortState] = useState( + null, + ); + + useEffect(() => { + setDismissedCohortState(getDismissedCohort()); + }, []); + + const cohort = ledger.newsCohortKey; + const hidden = + !user?.id || + !isEnabled || + !ledger.hasNews || + !cohort || + cohort === dismissedCohort; + + useEffect(() => { + if (hidden || impressionLogged.current) { + return; + } + impressionLogged.current = true; + logEvent({ + event_name: LogEvent.InviteLedgerStripImpression, + target_type: TargetType.InviteLedgerStrip, + extra: JSON.stringify({ cohort }), + }); + }, [hidden, cohort, logEvent]); + + const next = useMemo( + () => getNextInviteMilestone(ledger.invitesAccepted), + [ledger.invitesAccepted], + ); + const invitesAway = getInvitesUntilNextTier(ledger.invitesAccepted); + + if (hidden) { + return null; + } + + const names = ledger.recentJoins + .map((row) => (row.user.username ? `@${row.user.username}` : row.user.name)) + .filter((n): n is string => Boolean(n)); + const verb = names.length === 1 ? 'joined' : 'joined'; + const coresEarned = + ledger.recentJoins.length * INVITE_LEDGER_CORES_PER_INVITE; + + const tierTail = + next && invitesAway > 0 ? ` · ${invitesAway} to ${next.title}` : ''; + + const handleClick = () => { + logEvent({ + event_name: LogEvent.InviteLedgerStripClick, + target_type: TargetType.InviteLedgerStrip, + extra: JSON.stringify({ cohort }), + }); + router.push('/settings/referrals'); + }; + + const handleDismiss = (event: React.MouseEvent) => { + event.stopPropagation(); + setDismissedCohort(cohort); + setDismissedCohortState(cohort); + logEvent({ + event_name: LogEvent.InviteLedgerStripDismiss, + target_type: TargetType.InviteLedgerStrip, + extra: JSON.stringify({ cohort }), + }); + }; + + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleClick(); + } + }} + className={classNames( + 'group flex items-center gap-3 rounded-10 border border-border-subtlest-secondary bg-surface-float px-3 py-2', + 'cursor-pointer transition-colors hover:border-border-subtlest-primary hover:bg-surface-hover', + )} + > + + Wire + + + · + +

+ {formatNames(names)}{' '} + {verb} through your link + · + + +{coresEarned.toLocaleString('en-US')} Cores + + {tierTail} +

+ + → + + +
+
+ ); +}; diff --git a/packages/shared/src/features/inviteLedger/components/LedgerPage.tsx b/packages/shared/src/features/inviteLedger/components/LedgerPage.tsx new file mode 100644 index 00000000000..88f250ccfc0 --- /dev/null +++ b/packages/shared/src/features/inviteLedger/components/LedgerPage.tsx @@ -0,0 +1,249 @@ +import type { ReactElement } from 'react'; +import React, { useContext, useEffect, useMemo } from 'react'; +import classNames from 'classnames'; +import { format } from 'date-fns'; +import AuthContext from '../../../contexts/AuthContext'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../../lib/log'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { getInviteLedgerDemoMode, setInviteLedgerDemoMode } from '../debug'; +import type { InviteLedgerDemoMode } from '../debug'; +import { useInviteLedger } from '../useInviteLedger'; +import { + getCurrentInviteTier, + getInvitesUntilNextTier, + getNextInviteMilestone, +} from '../milestones'; +import { + INVITE_LEDGER_CORES_PER_INVITE, + INVITE_LEDGER_PLUS_DAYS_PER_INVITE, +} from '../types'; +import { SectionRule } from './parts/SectionRule'; +import { InviteLinkFiling } from './parts/InviteLinkFiling'; +import { Ladder } from './parts/Ladder'; +import { Season } from './parts/Season'; + +const DEMO_MODES: InviteLedgerDemoMode[] = ['full', 'single', 'empty', null]; +const DEMO_LABEL: Record = { + full: 'full', + single: 'single', + empty: 'empty', + real: 'real', +}; + +const formatRewards = ( + rewards: { label: string }[] | undefined, +): string | null => { + if (!rewards?.length) { + return null; + } + return rewards.map((r) => r.label).join(' and '); +}; + +const DemoSwitcher = ({ + active, +}: { + active: InviteLedgerDemoMode; +}): ReactElement => ( +
+ demo + + · + + {DEMO_MODES.map((mode) => { + const label = DEMO_LABEL[mode ?? 'real']; + const isActive = mode === active; + return ( + + ); + })} +
+); + +const Dateline = ({ + invitesAccepted, + tierTitle, +}: { + invitesAccepted: number; + tierTitle: string | null; +}): ReactElement => { + const today = format(new Date(), 'MMM d').toUpperCase(); + return ( +
+ Invite ledger + + · + + {today} + + · + + + {invitesAccepted} in + + {tierTitle && ( + <> + + · + + + {tierTitle} + + + )} +
+ ); +}; + +/** + * The Field Report. + * + * A single editorial article about the user's referral activity. Reads + * top-to-bottom: dateline → lead → invite link → status paragraph → + * ladder → this season → editorial footnote. + * + * No section cards, no grid of stats, no big buttons. One column, one + * voice, dense typography. + */ +export const LedgerPage = (): ReactElement => { + const { user } = useContext(AuthContext); + const ledger = useInviteLedger(); + const { logEvent } = useLogContext(); + const demoMode = getInviteLedgerDemoMode(); + + const { invitesAccepted } = ledger; + const current = getCurrentInviteTier(invitesAccepted); + const next = getNextInviteMilestone(invitesAccepted); + const invitesAway = getInvitesUntilNextTier(invitesAccepted); + + useEffect(() => { + logEvent({ + event_name: LogEvent.InviteLedgerViewed, + target_type: TargetType.InviteLedgerPage, + }); + }, [logEvent]); + + const firstName = useMemo(() => { + const name = user?.name?.trim(); + if (name) { + return name.split(/\s+/)[0]; + } + return user?.username ?? null; + }, [user]); + + const greeting = firstName ? `${firstName}, ` : ''; + const nextReward = formatRewards(next?.rewards); + + let statusLine: string; + if (invitesAccepted === 0) { + statusLine = `${greeting}your filing is open and nobody has joined yet. The first developer who signs up earns you 100 Cores on top of the 200 per-invite rate — your way in.`; + } else if (!next) { + statusLine = `${greeting}fifty bring-ins. You're in the top 0.1%. Every additional signup still pays out 200 Cores, every time.`; + } else { + const verb = invitesAccepted === 1 ? 'developer has' : 'developers have'; + const cores = ledger.coresEarned.toLocaleString('en-US'); + const plusDays = ledger.plusDaysGiftedToFriends.toLocaleString('en-US'); + const remaining = + invitesAway === 1 ? 'One more bring-in' : `${invitesAway} more bring-ins`; + statusLine = `${greeting}${invitesAccepted} ${verb} signed up through your link. That's ${cores} Cores in, ${plusDays} days of Plus gifted out, and a clear shot at ${ + next.title + } — ${remaining.toLowerCase()} for ${nextReward}.`; + } + + return ( +
+ {demoMode && } + +
+ +

+ Send the link. We pay {INVITE_LEDGER_CORES_PER_INVITE} Cores per + developer who signs up. +

+

+ They get a week of Plus on the house —{' '} + {INVITE_LEDGER_PLUS_DAYS_PER_INVITE} days, no card asked. Six reward + tiers stack on top as more land, ending in a custom invite page if you + cross fifty. +

+
+ +
+ + +
+ +
+ +

{statusLine}

+
+ +
+ + +
+ +
+ + + {ledger.hasNextPage && ( +
+ +
+ )} +
+ +
+

+ + Filed from daily.dev + + + · + + The math holds: {INVITE_LEDGER_CORES_PER_INVITE} Cores per join, six + tiers, no expiry. +

+
+
+ ); +}; diff --git a/packages/shared/src/features/inviteLedger/components/parts/InviteLinkFiling.tsx b/packages/shared/src/features/inviteLedger/components/parts/InviteLinkFiling.tsx new file mode 100644 index 00000000000..214f400e71d --- /dev/null +++ b/packages/shared/src/features/inviteLedger/components/parts/InviteLinkFiling.tsx @@ -0,0 +1,136 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { useCopyLink } from '../../../../hooks/useCopy'; +import { useLogContext } from '../../../../contexts/LogContext'; +import { LogEvent, TargetId, TargetType } from '../../../../lib/log'; +import { + getEmailShareLink, + getLinkedInShareLink, + getTelegramShareLink, + getTwitterShareLink, + getWhatsappShareLink, + ShareProvider, +} from '../../../../lib/share'; + +interface InviteLinkFilingProps { + link: string; + origin: 'page' | 'modal'; + copyLabel?: string; +} + +interface Channel { + id: ShareProvider; + short: string; + build: (link: string, text: string) => string; +} + +const SHARE_TEXT = + 'I use daily.dev to keep up with what is shipping. Sign up with my link and you get 7 days of Plus on me.'; + +const CHANNELS: Channel[] = [ + { id: ShareProvider.WhatsApp, short: 'wa', build: getWhatsappShareLink }, + { + id: ShareProvider.LinkedIn, + short: 'li', + build: (link) => getLinkedInShareLink(link), + }, + { id: ShareProvider.Telegram, short: 'tg', build: getTelegramShareLink }, + { id: ShareProvider.Twitter, short: 'x', build: getTwitterShareLink }, + { + id: ShareProvider.Email, + short: '@', + build: (link) => + getEmailShareLink(link, 'Try daily.dev, get a week of Plus'), + }, +]; + +const CHANNEL_LABEL: Record = { + [ShareProvider.WhatsApp]: 'Share on WhatsApp', + [ShareProvider.LinkedIn]: 'Share on LinkedIn', + [ShareProvider.Telegram]: 'Share on Telegram', + [ShareProvider.Twitter]: 'Share on X', + [ShareProvider.Email]: 'Share by email', + [ShareProvider.Facebook]: 'Share on Facebook', + [ShareProvider.Reddit]: 'Share on Reddit', + [ShareProvider.CopyLink]: 'Copy link', + [ShareProvider.Native]: 'Share', +}; + +/** + * The "filing" — your invite link rendered as a single monospace line + * with a copy action, followed by a thin row of channel shortcuts. + * Designed to read like a dispatch point in a field report, not a form. + */ +export const InviteLinkFiling = ({ + link, + origin, + copyLabel = 'copy', +}: InviteLinkFilingProps): ReactElement => { + const [copied, copy] = useCopyLink(() => link); + const { logEvent } = useLogContext(); + + const handleCopy = () => { + copy(); + logEvent({ + event_name: LogEvent.CopyReferralLink, + target_id: TargetId.InviteLedgerPage, + extra: JSON.stringify({ surface: origin }), + }); + }; + + const handleShare = (channel: Channel) => { + logEvent({ + event_name: LogEvent.InviteLedgerChannelClick, + target_type: TargetType.InviteLedgerPage, + extra: JSON.stringify({ surface: origin, channel: channel.id }), + }); + }; + + // Strip protocol for a cleaner editorial read. + const display = link.replace(/^https?:\/\//, ''); + + return ( + + ); +}; diff --git a/packages/shared/src/features/inviteLedger/components/parts/Ladder.tsx b/packages/shared/src/features/inviteLedger/components/parts/Ladder.tsx new file mode 100644 index 00000000000..43956f29283 --- /dev/null +++ b/packages/shared/src/features/inviteLedger/components/parts/Ladder.tsx @@ -0,0 +1,165 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + formatStep, + getCurrentInviteTier, + getInvitesUntilNextTier, + getNextInviteMilestone, + INVITE_MILESTONES, +} from '../../milestones'; +import type { InviteMilestone } from '../../milestones'; + +interface LadderProps { + invitesAccepted: number; + className?: string; + variant?: 'page' | 'modal'; +} + +type RowState = 'unlocked' | 'next' | 'locked'; + +const STATE_FOR = ( + milestone: InviteMilestone, + invites: number, + next: InviteMilestone | null, +): RowState => { + if (invites >= milestone.invites) { + return 'unlocked'; + } + if (next && milestone.invites === next.invites) { + return 'next'; + } + return 'locked'; +}; + +const RewardCell = ({ + milestone, + faded, +}: { + milestone: InviteMilestone; + faded: boolean; +}): ReactElement => ( + + {milestone.rewards.map((reward, idx) => ( + + {idx > 0 && ( + + · + + )} + {reward.label} + + ))} + +); + +/** + * The ladder. Six rows. Each row is a line in the field report: + * №01 Your first bring-in ✓ 100 Cores + * No icons, no decoration — just the fact + state + reward. + */ +export const Ladder = ({ + invitesAccepted, + className, + variant = 'page', +}: LadderProps): ReactElement => { + const next = getNextInviteMilestone(invitesAccepted); + const current = getCurrentInviteTier(invitesAccepted); + const invitesAway = getInvitesUntilNextTier(invitesAccepted); + + return ( +
    + {INVITE_MILESTONES.map((milestone, idx) => { + const state = STATE_FOR(milestone, invitesAccepted, next); + const faded = state === 'locked'; + const isFirst = idx === 0; + const isCurrent = current?.step === milestone.step; + + let stateBadge: ReactElement; + if (state === 'unlocked') { + stateBadge = ( + + ✓ + + ); + } else if (state === 'next') { + stateBadge = ( + + {invitesAccepted}/{milestone.invites} + + ); + } else { + stateBadge = ( + + {milestone.invites} + + ); + } + + return ( +
  1. + + №{formatStep(milestone.step)} + + + {milestone.title} + + {stateBadge} + +
    + +
    +
  2. + ); + })} + + {next && invitesAway > 0 && variant === 'page' && ( +
  3. + + {invitesAway === 1 + ? 'One more bring-in' + : `${invitesAway} more bring-ins`} + {' '} + to{' '} + {next.title}. +
  4. + )} +
+ ); +}; diff --git a/packages/shared/src/features/inviteLedger/components/parts/Season.tsx b/packages/shared/src/features/inviteLedger/components/parts/Season.tsx new file mode 100644 index 00000000000..e53cfcf935a --- /dev/null +++ b/packages/shared/src/features/inviteLedger/components/parts/Season.tsx @@ -0,0 +1,126 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { format } from 'date-fns'; +import { + ProfilePicture, + ProfileImageSize, +} from '../../../../components/ProfilePicture'; +import type { InviteLedgerRow } from '../../types'; + +interface SeasonProps { + rows: InviteLedgerRow[]; + isLoading?: boolean; + className?: string; + emptyHint?: string; +} + +const STATUS_TONE: Record = { + joined: 'text-accent-avocado-default', + pending: 'text-accent-cheese-default', + expired: 'text-text-quaternary', +}; + +const STATUS_TAG: Record = { + joined: 'joined', + pending: 'pending', + expired: 'expired', +}; + +const formatLine = (row: InviteLedgerRow): string => { + if (row.status === 'joined') { + return `+${row.coresToInviter} Cores`; + } + if (row.status === 'pending') { + return 'reserved'; + } + return '—'; +}; + +/** + * The season log. Each row is a line in the report: + * MAY 15 yael.dev joined +200 Cores + * Dense, monospace dates, no badges or chips — just typed columns. + */ +export const Season = ({ + rows, + isLoading, + className, + emptyHint = 'No bring-ins yet. Filed lines show up here the moment a friend joins.', +}: SeasonProps): ReactElement => { + if (isLoading) { + return ( +
+ Reading the wire… +
+ ); + } + + if (rows.length === 0) { + return ( +

+ {emptyHint} +

+ ); + } + + return ( +
    + {rows.map((row, idx) => { + const created = new Date(row.user.createdAt); + const date = format(created, 'MMM d').toUpperCase(); + return ( +
  1. 0 && 'border-t border-border-subtlest-tertiary', + )} + > + + {date} + + + + + + {row.user.name ?? row.user.username} + + {row.user.username && ( + + @{row.user.username} + + )} + + + + {STATUS_TAG[row.status]} + + + {formatLine(row)} + +
  2. + ); + })} +
+ ); +}; diff --git a/packages/shared/src/features/inviteLedger/components/parts/SectionRule.tsx b/packages/shared/src/features/inviteLedger/components/parts/SectionRule.tsx new file mode 100644 index 00000000000..b710e4a9f6a --- /dev/null +++ b/packages/shared/src/features/inviteLedger/components/parts/SectionRule.tsx @@ -0,0 +1,38 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; + +interface SectionRuleProps { + label: string; + meta?: ReactNode; + className?: string; +} + +/** + * Editorial section divider used across the Field Report surfaces. + * Renders an uppercase monospace label, a hairline that fills the row, + * and optional right-aligned metadata. Reads like a section break in a + * field report or newsroom briefing. + */ +export const SectionRule = ({ + label, + meta, + className, +}: SectionRuleProps): ReactElement => ( +
+ + {label} + + + {meta && ( + + {meta} + + )} +
+); diff --git a/packages/shared/src/features/inviteLedger/debug.spec.ts b/packages/shared/src/features/inviteLedger/debug.spec.ts new file mode 100644 index 00000000000..8b563a9696b --- /dev/null +++ b/packages/shared/src/features/inviteLedger/debug.spec.ts @@ -0,0 +1,108 @@ +import { + isInviteLedgerDebugEnabled, + setInviteLedgerDebugEnabled, + isStripDismissed, + setStripDismissed, + getInviteLedgerDemoMode, + setInviteLedgerDemoMode, +} from './debug'; + +const ENABLED_KEY = 'inviteLedgerDebug'; + +describe('inviteLedger/debug', () => { + beforeEach(() => { + window.localStorage.clear(); + window.history.replaceState({}, '', '/'); + }); + + describe('isInviteLedgerDebugEnabled', () => { + it('returns false when no flag is set', () => { + expect(isInviteLedgerDebugEnabled()).toBe(false); + }); + + it('returns true when localStorage flag is true', () => { + window.localStorage.setItem(ENABLED_KEY, 'true'); + expect(isInviteLedgerDebugEnabled()).toBe(true); + }); + + it('sets sticky flag when ?inviteLedgerDebug=1 is in URL', () => { + window.history.replaceState({}, '', '/?inviteLedgerDebug=1'); + expect(isInviteLedgerDebugEnabled()).toBe(true); + expect(window.localStorage.getItem(ENABLED_KEY)).toBe('true'); + }); + + it('clears flag when ?inviteLedgerDebug=0 is in URL', () => { + window.localStorage.setItem(ENABLED_KEY, 'true'); + window.history.replaceState({}, '', '/?inviteLedgerDebug=0'); + expect(isInviteLedgerDebugEnabled()).toBe(false); + expect(window.localStorage.getItem(ENABLED_KEY)).toBeNull(); + }); + }); + + describe('setInviteLedgerDebugEnabled', () => { + it('dispatches change event', () => { + const listener = jest.fn(); + window.addEventListener('invite-ledger:debug-change', listener); + setInviteLedgerDebugEnabled(true); + expect(listener).toHaveBeenCalled(); + expect(window.localStorage.getItem(ENABLED_KEY)).toBe('true'); + window.removeEventListener('invite-ledger:debug-change', listener); + }); + }); + + describe('demo mode', () => { + it('defaults to null', () => { + expect(getInviteLedgerDemoMode()).toBeNull(); + }); + + it('persists ?inviteLedgerDemoData=full', () => { + window.history.replaceState({}, '', '/?inviteLedgerDemoData=full'); + expect(getInviteLedgerDemoMode()).toBe('full'); + expect(window.localStorage.getItem('inviteLedgerDemoMode')).toBe('full'); + }); + + it('persists ?inviteLedgerDemoData=empty and =single', () => { + window.history.replaceState({}, '', '/?inviteLedgerDemoData=empty'); + expect(getInviteLedgerDemoMode()).toBe('empty'); + window.history.replaceState({}, '', '/?inviteLedgerDemoData=single'); + expect(getInviteLedgerDemoMode()).toBe('single'); + }); + + it('clears with ?inviteLedgerDemoData=off', () => { + window.localStorage.setItem('inviteLedgerDemoMode', 'full'); + window.history.replaceState({}, '', '/?inviteLedgerDemoData=off'); + expect(getInviteLedgerDemoMode()).toBeNull(); + expect(window.localStorage.getItem('inviteLedgerDemoMode')).toBeNull(); + }); + + it('ignores invalid mode values', () => { + window.history.replaceState({}, '', '/?inviteLedgerDemoData=garbage'); + expect(getInviteLedgerDemoMode()).toBeNull(); + }); + + it('setter dispatches change event', () => { + const listener = jest.fn(); + window.addEventListener('invite-ledger:demo-mode-change', listener); + setInviteLedgerDemoMode('full'); + expect(listener).toHaveBeenCalled(); + expect(window.localStorage.getItem('inviteLedgerDemoMode')).toBe('full'); + setInviteLedgerDemoMode(null); + expect(window.localStorage.getItem('inviteLedgerDemoMode')).toBeNull(); + window.removeEventListener('invite-ledger:demo-mode-change', listener); + }); + }); + + describe('strip dismissal', () => { + it('is per-cohort so a new join shows again', () => { + expect(isStripDismissed('user1')).toBe(false); + setStripDismissed('user1'); + expect(isStripDismissed('user1')).toBe(true); + expect(isStripDismissed('user1,user2')).toBe(false); + }); + + it('no-ops on empty cohort key', () => { + setStripDismissed(''); + expect(isStripDismissed('')).toBe(false); + }); + }); +}); diff --git a/packages/shared/src/features/inviteLedger/debug.ts b/packages/shared/src/features/inviteLedger/debug.ts new file mode 100644 index 00000000000..97e7cba5844 --- /dev/null +++ b/packages/shared/src/features/inviteLedger/debug.ts @@ -0,0 +1,155 @@ +/** + * Demo override for the invite ledger. `?inviteLedgerDebug=1` (or the sticky + * localStorage flag) force-enables the feature for the signed-in user so the + * surface can be reviewed on a Vercel preview where `featureInviteLedger` + * defaults to false. + * + * `?inviteLedgerDebug=0` turns it back off. + */ + +const ENABLED_KEY = 'inviteLedgerDebug'; +const DEMO_MODE_KEY = 'inviteLedgerDemoMode'; +const STRIP_DISMISS_PREFIX = 'inviteLedgerStripDismissed:'; +const PROMO_DISMISSED_KEY = 'inviteLedgerPromoDismissed'; +const PROMO_SEEN_KEY = 'inviteLedgerPromoSeen'; + +export type InviteLedgerDemoMode = 'full' | 'empty' | 'single' | null; + +const VALID_MODES: ReadonlyArray = [ + 'full', + 'empty', + 'single', +]; + +const safeWindow = (): Window | null => + typeof window === 'undefined' ? null : window; + +export const isInviteLedgerDebugEnabled = (): boolean => { + const win = safeWindow(); + if (!win) { + return false; + } + if (win.location.search.includes('inviteLedgerDebug=0')) { + win.localStorage.removeItem(ENABLED_KEY); + return false; + } + if (win.location.search.includes('inviteLedgerDebug=1')) { + win.localStorage.setItem(ENABLED_KEY, 'true'); + return true; + } + return win.localStorage.getItem(ENABLED_KEY) === 'true'; +}; + +export const setInviteLedgerDebugEnabled = (enabled: boolean): void => { + const win = safeWindow(); + if (!win) { + return; + } + if (enabled) { + win.localStorage.setItem(ENABLED_KEY, 'true'); + } else { + win.localStorage.removeItem(ENABLED_KEY); + } + win.dispatchEvent(new Event('invite-ledger:debug-change')); +}; + +export const getInviteLedgerDemoMode = (): InviteLedgerDemoMode => { + const win = safeWindow(); + if (!win) { + return null; + } + const match = win.location.search.match(/inviteLedgerDemoData=([a-z]+)/); + if (match) { + const value = match[1]; + if (value === 'off' || value === '0') { + win.localStorage.removeItem(DEMO_MODE_KEY); + return null; + } + if ((VALID_MODES as ReadonlyArray).includes(value)) { + win.localStorage.setItem(DEMO_MODE_KEY, value); + return value as InviteLedgerDemoMode; + } + } + const stored = win.localStorage.getItem(DEMO_MODE_KEY); + if (stored && (VALID_MODES as ReadonlyArray).includes(stored)) { + return stored as InviteLedgerDemoMode; + } + return null; +}; + +export const setInviteLedgerDemoMode = (mode: InviteLedgerDemoMode): void => { + const win = safeWindow(); + if (!win) { + return; + } + if (mode === null) { + win.localStorage.removeItem(DEMO_MODE_KEY); + } else { + win.localStorage.setItem(DEMO_MODE_KEY, mode); + } + win.dispatchEvent(new Event('invite-ledger:demo-mode-change')); +}; + +export const getStripDismissalKey = (cohortKey: string): string => + `${STRIP_DISMISS_PREFIX}${cohortKey}`; + +export const isStripDismissed = (cohortKey: string): boolean => { + const win = safeWindow(); + if (!win || !cohortKey) { + return false; + } + return win.localStorage.getItem(getStripDismissalKey(cohortKey)) === 'true'; +}; + +export const setStripDismissed = (cohortKey: string): void => { + const win = safeWindow(); + if (!win || !cohortKey) { + return; + } + win.localStorage.setItem(getStripDismissalKey(cohortKey), 'true'); +}; + +export const isInviteLedgerPromoDismissed = (): boolean => { + const win = safeWindow(); + if (!win) { + return true; + } + return win.localStorage.getItem(PROMO_DISMISSED_KEY) === 'true'; +}; + +export const setInviteLedgerPromoDismissed = (dismissed: boolean): void => { + const win = safeWindow(); + if (!win) { + return; + } + if (dismissed) { + win.localStorage.setItem(PROMO_DISMISSED_KEY, 'true'); + } else { + win.localStorage.removeItem(PROMO_DISMISSED_KEY); + } + win.dispatchEvent(new Event('invite-ledger:promo-change')); +}; + +export const hasSeenInviteLedgerPromoThisSession = (): boolean => { + const win = safeWindow(); + if (!win) { + return true; + } + return win.sessionStorage.getItem(PROMO_SEEN_KEY) === 'true'; +}; + +export const markInviteLedgerPromoSeen = (): void => { + const win = safeWindow(); + if (!win) { + return; + } + win.sessionStorage.setItem(PROMO_SEEN_KEY, 'true'); +}; + +export const resetInviteLedgerPromoSeen = (): void => { + const win = safeWindow(); + if (!win) { + return; + } + win.sessionStorage.removeItem(PROMO_SEEN_KEY); +}; diff --git a/packages/shared/src/features/inviteLedger/fixtures.spec.ts b/packages/shared/src/features/inviteLedger/fixtures.spec.ts new file mode 100644 index 00000000000..dcb3599c95f --- /dev/null +++ b/packages/shared/src/features/inviteLedger/fixtures.spec.ts @@ -0,0 +1,35 @@ +import { getDemoSnapshot } from './fixtures'; +import { INVITE_LEDGER_CORES_PER_INVITE } from './types'; + +describe('inviteLedger/fixtures', () => { + it('empty mode returns zero rows and no news', () => { + const snap = getDemoSnapshot('empty'); + expect(snap.rows).toHaveLength(0); + expect(snap.invitesAccepted).toBe(0); + expect(snap.hasNews).toBe(false); + expect(snap.newsCohortKey).toBe(''); + }); + + it('single mode returns one row that drives the strip', () => { + const snap = getDemoSnapshot('single'); + expect(snap.rows).toHaveLength(1); + expect(snap.invitesAccepted).toBe(1); + expect(snap.recentJoins).toHaveLength(1); + expect(snap.hasNews).toBe(true); + expect(snap.coresGiftedToFriends).toBe(INVITE_LEDGER_CORES_PER_INVITE); + }); + + it('full mode includes joined + pending + expired and at least 2 recent joins', () => { + const snap = getDemoSnapshot('full'); + const statuses = snap.rows.map((r) => r.status); + expect(statuses).toContain('joined'); + expect(statuses).toContain('pending'); + expect(statuses).toContain('expired'); + expect(snap.recentJoins.length).toBeGreaterThanOrEqual(2); + expect(snap.hasNews).toBe(true); + // invitesAccepted only counts joined + expect(snap.invitesAccepted).toBe( + snap.rows.filter((r) => r.status === 'joined').length, + ); + }); +}); diff --git a/packages/shared/src/features/inviteLedger/fixtures.ts b/packages/shared/src/features/inviteLedger/fixtures.ts new file mode 100644 index 00000000000..995e0b60e0d --- /dev/null +++ b/packages/shared/src/features/inviteLedger/fixtures.ts @@ -0,0 +1,105 @@ +import type { UserShortProfile } from '../../lib/user'; +import type { InviteLedgerDemoMode } from './debug'; +import type { InviteLedgerRow, InviteLedgerSnapshot } from './types'; +import { + INVITE_LEDGER_CORES_PER_INVITE, + INVITE_LEDGER_PLUS_DAYS_PER_INVITE, +} from './types'; + +const DEMO_INVITE_URL = 'https://api.daily.dev/get?r=demo'; + +const daysAgoIso = (days: number): string => + new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + +const makeUser = ( + id: string, + username: string, + daysAgo: number, +): UserShortProfile => ({ + id, + name: username, + username, + image: '', + permalink: `https://app.daily.dev/${username}`, + bio: undefined, + createdAt: daysAgoIso(daysAgo), + reputation: 0, + companies: [], + isPlus: false, + plusMemberSince: undefined, +}); + +const makeRow = ( + id: string, + username: string, + daysAgo: number, + status: InviteLedgerRow['status'] = 'joined', +): InviteLedgerRow => ({ + user: makeUser(id, username, daysAgo), + status, + coresToInviter: status === 'joined' ? INVITE_LEDGER_CORES_PER_INVITE : 0, +}); + +const FULL_ROWS: InviteLedgerRow[] = [ + makeRow('1', 'yael.dev', 2), + makeRow('2', 'petraq', 4), + makeRow('3', 'maya.k', 5, 'pending'), + makeRow('4', 'k.menshikov', 10), + makeRow('5', 'dpetrosyan', 12, 'pending'), + makeRow('6', 'old.invite', 30, 'expired'), + makeRow('7', 'orelyahav', 21), + makeRow('8', 'nimrod.k', 28), +]; + +const SINGLE_ROWS: InviteLedgerRow[] = [makeRow('1', 'yael.dev', 1)]; + +const buildSnapshot = ( + rows: InviteLedgerRow[], +): Pick< + InviteLedgerSnapshot, + | 'invitesAccepted' + | 'coresGiftedToFriends' + | 'plusDaysGiftedToFriends' + | 'coresEarned' + | 'rows' + | 'recentJoins' + | 'hasNews' + | 'newsCohortKey' +> => { + const joined = rows.filter((r) => r.status === 'joined'); + const recent = joined.filter( + (r) => + (Date.now() - new Date(r.user.createdAt).getTime()) / + (24 * 60 * 60 * 1000) <= + 7, + ); + return { + invitesAccepted: joined.length, + coresGiftedToFriends: joined.length * INVITE_LEDGER_CORES_PER_INVITE, + plusDaysGiftedToFriends: joined.length * INVITE_LEDGER_PLUS_DAYS_PER_INVITE, + coresEarned: joined.length * INVITE_LEDGER_CORES_PER_INVITE, + rows, + recentJoins: recent, + hasNews: recent.length > 0, + newsCohortKey: recent.map((r) => r.user.id).join(','), + }; +}; + +export const getDemoSnapshot = ( + mode: NonNullable, +): InviteLedgerSnapshot => { + const base = { + inviteUrl: DEMO_INVITE_URL, + isLoading: false, + fetchNextPage: async () => undefined, + hasNextPage: false, + isFetchingNextPage: false, + }; + if (mode === 'empty') { + return { ...base, ...buildSnapshot([]) }; + } + if (mode === 'single') { + return { ...base, ...buildSnapshot(SINGLE_ROWS) }; + } + return { ...base, ...buildSnapshot(FULL_ROWS) }; +}; diff --git a/packages/shared/src/features/inviteLedger/milestones.spec.ts b/packages/shared/src/features/inviteLedger/milestones.spec.ts new file mode 100644 index 00000000000..79af5359a3a --- /dev/null +++ b/packages/shared/src/features/inviteLedger/milestones.spec.ts @@ -0,0 +1,41 @@ +import { + INVITE_MILESTONES, + formatStep, + getCurrentInviteTier, + getInviteTierProgress, + getInvitesUntilNextTier, + getNextInviteMilestone, +} from './milestones'; + +describe('invite milestones', () => { + it('starts with no tier when user has zero invites', () => { + expect(getCurrentInviteTier(0)).toBeNull(); + expect(getNextInviteMilestone(0)).toEqual(INVITE_MILESTONES[0]); + expect(getInvitesUntilNextTier(0)).toBe(INVITE_MILESTONES[0].invites); + expect(getInviteTierProgress(0)).toBe(0); + }); + + it('returns the first tier once one invite lands', () => { + expect(getCurrentInviteTier(1)?.invites).toBe(1); + expect(getNextInviteMilestone(1)?.invites).toBe(3); + expect(getInvitesUntilNextTier(1)).toBe(2); + }); + + it('computes progress between two tiers', () => { + // current 3 → next 5, at 4 we are 50%. + expect(getInviteTierProgress(4)).toBe(50); + }); + + it('clamps progress at 100 when the top tier is reached', () => { + const top = INVITE_MILESTONES[INVITE_MILESTONES.length - 1]; + expect(getNextInviteMilestone(top.invites)).toBeNull(); + expect(getInviteTierProgress(top.invites + 5)).toBe(100); + expect(getInvitesUntilNextTier(top.invites + 5)).toBe(0); + }); + + it('formats the step prefix with a leading zero under 10', () => { + expect(formatStep(1)).toBe('01'); + expect(formatStep(9)).toBe('09'); + expect(formatStep(10)).toBe('10'); + }); +}); diff --git a/packages/shared/src/features/inviteLedger/milestones.ts b/packages/shared/src/features/inviteLedger/milestones.ts new file mode 100644 index 00000000000..c879220d9de --- /dev/null +++ b/packages/shared/src/features/inviteLedger/milestones.ts @@ -0,0 +1,137 @@ +/** + * Tiered invite milestones. Each row is a fact about what unlocks at N invites, + * written in editorial voice — no RPG names, no marketing copy. The reward + * curve is intentionally steeper at the top so the early invites feel + * achievable and the top of the ladder feels like an actual flex. + */ + +export enum InviteRewardKind { + Cores = 'cores', + PlusDays = 'plus_days', + Cosmetic = 'cosmetic', + Perk = 'perk', +} + +export interface InviteReward { + kind: InviteRewardKind; + label: string; +} + +export interface InviteMilestone { + /** Numeric ID used as the visible "01"…"06" prefix. */ + step: number; + /** Number of invites required to unlock this row. */ + invites: number; + /** Short, neutral name for the milestone — fact, not flavor. */ + title: string; + /** Editorial one-liner. Conversational, specific, real stakes. */ + blurb: string; + rewards: InviteReward[]; +} + +export const INVITE_MILESTONES: InviteMilestone[] = [ + { + step: 1, + invites: 1, + title: 'Your first bring-in', + blurb: + 'Someone joins daily.dev because you sent the link. Small reward, big proof it works.', + rewards: [{ kind: InviteRewardKind.Cores, label: '100 Cores' }], + }, + { + step: 2, + invites: 3, + title: 'Three deep', + blurb: + "You've sent enough links that this isn't an accident. The flywheel starts.", + rewards: [{ kind: InviteRewardKind.Cores, label: '500 Cores' }], + }, + { + step: 3, + invites: 5, + title: 'A real circle', + blurb: + "Five devs landed here through you. That's a small group chat's worth of credit.", + rewards: [ + { kind: InviteRewardKind.Cores, label: '1,500 Cores' }, + { kind: InviteRewardKind.PlusDays, label: '7 days of Plus' }, + ], + }, + { + step: 4, + invites: 10, + title: 'Double-digit territory', + blurb: + 'Ten bring-ins puts you ahead of 98% of accounts. We start noticing.', + rewards: [ + { kind: InviteRewardKind.Cores, label: '5,000 Cores' }, + { kind: InviteRewardKind.PlusDays, label: '30 days of Plus' }, + ], + }, + { + step: 5, + invites: 25, + title: 'Needle-moving territory', + blurb: + 'Twenty-five bring-ins is the kind of number that shows up in our quarterly review.', + rewards: [ + { kind: InviteRewardKind.Cores, label: '15,000 Cores' }, + { + kind: InviteRewardKind.Cosmetic, + label: 'Inviter frame on your profile', + }, + ], + }, + { + step: 6, + invites: 50, + title: 'Top 0.1% of inviters', + blurb: + 'Fewer than a hundred accounts make it here. We unlock the rest of the kit.', + rewards: [ + { kind: InviteRewardKind.Cores, label: '50,000 Cores' }, + { kind: InviteRewardKind.Cosmetic, label: 'Glowing public ledger' }, + { kind: InviteRewardKind.Perk, label: 'Custom invite page' }, + ], + }, +]; + +export const getCurrentInviteTier = ( + invitesAccepted: number, +): InviteMilestone | null => { + const reached = INVITE_MILESTONES.filter((m) => invitesAccepted >= m.invites); + return reached[reached.length - 1] ?? null; +}; + +export const getNextInviteMilestone = ( + invitesAccepted: number, +): InviteMilestone | null => + INVITE_MILESTONES.find((m) => m.invites > invitesAccepted) ?? null; + +export const getInviteTierProgress = (invitesAccepted: number): number => { + const next = getNextInviteMilestone(invitesAccepted); + if (!next) { + return 100; + } + const current = getCurrentInviteTier(invitesAccepted); + const rangeStart = current?.invites ?? 0; + const rangeEnd = next.invites; + if (rangeEnd === rangeStart) { + return 100; + } + const ratio = + ((invitesAccepted - rangeStart) / (rangeEnd - rangeStart)) * 100; + return Math.min(Math.max(ratio, 0), 100); +}; + +export const getInvitesUntilNextTier = (invitesAccepted: number): number => { + const next = getNextInviteMilestone(invitesAccepted); + if (!next) { + return 0; + } + return Math.max(0, next.invites - invitesAccepted); +}; + +/** "01", "02"… for the visible numbered prefix in lists. */ +export const formatStep = (step: number): string => + step < 10 ? `0${step}` : String(step); diff --git a/packages/shared/src/features/inviteLedger/types.ts b/packages/shared/src/features/inviteLedger/types.ts new file mode 100644 index 00000000000..cda420d2978 --- /dev/null +++ b/packages/shared/src/features/inviteLedger/types.ts @@ -0,0 +1,29 @@ +import type { UserShortProfile } from '../../lib/user'; + +export const INVITE_LEDGER_CORES_PER_INVITE = 200; +export const INVITE_LEDGER_PLUS_DAYS_PER_INVITE = 7; +export const INVITE_LEDGER_RECENT_JOINS_DAYS = 7; + +export type InviteLedgerRowStatus = 'joined' | 'pending' | 'expired'; + +export interface InviteLedgerRow { + user: UserShortProfile; + status: InviteLedgerRowStatus; + coresToInviter: number; +} + +export interface InviteLedgerSnapshot { + inviteUrl: string; + invitesAccepted: number; + coresGiftedToFriends: number; + plusDaysGiftedToFriends: number; + coresEarned: number; + recentJoins: InviteLedgerRow[]; + rows: InviteLedgerRow[]; + hasNews: boolean; + isLoading: boolean; + fetchNextPage: () => Promise; + hasNextPage: boolean; + isFetchingNextPage: boolean; + newsCohortKey: string; +} diff --git a/packages/shared/src/features/inviteLedger/useInviteLedger.ts b/packages/shared/src/features/inviteLedger/useInviteLedger.ts new file mode 100644 index 00000000000..485126c5535 --- /dev/null +++ b/packages/shared/src/features/inviteLedger/useInviteLedger.ts @@ -0,0 +1,154 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useContext, useEffect, useMemo, useState } from 'react'; +import { differenceInDays } from 'date-fns'; +import AuthContext from '../../contexts/AuthContext'; +import { + ReferralCampaignKey, + useReferralCampaign, +} from '../../hooks/referral/useReferralCampaign'; +import { REFERRED_USERS_QUERY } from '../../graphql/users'; +import { + generateQueryKey, + getNextPageParam, + RequestKey, +} from '../../lib/query'; +import type { ReferredUsersData } from '../../graphql/common'; +import { gqlClient } from '../../graphql/common'; +import type { UserShortProfile } from '../../lib/user'; +import { link } from '../../lib/links'; +import { getInviteLedgerDemoMode } from './debug'; +import { getDemoSnapshot } from './fixtures'; +import { + INVITE_LEDGER_CORES_PER_INVITE, + INVITE_LEDGER_PLUS_DAYS_PER_INVITE, + INVITE_LEDGER_RECENT_JOINS_DAYS, +} from './types'; +import type { + InviteLedgerRow, + InviteLedgerRowStatus, + InviteLedgerSnapshot, +} from './types'; + +export type { InviteLedgerRow, InviteLedgerRowStatus, InviteLedgerSnapshot }; +export { + INVITE_LEDGER_CORES_PER_INVITE, + INVITE_LEDGER_PLUS_DAYS_PER_INVITE, + INVITE_LEDGER_RECENT_JOINS_DAYS, +}; + +const emptySnapshot = (): Omit< + InviteLedgerSnapshot, + 'inviteUrl' | 'fetchNextPage' +> => ({ + invitesAccepted: 0, + coresGiftedToFriends: 0, + plusDaysGiftedToFriends: 0, + coresEarned: 0, + recentJoins: [], + rows: [], + hasNews: false, + isLoading: false, + hasNextPage: false, + isFetchingNextPage: false, + newsCohortKey: '', +}); + +export const useInviteLedger = (): InviteLedgerSnapshot => { + const { user } = useContext(AuthContext); + const { url, referredUsersCount } = useReferralCampaign({ + campaignKey: ReferralCampaignKey.Generic, + }); + const inviteUrl = url || link.referral.defaultUrl; + const referredKey = generateQueryKey(RequestKey.ReferredUsers, user); + + const [demoMode, setDemoMode] = useState(getInviteLedgerDemoMode()); + useEffect(() => { + setDemoMode(getInviteLedgerDemoMode()); + const onChange = () => setDemoMode(getInviteLedgerDemoMode()); + window.addEventListener('invite-ledger:demo-mode-change', onChange); + return () => + window.removeEventListener('invite-ledger:demo-mode-change', onChange); + }, []); + + const demoSnapshot = useMemo( + () => (demoMode ? getDemoSnapshot(demoMode) : null), + [demoMode], + ); + + const usersResult = useInfiniteQuery({ + queryKey: referredKey, + queryFn: ({ pageParam }) => + gqlClient.request(REFERRED_USERS_QUERY, { + after: typeof pageParam === 'string' ? pageParam : undefined, + }), + initialPageParam: '', + enabled: !!user?.id, + getNextPageParam: ({ referredUsers }) => + getNextPageParam(referredUsers?.pageInfo), + }); + + const rows: InviteLedgerRow[] = useMemo(() => { + const list: InviteLedgerRow[] = []; + usersResult.data?.pages.forEach((page) => { + page?.referredUsers?.edges?.forEach(({ node }) => { + list.push({ + user: node as UserShortProfile, + status: 'joined', + coresToInviter: INVITE_LEDGER_CORES_PER_INVITE, + }); + }); + }); + return list; + }, [usersResult.data]); + + const now = Date.now(); + const recentJoins = useMemo( + () => + rows.filter( + (row) => + differenceInDays(now, new Date(row.user.createdAt)) <= + INVITE_LEDGER_RECENT_JOINS_DAYS, + ), + [rows, now], + ); + + const newsCohortKey = useMemo( + () => recentJoins.map((r) => r.user.id).join(','), + [recentJoins], + ); + + const totals = { + invitesAccepted: referredUsersCount || rows.length, + coresGiftedToFriends: + (referredUsersCount || rows.length) * INVITE_LEDGER_CORES_PER_INVITE, + plusDaysGiftedToFriends: + (referredUsersCount || rows.length) * INVITE_LEDGER_PLUS_DAYS_PER_INVITE, + coresEarned: + (referredUsersCount || rows.length) * INVITE_LEDGER_CORES_PER_INVITE, + }; + + if (demoSnapshot) { + return demoSnapshot; + } + + if (!user?.id) { + return { + inviteUrl, + fetchNextPage: async () => undefined, + ...emptySnapshot(), + }; + } + + return { + inviteUrl, + ...totals, + recentJoins, + rows, + hasNews: recentJoins.length > 0, + isLoading: usersResult.isLoading, + fetchNextPage: usersResult.fetchNextPage, + hasNextPage: !!usersResult.hasNextPage, + isFetchingNextPage: usersResult.isFetchingNextPage, + newsCohortKey, + }; +}; diff --git a/packages/shared/src/features/inviteLedger/useInviteLedgerEnabled.ts b/packages/shared/src/features/inviteLedger/useInviteLedgerEnabled.ts new file mode 100644 index 00000000000..f7427da06f1 --- /dev/null +++ b/packages/shared/src/features/inviteLedger/useInviteLedgerEnabled.ts @@ -0,0 +1,31 @@ +import { useContext, useEffect, useState } from 'react'; +import AuthContext from '../../contexts/AuthContext'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { featureInviteLedger } from '../../lib/featureManagement'; +import { isInviteLedgerDebugEnabled } from './debug'; + +/** + * Single source of truth for "is the Invite Ledger surface visible to this + * user?". GrowthBook drives production rollout; the debug override lets us + * review on Vercel previews where NODE_ENV !== 'development'. + */ +export const useInviteLedgerEnabled = (): boolean => { + const { user } = useContext(AuthContext); + const shouldEvaluate = !!user?.id; + const { value } = useConditionalFeature({ + feature: featureInviteLedger, + shouldEvaluate, + }); + + const [isDebugForced, setIsDebugForced] = useState(false); + useEffect(() => { + setIsDebugForced(isInviteLedgerDebugEnabled()); + const onChange = () => setIsDebugForced(isInviteLedgerDebugEnabled()); + window.addEventListener('invite-ledger:debug-change', onChange); + return () => { + window.removeEventListener('invite-ledger:debug-change', onChange); + }; + }, []); + + return shouldEvaluate && (!!value || isDebugForced); +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 9690a4cdf18..3cb65fde05a 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -42,6 +42,8 @@ export const featurePostPageHighlights = new Feature( false, ); +export const featureInviteLedger = new Feature('invite_ledger', isDevelopment); + // @ts-expect-error stale feature without default export const plusTakeoverContent = new Feature<{ title: string; diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 28449752b04..209e74d571c 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -210,6 +210,13 @@ export enum LogEvent { // Referral campaign CopyReferralLink = 'copy referral link', InviteReferral = 'invite referral', + // Invite ledger + InviteLedgerViewed = 'invite ledger viewed', + InviteLedgerStripImpression = 'invite ledger strip impression', + InviteLedgerStripClick = 'invite ledger strip click', + InviteLedgerStripDismiss = 'invite ledger strip dismiss', + InviteLedgerCounterClick = 'invite ledger counter click', + InviteLedgerChannelClick = 'invite ledger channel click', // Shortcuts RevokeShortcutAccess = 'revoke shortcut access', SaveShortcutAccess = 'save shortcut access', @@ -499,6 +506,9 @@ export enum TargetType { InviteFriendsPage = 'invite friends page', ProfilePage = 'profile page', GenericReferralPopup = 'generic referral popup', + InviteLedgerPage = 'invite ledger page', + InviteLedgerStrip = 'invite ledger strip', + InviteLedgerCounter = 'invite ledger counter', Shortcuts = 'shortcuts', VerifyEmail = 'verify email', ResendVerificationCode = 'resend verification code', @@ -573,6 +583,7 @@ export enum TargetId { GenericReferralPopup = 'generic referral popup', ProfilePage = 'profile page', InviteFriendsPage = 'invite friends page', + InviteLedgerPage = 'invite ledger page', Squad = 'squad', General = 'general', OrganizationsPage = 'organizations page', diff --git a/packages/webapp/pages/settings/referrals.tsx b/packages/webapp/pages/settings/referrals.tsx new file mode 100644 index 00000000000..4fa96720fe3 --- /dev/null +++ b/packages/webapp/pages/settings/referrals.tsx @@ -0,0 +1,68 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { NextSeoProps } from 'next-seo'; +import { LedgerPage } from '@dailydotdev/shared/src/features/inviteLedger/components/LedgerPage'; +import { useInviteLedgerEnabled } from '@dailydotdev/shared/src/features/inviteLedger/useInviteLedgerEnabled'; +import { setInviteLedgerDebugEnabled } from '@dailydotdev/shared/src/features/inviteLedger/debug'; +import { + Button, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { AccountPageContainer } from '../../components/layouts/SettingsLayout/AccountPageContainer'; +import { getSettingsLayout } from '../../components/layouts/SettingsLayout'; +import { defaultSeo } from '../../next-seo'; +import { getPageSeoTitles } from '../../components/layouts/utils'; + +const seo: NextSeoProps = { + ...defaultSeo, + ...getPageSeoTitles('Referrals'), +}; + +const SettingsReferralsPage = (): ReactElement => { + const isEnabled = useInviteLedgerEnabled(); + + if (!isEnabled) { + return ( + +
+ + The invite ledger is behind a feature flag. + + + Enable the demo console to preview the ledger, the feed strip and + the public profile counter on this preview environment. + + +
+
+ ); + } + + return ( + + + + ); +}; + +SettingsReferralsPage.getLayout = getSettingsLayout; +SettingsReferralsPage.layoutProps = { seo }; + +export default SettingsReferralsPage;