- {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 && (
+
+
+
+ {SURFACES.map((entry) => {
+ const isActive =
+ router.asPath.split('?')[0] ===
+ entry.buildHref(user?.username) &&
+ entry.demoMode === activeMode;
+ return (
+
+ goTo(entry)}
+ className={classNames(
+ 'flex w-full flex-col items-start gap-0.5 border-l-2 px-4 py-2 text-left hover:bg-surface-float',
+ isActive
+ ? 'border-action-bookmark-default'
+ : 'border-transparent',
+ )}
+ >
+
+ {entry.label}
+
+
+ {entry.description}
+
+
+
+ );
+ })}
+
+
+
+ Promo modal
+
+
+ open
+
+
+
+ {
+ setInviteLedgerDemoMode(null);
+ clearStripDismissals();
+ setInviteLedgerPromoDismissed(false);
+ resetInviteLedgerPromoSeen();
+ window.location.reload();
+ }}
+ >
+ reset state
+
+ {
+ setInviteLedgerDebugEnabled(false);
+ setInviteLedgerDemoMode(null);
+ window.location.reload();
+ }}
+ >
+ disable demo
+
+
+
+ )}
+
setOpen((v) => !v)}
+ aria-expanded={open}
+ className={classNames(
+ 'pointer-events-auto inline-flex h-9 items-center gap-2 rounded-full border border-border-subtlest-secondary px-3 font-mono text-[11px] uppercase tracking-[0.1em] shadow-2 backdrop-blur transition-colors',
+ open
+ ? 'bg-surface-primary text-text-primary'
+ : 'bg-background-default text-text-secondary hover:text-text-primary',
+ )}
+ >
+
+ ledger demo · {activeMode ?? 'real'}
+
+
+ );
+};
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}
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ onClick={handleOpenLedger}
+ >
+ Open the ledger
+
+
+ Not now
+
+
+
+ );
+}
+
+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 (
+ {
+ setInviteLedgerDemoMode(mode);
+ window.location.reload();
+ }}
+ className={classNames(
+ 'rounded-6 px-1.5',
+ isActive
+ ? 'bg-surface-primary text-text-primary'
+ : 'hover:text-text-primary',
+ )}
+ >
+ {label}
+
+ );
+ })}
+
+);
+
+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.
+
+
+
+
+
+
+
+
+
+
+
+
+ {ledger.hasNextPage && (
+
+ ledger.fetchNextPage()}
+ >
+ Load earlier filings
+
+
+ )}
+
+
+
+
+
+ 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 (
+
+
+
+
+ {display}
+
+
+
+ {copied ? 'copied' : copyLabel}
+
+
+
+
+
+ );
+};
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 (
+
+
+ №{formatStep(milestone.step)}
+
+
+ {milestone.title}
+
+ {stateBadge}
+
+
+
+
+
+ );
+ })}
+
+ {next && invitesAway > 0 && variant === 'page' && (
+
+
+ {invitesAway === 1
+ ? 'One more bring-in'
+ : `${invitesAway} more bring-ins`}
+ {' '}
+ to{' '}
+ {next.title} .
+
+ )}
+
+ );
+};
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 (
+ 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)}
+
+
+ );
+ })}
+
+ );
+};
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.
+
+ {
+ setInviteLedgerDebugEnabled(true);
+ window.location.reload();
+ }}
+ >
+ Enable demo
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+};
+
+SettingsReferralsPage.getLayout = getSettingsLayout;
+SettingsReferralsPage.layoutProps = { seo };
+
+export default SettingsReferralsPage;