From ce60a05c0821dfdcaf677d9fa8c5b287a762d434 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 17 May 2026 12:23:19 +0300 Subject: [PATCH 01/11] feat(invite-ledger): introduce typography-first referral surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a restrained, layout-stable referral engine ("The Invite Ledger") built around three surfaces and one mental model: every developer you brought in is a permanent ledger entry. Surfaces - /settings/referrals — hero counters + invite link row + channel chips + monospace ledger table. No imagery, no progression chrome. - MainFeedLayout strip — fixed 44px element above the feed list, rendered only when there are joins in the last 7 days. Per-cohort localStorage dismissal so a new join still shows. - ProfileHeader counter — small mono pill next to the handle on the user's own profile, rendered only when count >= 1. Data and gating - `useInviteLedger` composes the existing `useReferralCampaign` + `REFERRED_USERS_QUERY` queries — no new GraphQL. - `featureInviteLedger` GrowthBook flag, defaults to `isDevelopment`. - `?inviteLedgerDebug=1` URL flag (sticky via localStorage) force-enables the feature on Vercel previews and is required for the settings menu entry to appear. Out of scope (explicitly avoided) - No DevCard treatments, no circular progress, no tier badges. - No new modals, no BootPopups wiring, no celebration scenes. Tests - LedgerStatusPill renders all three states with the correct accent. - Debug helper covers sticky URL flag, dismissal cohort behavior, and change-event dispatch. Co-authored-by: Cursor --- .../shared/src/components/MainFeedLayout.tsx | 2 + .../src/components/profile/ProfileHeader.tsx | 13 +- .../profile/ProfileSettingsMenu.tsx | 11 +- .../inviteLedger/components/ChannelChips.tsx | 71 ++++++++ .../components/InviteLedgerCounter.tsx | 42 +++++ .../components/InviteLedgerStrip.tsx | 126 ++++++++++++++ .../components/LedgerCounters.tsx | 38 +++++ .../inviteLedger/components/LedgerPage.tsx | 90 ++++++++++ .../inviteLedger/components/LedgerRow.tsx | 52 ++++++ .../components/LedgerStatusPill.spec.tsx | 26 +++ .../components/LedgerStatusPill.tsx | 33 ++++ .../inviteLedger/components/LedgerTable.tsx | 66 ++++++++ .../src/features/inviteLedger/debug.spec.ts | 64 +++++++ .../shared/src/features/inviteLedger/debug.ts | 62 +++++++ .../features/inviteLedger/useInviteLedger.ts | 157 ++++++++++++++++++ .../inviteLedger/useInviteLedgerEnabled.ts | 31 ++++ packages/shared/src/lib/featureManagement.ts | 2 + packages/shared/src/lib/log.ts | 11 ++ packages/webapp/pages/settings/referrals.tsx | 68 ++++++++ 19 files changed, 961 insertions(+), 4 deletions(-) create mode 100644 packages/shared/src/features/inviteLedger/components/ChannelChips.tsx create mode 100644 packages/shared/src/features/inviteLedger/components/InviteLedgerCounter.tsx create mode 100644 packages/shared/src/features/inviteLedger/components/InviteLedgerStrip.tsx create mode 100644 packages/shared/src/features/inviteLedger/components/LedgerCounters.tsx create mode 100644 packages/shared/src/features/inviteLedger/components/LedgerPage.tsx create mode 100644 packages/shared/src/features/inviteLedger/components/LedgerRow.tsx create mode 100644 packages/shared/src/features/inviteLedger/components/LedgerStatusPill.spec.tsx create mode 100644 packages/shared/src/features/inviteLedger/components/LedgerStatusPill.tsx create mode 100644 packages/shared/src/features/inviteLedger/components/LedgerTable.tsx create mode 100644 packages/shared/src/features/inviteLedger/debug.spec.ts create mode 100644 packages/shared/src/features/inviteLedger/debug.ts create mode 100644 packages/shared/src/features/inviteLedger/useInviteLedger.ts create mode 100644 packages/shared/src/features/inviteLedger/useInviteLedgerEnabled.ts create mode 100644 packages/webapp/pages/settings/referrals.tsx 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 ? ( {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/ChannelChips.tsx b/packages/shared/src/features/inviteLedger/components/ChannelChips.tsx new file mode 100644 index 00000000000..acefaf6eb9f --- /dev/null +++ b/packages/shared/src/features/inviteLedger/components/ChannelChips.tsx @@ -0,0 +1,71 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { ShareProvider, getShareLink } from '../../../lib/share'; +import { labels } from '../../../lib'; +import { useCopyLink } from '../../../hooks/useCopy'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../../lib/log'; + +interface ChannelChipsProps { + link: string; +} + +interface Channel { + provider: ShareProvider; + label: string; +} + +const CHANNELS: Channel[] = [ + { provider: ShareProvider.WhatsApp, label: 'WhatsApp' }, + { provider: ShareProvider.LinkedIn, label: 'LinkedIn' }, + { provider: ShareProvider.Telegram, label: 'Telegram' }, + { provider: ShareProvider.Twitter, label: 'X' }, + { provider: ShareProvider.Email, label: 'Email' }, +]; + +const CHIP_CLASS = + 'rounded-md border border-border-subtlest-tertiary bg-transparent px-2.5 py-1 font-mono text-[11px] tracking-[0.04em] text-text-secondary hover:border-text-tertiary hover:text-text-primary transition-colors'; + +export const ChannelChips = ({ link }: ChannelChipsProps): ReactElement => { + const { logEvent } = useLogContext(); + const [, copyLink] = useCopyLink(); + + const handleClick = (provider: ShareProvider) => { + logEvent({ + event_name: LogEvent.InviteLedgerChannelClick, + target_id: provider, + target_type: TargetType.InviteLedgerPage, + }); + }; + + return ( +
+ {CHANNELS.map(({ provider, label }) => ( + handleClick(provider)} + > + {label} + + ))} + +
+ ); +}; 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..fa920c204d5 --- /dev/null +++ b/packages/shared/src/features/inviteLedger/components/InviteLedgerCounter.tsx @@ -0,0 +1,42 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useRouter } from 'next/router'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../../lib/log'; +import { useInviteLedgerEnabled } from '../useInviteLedgerEnabled'; +import { useInviteLedger } from '../useInviteLedger'; + +/** + * Small mono pill rendered next to the user's handle on their own profile. + * Public-facing surface (visitors see the count, not the cores). Renders + * only when count >= 1 so "0 invites" never appears as a pill. + */ +export const InviteLedgerCounter = (): ReactElement | null => { + const isEnabled = useInviteLedgerEnabled(); + const ledger = useInviteLedger(); + const { logEvent } = useLogContext(); + const router = useRouter(); + + if (!isEnabled || ledger.invitesAccepted < 1) { + return null; + } + + return ( + + ); +}; 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..eba32d010c6 --- /dev/null +++ b/packages/shared/src/features/inviteLedger/components/InviteLedgerStrip.tsx @@ -0,0 +1,126 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { useRouter } from 'next/router'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../../lib/log'; +import { useInviteLedger } from '../useInviteLedger'; +import { useInviteLedgerEnabled } from '../useInviteLedgerEnabled'; +import { isStripDismissed, setStripDismissed } from '../debug'; +import { MiniCloseIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; + +interface InviteLedgerStripProps { + className?: string; +} + +const STRIP_HEIGHT_CLASS = 'h-11'; // 44px exact, prevents CLS + +const buildHeadline = (joinedNames: string[]): string => { + if (joinedNames.length === 0) { + return ''; + } + if (joinedNames.length === 1) { + return `${joinedNames[0]}`; + } + if (joinedNames.length === 2) { + return `${joinedNames[0]} and ${joinedNames[1]}`; + } + return `${joinedNames[0]}, ${joinedNames[1]} +${joinedNames.length - 2}`; +}; + +/** + * Fixed-height strip placed above the feed. Renders only when there is + * something to tell the user about (joins in the last 7 days). Height is + * locked so render/unrender does NOT shift cumulative layout. + */ +export const InviteLedgerStrip = ({ + className, +}: InviteLedgerStripProps): ReactElement | null => { + const isEnabled = useInviteLedgerEnabled(); + const ledger = useInviteLedger(); + const { logEvent } = useLogContext(); + const router = useRouter(); + const [isDismissed, setIsDismissed] = useState(true); + + useEffect(() => { + if (!ledger.newsCohortKey) { + setIsDismissed(true); + return; + } + setIsDismissed(isStripDismissed(ledger.newsCohortKey)); + }, [ledger.newsCohortKey]); + + const shouldRender = + isEnabled && ledger.hasNews && !isDismissed && !!ledger.newsCohortKey; + + useEffect(() => { + if (!shouldRender) { + return; + } + logEvent({ + event_name: LogEvent.InviteLedgerStripImpression, + target_type: TargetType.InviteLedgerStrip, + extra: JSON.stringify({ joined: ledger.recentJoins.length }), + }); + }, [shouldRender, ledger.recentJoins.length, logEvent]); + + if (!shouldRender) { + return null; + } + + const headline = buildHeadline( + ledger.recentJoins.map((row) => `@${row.user.username}`), + ); + const coresAdded = + ledger.recentJoins.length * (ledger.recentJoins[0]?.coresToInviter || 0); + + return ( +
+ + +{ledger.recentJoins.length} joined + + + {headline} + + {' '} + joined through your invite this week. {coresAdded} Cores added. + + + + +
+ ); +}; diff --git a/packages/shared/src/features/inviteLedger/components/LedgerCounters.tsx b/packages/shared/src/features/inviteLedger/components/LedgerCounters.tsx new file mode 100644 index 00000000000..75f7fa3c6f9 --- /dev/null +++ b/packages/shared/src/features/inviteLedger/components/LedgerCounters.tsx @@ -0,0 +1,38 @@ +import type { ReactElement } from 'react'; +import React from 'react'; + +interface LedgerCountersProps { + invitesAccepted: number; + coresGiftedToFriends: number; + plusDaysGiftedToFriends: number; +} + +interface CounterProps { + label: string; + value: number; +} + +const formatNumber = (value: number): string => value.toLocaleString('en-US'); + +const Counter = ({ label, value }: CounterProps) => ( +
+
+ {label} +
+
+ {formatNumber(value)} +
+
+); + +export const LedgerCounters = ({ + invitesAccepted, + coresGiftedToFriends, + plusDaysGiftedToFriends, +}: LedgerCountersProps): ReactElement => ( +
+ + + +
+); 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..57bcd23b3e9 --- /dev/null +++ b/packages/shared/src/features/inviteLedger/components/LedgerPage.tsx @@ -0,0 +1,90 @@ +import type { ReactElement } from 'react'; +import React, { useEffect } from 'react'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetId, TargetType } from '../../../lib/log'; +import { InviteLinkInput } from '../../../components/referral'; +import { useInviteLedger } from '../useInviteLedger'; +import { LedgerCounters } from './LedgerCounters'; +import { LedgerTable } from './LedgerTable'; +import { ChannelChips } from './ChannelChips'; +import { Button, ButtonVariant } from '../../../components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; + +export const LedgerPage = (): ReactElement => { + const ledger = useInviteLedger(); + const { logEvent } = useLogContext(); + + useEffect(() => { + logEvent({ + event_name: LogEvent.InviteLedgerViewed, + target_type: TargetType.InviteLedgerPage, + }); + }, [logEvent]); + + return ( +
+
+
+ + Your invite ledger + + + A record of the developers you brought in. + +
+ +
+ +
+ + +
+ +
+ +
+ + {ledger.hasNextPage && ( +
+ +
+ )} + +
+ Showing {ledger.rows.length} of {ledger.invitesAccepted} \u00b7 sorted + by most recent +
+
+ ); +}; diff --git a/packages/shared/src/features/inviteLedger/components/LedgerRow.tsx b/packages/shared/src/features/inviteLedger/components/LedgerRow.tsx new file mode 100644 index 00000000000..2d9306d6aa5 --- /dev/null +++ b/packages/shared/src/features/inviteLedger/components/LedgerRow.tsx @@ -0,0 +1,52 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { format } from 'date-fns'; +import { LedgerStatusPill } from './LedgerStatusPill'; +import type { InviteLedgerRow } from '../useInviteLedger'; + +interface LedgerRowProps { + row: InviteLedgerRow; + coresPerInvite: number; + plusDaysPerInvite: number; +} + +const buildGiftLabel = ( + status: InviteLedgerRow['status'], + coresPerInvite: number, + plusDaysPerInvite: number, +): string => { + if (status === 'expired') { + return 'No response'; + } + if (status === 'pending') { + return `${coresPerInvite} Cores reserved`; + } + return `${coresPerInvite} Cores + ${plusDaysPerInvite} Plus days`; +}; + +export const LedgerRow = ({ + row, + coresPerInvite, + plusDaysPerInvite, +}: LedgerRowProps): ReactElement => { + const { user, status, coresToInviter } = row; + return ( + + + {format(new Date(user.createdAt), 'yyyy-MM-dd')} + + + @{user.username} + + + {buildGiftLabel(status, coresPerInvite, plusDaysPerInvite)} + + + {status === 'joined' ? coresToInviter : '\u2014'} + + + + + + ); +}; diff --git a/packages/shared/src/features/inviteLedger/components/LedgerStatusPill.spec.tsx b/packages/shared/src/features/inviteLedger/components/LedgerStatusPill.spec.tsx new file mode 100644 index 00000000000..42ee1e35f92 --- /dev/null +++ b/packages/shared/src/features/inviteLedger/components/LedgerStatusPill.spec.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { LedgerStatusPill } from './LedgerStatusPill'; + +describe('LedgerStatusPill', () => { + it('renders Joined label with upvote accent', () => { + render(); + const pill = screen.getByText('Joined'); + expect(pill).toBeInTheDocument(); + expect(pill).toHaveClass('text-action-upvote-default'); + }); + + it('renders Pending label with bookmark accent', () => { + render(); + const pill = screen.getByText('Pending'); + expect(pill).toBeInTheDocument(); + expect(pill).toHaveClass('text-action-bookmark-default'); + }); + + it('renders Expired label with quaternary text', () => { + render(); + const pill = screen.getByText('Expired'); + expect(pill).toBeInTheDocument(); + expect(pill).toHaveClass('text-text-quaternary'); + }); +}); diff --git a/packages/shared/src/features/inviteLedger/components/LedgerStatusPill.tsx b/packages/shared/src/features/inviteLedger/components/LedgerStatusPill.tsx new file mode 100644 index 00000000000..acf686a01ce --- /dev/null +++ b/packages/shared/src/features/inviteLedger/components/LedgerStatusPill.tsx @@ -0,0 +1,33 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { InviteLedgerRowStatus } from '../useInviteLedger'; + +interface LedgerStatusPillProps { + status: InviteLedgerRowStatus; +} + +const statusLabel: Record = { + joined: 'Joined', + pending: 'Pending', + expired: 'Expired', +}; + +const statusClassName: Record = { + joined: 'bg-action-upvote-float text-action-upvote-default', + pending: 'bg-action-bookmark-float text-action-bookmark-default', + expired: 'bg-surface-float text-text-quaternary', +}; + +export const LedgerStatusPill = ({ + status, +}: LedgerStatusPillProps): ReactElement => ( + + {statusLabel[status]} + +); diff --git a/packages/shared/src/features/inviteLedger/components/LedgerTable.tsx b/packages/shared/src/features/inviteLedger/components/LedgerTable.tsx new file mode 100644 index 00000000000..d76f3f34302 --- /dev/null +++ b/packages/shared/src/features/inviteLedger/components/LedgerTable.tsx @@ -0,0 +1,66 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { LedgerRow } from './LedgerRow'; +import { + INVITE_LEDGER_CORES_PER_INVITE, + INVITE_LEDGER_PLUS_DAYS_PER_INVITE, +} from '../useInviteLedger'; +import type { InviteLedgerRow } from '../useInviteLedger'; + +interface LedgerTableProps { + rows: InviteLedgerRow[]; + isLoading: boolean; + className?: string; +} + +const HEADER_CELL = + 'px-3 pt-4 pb-2 text-left font-mono text-[10px] uppercase tracking-[0.16em] font-medium text-text-quaternary border-b border-border-subtlest-tertiary'; + +export const LedgerTable = ({ + rows, + isLoading, + className, +}: LedgerTableProps): ReactElement => { + if (isLoading) { + return ( +
+ Loading ledger\u2026 +
+ ); + } + + if (rows.length === 0) { + return ( +
+ No invites yet. Share your link. +
+ ); + } + + return ( + + + + + + + + + + + + {rows.map((row) => ( + + ))} + +
DateDeveloperGift to them + Cores you got + Status
+ ); +}; 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..649c5877aa2 --- /dev/null +++ b/packages/shared/src/features/inviteLedger/debug.spec.ts @@ -0,0 +1,64 @@ +import { + isInviteLedgerDebugEnabled, + setInviteLedgerDebugEnabled, + isStripDismissed, + setStripDismissed, +} 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('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..1a14741e237 --- /dev/null +++ b/packages/shared/src/features/inviteLedger/debug.ts @@ -0,0 +1,62 @@ +/** + * 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 STRIP_DISMISS_PREFIX = 'inviteLedgerStripDismissed:'; + +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 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'); +}; diff --git a/packages/shared/src/features/inviteLedger/useInviteLedger.ts b/packages/shared/src/features/inviteLedger/useInviteLedger.ts new file mode 100644 index 00000000000..4da2ebc0e35 --- /dev/null +++ b/packages/shared/src/features/inviteLedger/useInviteLedger.ts @@ -0,0 +1,157 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useContext, useMemo } 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'; + +/** + * v1 reward shape, hard-coded until backend exposes per-invite reward + * metadata. Mirrors what the existing /settings/invite copy already + * promises ("200 Cores per invite"). + */ +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; + /** + * Backend only returns developers who already joined through the link + * today, so every row is "joined" in v1. The shape supports `pending` + * and `expired` so the same component renders unchanged once the API + * exposes those states. + */ + 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; + /** Stable identifier of the current "recent joins" cohort for dismissal. */ + newsCohortKey: string; +} + +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 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 (!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; From 21580764dd96fa692b59d2c2080c7b625c59782a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 17 May 2026 12:38:26 +0300 Subject: [PATCH 02/11] chore(invite-ledger): add ?inviteLedgerDemoData URL flag for state coverage Lets reviewers preview every visual state of the Invite Ledger surface without seeding real referral data on their account. Modes - ?inviteLedgerDemoData=full joined + pending + expired pills, 3 recent joins to drive the strip and counter - ?inviteLedgerDemoData=single 1 joined row, drives minimal strip - ?inviteLedgerDemoData=empty zero rows, exercises the empty state (no strip, no counter) - ?inviteLedgerDemoData=off clears the sticky flag Sticky via localStorage like the existing debug flag. When active a small mono banner appears on /settings/referrals with mode-switch controls so reviewers don't have to leave the page. Refactor types/constants out of useInviteLedger into a dedicated types.ts to break the circular import with the new fixtures module. Co-authored-by: Cursor --- .../inviteLedger/components/LedgerPage.tsx | 34 ++++++ .../src/features/inviteLedger/debug.spec.ts | 44 ++++++++ .../shared/src/features/inviteLedger/debug.ts | 46 ++++++++ .../features/inviteLedger/fixtures.spec.ts | 35 ++++++ .../src/features/inviteLedger/fixtures.ts | 105 ++++++++++++++++++ .../shared/src/features/inviteLedger/types.ts | 29 +++++ .../features/inviteLedger/useInviteLedger.ts | 77 ++++++------- 7 files changed, 330 insertions(+), 40 deletions(-) create mode 100644 packages/shared/src/features/inviteLedger/fixtures.spec.ts create mode 100644 packages/shared/src/features/inviteLedger/fixtures.ts create mode 100644 packages/shared/src/features/inviteLedger/types.ts diff --git a/packages/shared/src/features/inviteLedger/components/LedgerPage.tsx b/packages/shared/src/features/inviteLedger/components/LedgerPage.tsx index 57bcd23b3e9..7e30475b885 100644 --- a/packages/shared/src/features/inviteLedger/components/LedgerPage.tsx +++ b/packages/shared/src/features/inviteLedger/components/LedgerPage.tsx @@ -4,6 +4,7 @@ import { useLogContext } from '../../../contexts/LogContext'; import { LogEvent, TargetId, TargetType } from '../../../lib/log'; import { InviteLinkInput } from '../../../components/referral'; import { useInviteLedger } from '../useInviteLedger'; +import { getInviteLedgerDemoMode, setInviteLedgerDemoMode } from '../debug'; import { LedgerCounters } from './LedgerCounters'; import { LedgerTable } from './LedgerTable'; import { ChannelChips } from './ChannelChips'; @@ -15,9 +16,12 @@ import { TypographyType, } from '../../../components/typography/Typography'; +const DEMO_MODES = ['full', 'single', 'empty'] as const; + export const LedgerPage = (): ReactElement => { const ledger = useInviteLedger(); const { logEvent } = useLogContext(); + const demoMode = getInviteLedgerDemoMode(); useEffect(() => { logEvent({ @@ -28,6 +32,36 @@ export const LedgerPage = (): ReactElement => { return (
+ {demoMode && ( +
+ Demo data: {demoMode} + + {DEMO_MODES.filter((m) => m !== demoMode).map((mode) => ( + + ))} + + +
+ )}
{ }); }); + 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); diff --git a/packages/shared/src/features/inviteLedger/debug.ts b/packages/shared/src/features/inviteLedger/debug.ts index 1a14741e237..273691d34c1 100644 --- a/packages/shared/src/features/inviteLedger/debug.ts +++ b/packages/shared/src/features/inviteLedger/debug.ts @@ -8,8 +8,17 @@ */ const ENABLED_KEY = 'inviteLedgerDebug'; +const DEMO_MODE_KEY = 'inviteLedgerDemoMode'; const STRIP_DISMISS_PREFIX = 'inviteLedgerStripDismissed:'; +export type InviteLedgerDemoMode = 'full' | 'empty' | 'single' | null; + +const VALID_MODES: ReadonlyArray = [ + 'full', + 'empty', + 'single', +]; + const safeWindow = (): Window | null => typeof window === 'undefined' ? null : window; @@ -42,6 +51,43 @@ export const setInviteLedgerDebugEnabled = (enabled: boolean): void => { 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}`; 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/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 index 4da2ebc0e35..485126c5535 100644 --- a/packages/shared/src/features/inviteLedger/useInviteLedger.ts +++ b/packages/shared/src/features/inviteLedger/useInviteLedger.ts @@ -1,5 +1,5 @@ import { useInfiniteQuery } from '@tanstack/react-query'; -import { useContext, useMemo } from 'react'; +import { useContext, useEffect, useMemo, useState } from 'react'; import { differenceInDays } from 'date-fns'; import AuthContext from '../../contexts/AuthContext'; import { @@ -16,46 +16,25 @@ 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'; -/** - * v1 reward shape, hard-coded until backend exposes per-invite reward - * metadata. Mirrors what the existing /settings/invite copy already - * promises ("200 Cores per invite"). - */ -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; - /** - * Backend only returns developers who already joined through the link - * today, so every row is "joined" in v1. The shape supports `pending` - * and `expired` so the same component renders unchanged once the API - * exposes those states. - */ - 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; - /** Stable identifier of the current "recent joins" cohort for dismissal. */ - newsCohortKey: string; -} +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, @@ -82,6 +61,20 @@ export const useInviteLedger = (): InviteLedgerSnapshot => { 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 }) => @@ -134,6 +127,10 @@ export const useInviteLedger = (): InviteLedgerSnapshot => { (referredUsersCount || rows.length) * INVITE_LEDGER_CORES_PER_INVITE, }; + if (demoSnapshot) { + return demoSnapshot; + } + if (!user?.id) { return { inviteUrl, From f51b0a4490b742af4c1ad7ba6fc3b5350a856cc2 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 17 May 2026 12:45:24 +0300 Subject: [PATCH 03/11] fix(invite-ledger): contrast pass + pinned navigator panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contrast - Drop the redundant white card on /settings/referrals — AccountPageContainer already provides the outer chrome, so the inner bg-surface-primary turned the whole page white-on-white in light theme. - Bump every muted token one step (text-quaternary → text-tertiary, text-tertiary → text-secondary, border-subtlest-tertiary → border-subtlest-secondary) on the table headers, footer, channel chips, feed strip, profile counter, and dismiss button. Pinned navigator - New InviteLedgerNavigator component mounted in MainLayout. Visible only when ?inviteLedgerDebug=1 is sticky. Discreet pill in the bottom-right that expands into a list of every demo state (ledger full / single / empty / real, feed strip variants, profile counter, settings menu entry, legacy invite page). One click switches demo mode, clears strip dismissals, and navigates — no more typing URL params. Co-authored-by: Cursor --- packages/shared/src/components/MainLayout.tsx | 2 + .../inviteLedger/components/ChannelChips.tsx | 2 +- .../components/InviteLedgerCounter.tsx | 2 +- .../components/InviteLedgerNavigator.tsx | 243 ++++++++++++++++++ .../components/InviteLedgerStrip.tsx | 8 +- .../components/LedgerCounters.tsx | 2 +- .../inviteLedger/components/LedgerPage.tsx | 8 +- .../inviteLedger/components/LedgerRow.tsx | 4 +- .../inviteLedger/components/LedgerTable.tsx | 6 +- 9 files changed, 261 insertions(+), 16 deletions(-) create mode 100644 packages/shared/src/features/inviteLedger/components/InviteLedgerNavigator.tsx diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index ef63d4e726a..72c23790081 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -35,6 +35,7 @@ import { SearchProvider } from '../contexts/search/SearchContext'; import { SpotlightProvider } from './spotlight/SpotlightContext'; import { SpotlightHost } from './spotlight/SpotlightHost'; import { FeedbackWidget } from './feedback'; +import { InviteLedgerNavigator } from '../features/inviteLedger/components/InviteLedgerNavigator'; import { isExtension } from '../lib/func'; const GoBackHeaderMobile = dynamic( @@ -226,6 +227,7 @@ function MainLayoutComponent({ {children} {!hideFeedbackWidget && } +
); } diff --git a/packages/shared/src/features/inviteLedger/components/ChannelChips.tsx b/packages/shared/src/features/inviteLedger/components/ChannelChips.tsx index acefaf6eb9f..8b0b69565e7 100644 --- a/packages/shared/src/features/inviteLedger/components/ChannelChips.tsx +++ b/packages/shared/src/features/inviteLedger/components/ChannelChips.tsx @@ -24,7 +24,7 @@ const CHANNELS: Channel[] = [ ]; const CHIP_CLASS = - 'rounded-md border border-border-subtlest-tertiary bg-transparent px-2.5 py-1 font-mono text-[11px] tracking-[0.04em] text-text-secondary hover:border-text-tertiary hover:text-text-primary transition-colors'; + 'rounded-md border border-border-subtlest-secondary bg-transparent px-2.5 py-1 font-mono text-[11px] tracking-[0.04em] text-text-secondary hover:border-text-secondary hover:text-text-primary transition-colors'; export const ChannelChips = ({ link }: ChannelChipsProps): ReactElement => { const { logEvent } = useLogContext(); diff --git a/packages/shared/src/features/inviteLedger/components/InviteLedgerCounter.tsx b/packages/shared/src/features/inviteLedger/components/InviteLedgerCounter.tsx index fa920c204d5..ecf3e64729a 100644 --- a/packages/shared/src/features/inviteLedger/components/InviteLedgerCounter.tsx +++ b/packages/shared/src/features/inviteLedger/components/InviteLedgerCounter.tsx @@ -24,7 +24,7 @@ export const InviteLedgerCounter = (): ReactElement | null => { return ( +
+
    + {SURFACES.map((entry) => { + const isActive = + router.asPath.split('?')[0] === + entry.buildHref(user?.username) && + entry.demoMode === activeMode; + return ( +
  • + +
  • + ); + })} +
+
+ + +
+
+ )} + +
+ ); +}; diff --git a/packages/shared/src/features/inviteLedger/components/InviteLedgerStrip.tsx b/packages/shared/src/features/inviteLedger/components/InviteLedgerStrip.tsx index eba32d010c6..3725f3e8080 100644 --- a/packages/shared/src/features/inviteLedger/components/InviteLedgerStrip.tsx +++ b/packages/shared/src/features/inviteLedger/components/InviteLedgerStrip.tsx @@ -79,16 +79,16 @@ export const InviteLedgerStrip = ({
- + +{ledger.recentJoins.length} joined {headline} - + {' '} joined through your invite this week. {coresAdded} Cores added. @@ -109,7 +109,7 @@ export const InviteLedgerStrip = ({
    @@ -236,7 +236,7 @@ export const InviteLedgerNavigator = (): ReactElement | null => { )} > - ledger demo \u00b7 {activeMode ?? 'real'} + ledger demo · {activeMode ?? 'real'}
); diff --git a/packages/shared/src/features/inviteLedger/components/InviteLedgerStrip.tsx b/packages/shared/src/features/inviteLedger/components/InviteLedgerStrip.tsx index 3725f3e8080..2b7900d07cf 100644 --- a/packages/shared/src/features/inviteLedger/components/InviteLedgerStrip.tsx +++ b/packages/shared/src/features/inviteLedger/components/InviteLedgerStrip.tsx @@ -104,7 +104,7 @@ export const InviteLedgerStrip = ({ router.push('/settings/referrals'); }} > - Open ledger \u2192 + Open ledger → + + + + + ); +}; 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} + + )} +
+);