From 09af923dba7d705270dec7c7e53b4a96eaa9f2d3 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sat, 27 Jun 2026 00:13:24 -0300 Subject: [PATCH 1/6] feat(gamification): free-tier display + next-reward carrot + recap types (#186, #190) Free users see streak/XP/level gated on canViewGamification; next-reward carrot teases Pro rewards. Recap endpoint + Zod types + query key + share-url helper. Web+mobile parity, en + pt-BR. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../gamification/next-reward-carrot.test.tsx | 105 +++++++++++++ .../screens/upgrade-guard-redirects.test.tsx | 3 + apps/mobile/app/(tabs)/profile.tsx | 49 ++++-- .../_components/next-reward-carrot.tsx | 148 ++++++++++++++++++ apps/mobile/app/_layout.tsx | 5 +- apps/mobile/app/achievements.tsx | 24 ++- apps/mobile/app/streak-sections-freeze.tsx | 6 +- apps/mobile/app/streak.tsx | 6 +- apps/mobile/hooks/use-profile.ts | 6 + .../components/next-reward-carrot.test.tsx | 52 ++++++ .../__tests__/hooks/use-gamification.test.ts | 8 + .../web/__tests__/pages/achievements.test.tsx | 21 ++- apps/web/__tests__/pages/streak.test.tsx | 8 +- apps/web/app/(app)/achievements/page.tsx | 17 +- apps/web/app/(app)/layout.tsx | 8 +- .../_components/next-reward-carrot.tsx | 112 +++++++++++++ .../_components/profile-stat-tiles.tsx | 26 ++- apps/web/app/(app)/profile/page.tsx | 19 ++- .../streak/_components/streak-sections.tsx | 6 +- apps/web/app/(app)/streak/page.tsx | 6 +- apps/web/hooks/use-profile.ts | 6 + packages/shared/src/__tests__/factories.ts | 9 ++ .../src/__tests__/gamification-schema.test.ts | 116 ++++++++++++++ .../__tests__/gamification-selectors.test.ts | 46 ++++++ .../shared/src/__tests__/referral.test.ts | 14 +- packages/shared/src/api/endpoints.ts | 1 + packages/shared/src/i18n/en.json | 20 +++ packages/shared/src/i18n/pt-BR.json | 20 +++ packages/shared/src/query/keys.ts | 1 + packages/shared/src/types/gamification.ts | 45 ++++++ packages/shared/src/types/profile.ts | 1 + .../src/utils/gamification-selectors.ts | 27 ++++ packages/shared/src/utils/index.ts | 4 +- packages/shared/src/utils/referral.ts | 11 ++ packages/shared/src/utils/retrospective.ts | 28 +--- 35 files changed, 902 insertions(+), 82 deletions(-) create mode 100644 apps/mobile/__tests__/components/gamification/next-reward-carrot.test.tsx create mode 100644 apps/mobile/app/(tabs)/profile/_components/next-reward-carrot.tsx create mode 100644 apps/web/__tests__/components/next-reward-carrot.test.tsx create mode 100644 apps/web/app/(app)/profile/_components/next-reward-carrot.tsx create mode 100644 packages/shared/src/__tests__/gamification-schema.test.ts diff --git a/apps/mobile/__tests__/components/gamification/next-reward-carrot.test.tsx b/apps/mobile/__tests__/components/gamification/next-reward-carrot.test.tsx new file mode 100644 index 000000000..398d48cf6 --- /dev/null +++ b/apps/mobile/__tests__/components/gamification/next-reward-carrot.test.tsx @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest' + +import { NextRewardCarrot } from '@/app/(tabs)/profile/_components/next-reward-carrot' + +const TestRenderer = require('react-test-renderer') + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => { + if (params) return `${key}:${JSON.stringify(params)}` + return key + }, + }), +})) + +vi.mock('@/lib/use-app-theme', () => ({ + useAppTheme: () => ({ currentScheme: 'purple', currentTheme: 'dark' }), +})) + +vi.mock('@/lib/theme', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createTokensV2: () => ({ + primary: '#7c5cff', + primaryPressed: '#6a4ce0', + fgOnPrimary: '#ffffff', + fg1: '#ffffff', + fg2: '#cccccc', + fg3: '#999999', + }), + primaryGlow: () => ({}), + tintFromPrimary: () => 'rgba(124, 92, 255, 0.08)', + } +}) + +vi.mock('lucide-react-native', () => ({ + Lock: () => null, + Sparkles: () => null, +})) + +function render(props: Parameters[0]) { + let tree: { toJSON: () => unknown; root: { findAll: (predicate: (node: { props?: Record }) => boolean) => unknown[] } } + TestRenderer.act(() => { + tree = TestRenderer.create() + }) + return tree! +} + +function collectText(node: unknown): string { + if (node == null) return '' + if (typeof node === 'string') return node + if (Array.isArray(node)) return node.map(collectText).join(' ') + if (typeof node === 'object' && 'children' in (node as Record)) { + return collectText((node as { children: unknown }).children) + } + return '' +} + +function serialize(tree: { toJSON: () => unknown }) { + return collectText(tree.toJSON()) +} + +function findUpgradeButton(tree: { root: { findAll: (predicate: (node: { props?: Record }) => boolean) => unknown[] } }) { + return tree.root.findAll( + (node) => + !!node.props && + node.props.accessibilityRole === 'button' && + typeof node.props.onPress === 'function', + ) +} + +const baseCarrot = { nextLevel: 4, nextLevelTitle: 'Navigator', xpToNextLevel: 300 } + +describe('NextRewardCarrot (mobile)', () => { + it('renders nothing when carrot is null', () => { + const tree = render({ carrot: null, onUpgrade: vi.fn() }) + expect(tree.toJSON()).toBeNull() + }) + + it('shows the next level, XP-to-go, Pro teaser, and upgrade CTA', () => { + const onUpgrade = vi.fn() + const tree = render({ carrot: { ...baseCarrot, showProTeaser: true }, onUpgrade }) + + const serialized = serialize(tree) + expect(serialized).toContain('gamification.carrot.title'.toUpperCase()) + expect(serialized).toContain('gamification.carrot.toNextLevel:{"xp":300,"level":4}') + expect(serialized).toContain('gamification.carrot.proTeaser.title') + expect(serialized).toContain('gamification.carrot.proTeaser.achievements') + + const [button] = findUpgradeButton(tree) as Array<{ props: { onPress: () => void } }> + expect(button).toBeTruthy() + TestRenderer.act(() => button!.props.onPress()) + expect(onUpgrade).toHaveBeenCalledTimes(1) + }) + + it('omits the Pro teaser and CTA when showProTeaser is false', () => { + const tree = render({ carrot: { ...baseCarrot, showProTeaser: false }, onUpgrade: vi.fn() }) + + const serialized = serialize(tree) + expect(serialized).toContain('gamification.carrot.toNextLevel:{"xp":300,"level":4}') + expect(serialized).not.toContain('gamification.carrot.proTeaser.title') + expect(findUpgradeButton(tree)).toHaveLength(0) + }) +}) diff --git a/apps/mobile/__tests__/screens/upgrade-guard-redirects.test.tsx b/apps/mobile/__tests__/screens/upgrade-guard-redirects.test.tsx index cd614a86c..e6e6c1979 100644 --- a/apps/mobile/__tests__/screens/upgrade-guard-redirects.test.tsx +++ b/apps/mobile/__tests__/screens/upgrade-guard-redirects.test.tsx @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({ state: { profile: null as ReturnType | null, hasProAccess: false, + canViewGamification: false, isYearlyPro: false, }, })); @@ -49,6 +50,7 @@ vi.mock("@/hooks/use-profile", () => ({ isLoading: false, }), useHasProAccess: () => mocks.state.hasProAccess, + useCanViewGamification: () => mocks.state.canViewGamification, useIsYearlyPro: () => mocks.state.isYearlyPro, })); @@ -179,6 +181,7 @@ describe("mobile upgrade guard redirects", () => { isTrialActive: false, }); mocks.state.hasProAccess = false; + mocks.state.canViewGamification = false; mocks.state.isYearlyPro = false; }); diff --git a/apps/mobile/app/(tabs)/profile.tsx b/apps/mobile/app/(tabs)/profile.tsx index f3e3fe796..578fe151d 100644 --- a/apps/mobile/app/(tabs)/profile.tsx +++ b/apps/mobile/app/(tabs)/profile.tsx @@ -13,7 +13,8 @@ import { shouldRedirectProfileNavItem, type ProfileNavItem, } from '@orbit/shared/utils/profile-navigation' -import { CreditCard, Pencil, User as UserIcon } from 'lucide-react-native' +import { CreditCard, Lock, Pencil, User as UserIcon } from 'lucide-react-native' +import { deriveNextRewardCarrot } from '@orbit/shared/utils' import { useProfile, useTrialDaysLeft, @@ -31,10 +32,11 @@ import { ThemeToggle } from '@/components/ui/theme-toggle' import { StreakBadge } from '@/components/gamification/streak-badge' import { NotificationBell } from '@/components/navigation/notification-bell' import { useAppTheme } from '@/lib/use-app-theme' -import { createTokensV2 } from '@/lib/theme' +import { createTokensV2, tintFromPrimary } from '@/lib/theme' import { buildUpgradeHref } from '@/lib/upgrade-route' import { plural } from '@/lib/plural' import { ProfileNavIcon } from './profile/_components/profile-nav-icon' +import { NextRewardCarrot } from './profile/_components/next-reward-carrot' import { ProfileAccountActions } from './profile/_components/profile-account-actions' import { EditNameSheet } from './profile/_components/edit-name-sheet' import { FreshStartModal } from './profile/_components/fresh-start-modal' @@ -66,9 +68,13 @@ export default function ProfileScreen() { const trialDaysLeft = useTrialDaysLeft() const trialExpired = useTrialExpired() const logout = useAuthStore((s) => s.logout) - const { profile: gamificationProfile } = useGamificationProfile( - profile?.hasProAccess ?? false, - ) + const canViewGamification = profile?.canViewGamification ?? false + const { profile: gamificationProfile } = useGamificationProfile(canViewGamification) + const nextRewardCarrot = deriveNextRewardCarrot(gamificationProfile, canViewGamification) + const achievementsLocked = gamificationProfile?.achievementsLocked ?? false + const achievementsTileValue = achievementsLocked + ? gamificationProfile?.achievementsTotal ?? 0 + : gamificationProfile?.achievementsEarned ?? 0 const { isExporting, exportError, exportData } = useDataExport() const streak = profile?.currentStreak ?? 0 const styles = useMemo(() => createStyles(tokens), [tokens]) @@ -107,14 +113,14 @@ export default function ProfileScreen() { (item: ProfileNavItem): string => { if ( item.hintMode === 'gamificationProfile' && - profile?.hasProAccess && + canViewGamification && gamificationProfile ) { return `${t('gamification.profileCard.level', { level: gamificationProfile.level })} · ${t('gamification.profileCard.totalXp', { total: gamificationProfile.totalXp })}` } return t(item.hintKey) }, - [profile?.hasProAccess, gamificationProfile, t], + [canViewGamification, gamificationProfile, t], ) const [showResetModal, setShowResetModal] = useState(false) @@ -172,7 +178,7 @@ export default function ProfileScreen() { : t('common.proBadge') const identityLine = - profile?.hasProAccess && gamificationProfile + canViewGamification && gamificationProfile ? t('gamification.profileCard.level', { level: gamificationProfile.level }) : profile?.email @@ -287,14 +293,29 @@ export default function ProfileScreen() { > + {achievementsLocked ? ( + + + + ) : null} ) : null} + router.push(buildUpgradeHref('/profile'))} + /> + {t('profile.sections.account')} @@ -463,6 +484,16 @@ function createStyles(_tokens: Tokens) { transform: [{ scale: 0.99 }], opacity: 0.92, }, + lockBadge: { + position: 'absolute', + top: 10, + right: 10, + width: 22, + height: 22, + borderRadius: 999, + alignItems: 'center', + justifyContent: 'center', + }, groupWrap: { paddingHorizontal: 20, diff --git a/apps/mobile/app/(tabs)/profile/_components/next-reward-carrot.tsx b/apps/mobile/app/(tabs)/profile/_components/next-reward-carrot.tsx new file mode 100644 index 000000000..0a9b7c4e0 --- /dev/null +++ b/apps/mobile/app/(tabs)/profile/_components/next-reward-carrot.tsx @@ -0,0 +1,148 @@ +import { useMemo } from 'react' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { Lock, Sparkles } from 'lucide-react-native' +import { useTranslation } from 'react-i18next' +import type { NextRewardCarrotState } from '@orbit/shared/utils' +import { createTokensV2, primaryGlow, tintFromPrimary } from '@/lib/theme' +import { useAppTheme } from '@/lib/use-app-theme' + +interface NextRewardCarrotProps { + carrot: NextRewardCarrotState | null + onUpgrade: () => void +} + +/** "Next reward" upgrade nudge shown to free-unlocked users: next free level plus a Pro teaser. */ +export function NextRewardCarrot({ carrot, onUpgrade }: Readonly) { + const { t } = useTranslation() + const { currentScheme, currentTheme } = useAppTheme() + const tokens = useMemo( + () => createTokensV2(currentScheme, currentTheme), + [currentScheme, currentTheme], + ) + + if (!carrot) return null + + return ( + + + + + + {t('gamification.carrot.title').toUpperCase()} + + + + + {t('gamification.carrot.toNextLevel', { + xp: carrot.xpToNextLevel, + level: carrot.nextLevel, + })} + + + {carrot.showProTeaser ? ( + + + + + + {t('gamification.carrot.proTeaser.title')} + + + {t('gamification.carrot.proTeaser.achievements')} + + + + [ + styles.pill, + primaryGlow(tokens), + { + backgroundColor: pressed ? tokens.primaryPressed : tokens.primary, + transform: [{ scale: pressed ? 0.96 : 1 }], + }, + ]} + > + + {t('common.upgrade')} + + + + ) : null} + + + ) +} + +const styles = StyleSheet.create({ + wrap: { + paddingHorizontal: 20, + marginTop: 24, + }, + card: { + borderRadius: 18, + borderWidth: 1, + padding: 18, + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + marginBottom: 8, + }, + title: { + fontFamily: 'Rubik_500Medium', + fontSize: 12, + letterSpacing: 0.48, + }, + toNextLevel: { + fontFamily: 'Rubik_500Medium', + fontSize: 16, + lineHeight: 22, + }, + teaserRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + marginTop: 16, + }, + teaserLead: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + flexShrink: 1, + }, + teaserCopy: { + flexShrink: 1, + minWidth: 0, + }, + teaserTitle: { + fontFamily: 'Rubik_500Medium', + fontSize: 13, + }, + teaserSub: { + fontFamily: 'Rubik_400Regular', + fontSize: 13, + }, + pill: { + borderRadius: 999, + paddingVertical: 9, + paddingHorizontal: 16, + }, + pillLabel: { + fontFamily: 'Rubik_500Medium', + fontSize: 13, + }, +}) diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 6c94df427..9a46c445c 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -301,7 +301,8 @@ function GlobalOverlays({ const streakFreezeRef = useRef(null) const tourStarted = useRef(false) const hasProAccess = profile?.hasProAccess ?? false - const gamification = useGamificationProfile(hasProAccess) + const canViewGamification = profile?.canViewGamification ?? false + const gamification = useGamificationProfile(canViewGamification) useEffect(() => { if ( @@ -331,7 +332,7 @@ function GlobalOverlays({ {showSharedCelebrations ? : null} {showSharedCelebrations && hasProAccess ? : null} - {showSharedCelebrations && hasProAccess ? ( + {showSharedCelebrations && canViewGamification ? ( createStyles(tokens), [tokens]) const { profile: accountProfile, isLoading: profileLoading } = useProfile() - const hasProAccess = useHasProAccess() + const canViewGamification = useCanViewGamification() const { profile, isLoading, xpProgress, achievementsByCategory } = - useGamificationProfile(hasProAccess) + useGamificationProfile(canViewGamification) + const nextRewardCarrot = deriveNextRewardCarrot(profile, canViewGamification) useEffect(() => { - if (accountProfile && !hasProAccess) { + if (accountProfile && !canViewGamification) { router.replace('/upgrade') } - }, [accountProfile, hasProAccess, router]) + }, [accountProfile, canViewGamification, router]) const levelSubtitle = profile ? `${t('gamification.profileCard.level', { level: profile.level })} · ${profile.levelTitle}` @@ -75,7 +78,7 @@ export default function AchievementsScreen() { contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false} > - {!profileLoading && !hasProAccess ? ( + {!profileLoading && !canViewGamification ? ( ) : null} - {!profileLoading && hasProAccess && isLoading && !profile ? ( + {!profileLoading && canViewGamification && isLoading && !profile ? ( ) : null} - {hasProAccess && profile ? ( + {canViewGamification && profile ? ( <> ), )} + + router.push(buildUpgradeHref('/achievements'))} + /> ) : null} diff --git a/apps/mobile/app/streak-sections-freeze.tsx b/apps/mobile/app/streak-sections-freeze.tsx index bdce9074b..4fad9dd41 100644 --- a/apps/mobile/app/streak-sections-freeze.tsx +++ b/apps/mobile/app/streak-sections-freeze.tsx @@ -15,7 +15,7 @@ import { interface FreezeProgressCardProps { t: TranslationFn - isPro: boolean + unlocked: boolean streak: number streakFreezesAccumulated: number maxStreakFreezesAccumulated: number @@ -33,12 +33,12 @@ interface FreezeProgressCardProps { * protected-days list. Free users see a violet-tinted Pro gate card instead. */ export function FreezeProgressCard(props: Readonly) { - const { t, isPro } = props + const { t, unlocked } = props return ( {t('streakDisplay.freeze.title')} - {isPro ? ( + {unlocked ? ( ) : ( diff --git a/apps/mobile/app/streak.tsx b/apps/mobile/app/streak.tsx index 3c00aabd9..bbf3a8440 100644 --- a/apps/mobile/app/streak.tsx +++ b/apps/mobile/app/streak.tsx @@ -46,7 +46,7 @@ export default function StreakScreen() { const router = useRouter() const { profile } = useProfile() const streak = profile?.currentStreak ?? 0 - const isPro = profile?.hasProAccess ?? false + const canViewGamification = profile?.canViewGamification ?? false const { displayDate } = useDateFormat() const styles = useMemo(() => createStyles(tokens), [tokens]) const { @@ -57,7 +57,7 @@ export default function StreakScreen() { maxStreakFreezesAccumulated, freezesUsedThisMonth, maxFreezesPerMonth, - } = useStreakFreeze(profile, isPro) + } = useStreakFreeze(profile, canViewGamification) const freezeCelebrationRef = useRef(null) const wasFrozenTodayRef = useRef(isFrozenToday) @@ -187,7 +187,7 @@ export default function StreakScreen() { ({ + useTranslations: () => (key: string, params?: Record) => { + if (params) return `${key}:${JSON.stringify(params)}` + return key + }, +})) + +vi.mock('next/link', () => ({ + default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [k: string]: unknown }) => ( + {children} + ), +})) + +import { NextRewardCarrot } from '@/app/(app)/profile/_components/next-reward-carrot' + +describe('NextRewardCarrot', () => { + it('renders nothing when carrot is null', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('renders the next free level and XP-to-go with the Pro teaser and upgrade CTA', () => { + render( + , + ) + + expect(screen.getByText('gamification.carrot.title')).toBeInTheDocument() + expect( + screen.getByText('gamification.carrot.toNextLevel:{"xp":300,"level":4}'), + ).toBeInTheDocument() + expect(screen.getByText('gamification.carrot.proTeaser.title')).toBeInTheDocument() + expect(screen.getByText('gamification.carrot.proTeaser.achievements')).toBeInTheDocument() + expect(screen.getByText('common.upgrade').closest('a')).toHaveAttribute('href', '/upgrade') + }) + + it('omits the Pro teaser when showProTeaser is false', () => { + render( + , + ) + + expect(screen.getByText('gamification.carrot.toNextLevel:{"xp":300,"level":4}')).toBeInTheDocument() + expect(screen.queryByText('gamification.carrot.proTeaser.title')).not.toBeInTheDocument() + expect(screen.queryByText('common.upgrade')).not.toBeInTheDocument() + }) +}) diff --git a/apps/web/__tests__/hooks/use-gamification.test.ts b/apps/web/__tests__/hooks/use-gamification.test.ts index adee39f19..79872bb5c 100644 --- a/apps/web/__tests__/hooks/use-gamification.test.ts +++ b/apps/web/__tests__/hooks/use-gamification.test.ts @@ -85,6 +85,14 @@ function makeGamificationProfile(overrides: Partial = {}): currentStreak: 7, longestStreak: 14, lastActiveDate: '2025-01-15', + isPro: true, + achievementsLocked: false, + nextReward: { + nextLevel: 6, + nextLevelTitle: 'Pilot', + xpToNextLevel: 100, + proTeaser: null, + }, ...overrides, } } diff --git a/apps/web/__tests__/pages/achievements.test.tsx b/apps/web/__tests__/pages/achievements.test.tsx index 21afa057f..51105b09a 100644 --- a/apps/web/__tests__/pages/achievements.test.tsx +++ b/apps/web/__tests__/pages/achievements.test.tsx @@ -28,14 +28,14 @@ vi.mock('next/navigation', () => ({ })) let mockProfileLoading = false -let mockHasProAccess = true +let mockCanViewGamification = true vi.mock('@/hooks/use-profile', () => ({ useProfile: () => ({ profile: mockProfileLoading ? null : { id: 'u1', currentStreak: 5 }, isLoading: mockProfileLoading, }), - useHasProAccess: () => mockHasProAccess, + useCanViewGamification: () => mockCanViewGamification, })) let mockGamificationLoading = false @@ -68,7 +68,7 @@ describe('AchievementsPage', () => { beforeEach(() => { mockReplace.mockClear() mockProfileLoading = false - mockHasProAccess = true + mockCanViewGamification = true mockGamificationLoading = false mockGamificationProfile = null mockAchievementsByCategory = [] @@ -93,21 +93,21 @@ describe('AchievementsPage', () => { it('shows locked state when user does not have Pro access', () => { - mockHasProAccess = false + mockCanViewGamification = false render() expect(screen.getByText('gamification.page.lockedTitle')).toBeInTheDocument() expect(screen.getByText('gamification.page.lockedDescription')).toBeInTheDocument() }) it('shows upgrade button in locked state', () => { - mockHasProAccess = false + mockCanViewGamification = false render() const upgradeLink = screen.getByText('gamification.page.upgradeButton') expect(upgradeLink.closest('a')).toHaveAttribute('href', '/upgrade') }) it('does not show locked state for Pro users', () => { - mockHasProAccess = true + mockCanViewGamification = true render() expect(screen.queryByText('gamification.page.lockedTitle')).not.toBeInTheDocument() }) @@ -131,6 +131,7 @@ describe('AchievementsPage', () => { achievementsEarned: 3, achievementsTotal: 20, achievements: [], + isPro: true, } render() expect(document.body.textContent).toContain('gamification.profileCard.level:{"level":5}') @@ -146,6 +147,7 @@ describe('AchievementsPage', () => { achievementsEarned: 3, achievementsTotal: 20, achievements: [], + isPro: true, } render() expect(document.body.textContent).toContain('gamification.profileCard.xp') @@ -161,6 +163,7 @@ describe('AchievementsPage', () => { achievementsEarned: 3, achievementsTotal: 20, achievements: [], + isPro: true, } render() expect(document.body.textContent).toContain('gamification.profileCard.earned') @@ -175,6 +178,7 @@ describe('AchievementsPage', () => { achievementsEarned: 2, achievementsTotal: 3, achievements: [], + isPro: true, } mockAchievementsByCategory = [ { @@ -206,6 +210,7 @@ describe('AchievementsPage', () => { achievementsEarned: 1, achievementsTotal: 2, achievements: [], + isPro: true, } mockAchievementsByCategory = [ { @@ -232,6 +237,7 @@ describe('AchievementsPage', () => { achievementsEarned: 0, achievementsTotal: 1, achievements: [], + isPro: true, } mockAchievementsByCategory = [ { @@ -253,6 +259,7 @@ describe('AchievementsPage', () => { achievementsEarned: 1, achievementsTotal: 10, achievements: [], + isPro: true, } render() const progressBar = screen.getByRole('progressbar') @@ -261,7 +268,7 @@ describe('AchievementsPage', () => { it('does not show locked state while profile is still loading', () => { mockProfileLoading = true - mockHasProAccess = false + mockCanViewGamification = false render() expect(screen.queryByText('gamification.page.lockedTitle')).not.toBeInTheDocument() }) diff --git a/apps/web/__tests__/pages/streak.test.tsx b/apps/web/__tests__/pages/streak.test.tsx index 6b73c6c7f..dab5ee321 100644 --- a/apps/web/__tests__/pages/streak.test.tsx +++ b/apps/web/__tests__/pages/streak.test.tsx @@ -64,7 +64,7 @@ import StreakPage from '@/app/(app)/streak/page' describe('StreakPage', () => { beforeEach(() => { - mockProfile = { currentStreak: 10, longestStreak: 30, hasProAccess: true } + mockProfile = { currentStreak: 10, longestStreak: 30, hasProAccess: true, canViewGamification: true } mockStreakQuery = { isLoading: false, data: null } mockStreakInfo = { currentStreak: 10, @@ -119,7 +119,7 @@ describe('StreakPage', () => { }) it('renders no encouragement for streak 0', () => { - mockProfile = { currentStreak: 0, hasProAccess: true } + mockProfile = { currentStreak: 0, hasProAccess: true, canViewGamification: true } render() expect(document.body.textContent).not.toContain('streakDisplay.profile.encouragement1') expect(document.body.textContent).not.toContain('streakDisplay.profile.encouragement7') @@ -237,13 +237,13 @@ describe('StreakPage', () => { it('shows the steady tier in stats for streak >= 7', () => { - mockProfile = { currentStreak: 10, hasProAccess: true } + mockProfile = { currentStreak: 10, hasProAccess: true, canViewGamification: true } render() expect(screen.getByText('streakDisplay.detail.tierSteady')).toBeInTheDocument() }) it('shows the legendary tier in stats for streak >= 100', () => { - mockProfile = { currentStreak: 150, hasProAccess: true } + mockProfile = { currentStreak: 150, hasProAccess: true, canViewGamification: true } render() expect(screen.getByText('streakDisplay.detail.tierLegendary')).toBeInTheDocument() }) diff --git a/apps/web/app/(app)/achievements/page.tsx b/apps/web/app/(app)/achievements/page.tsx index 44564c3d2..5718ea102 100644 --- a/apps/web/app/(app)/achievements/page.tsx +++ b/apps/web/app/(app)/achievements/page.tsx @@ -3,11 +3,13 @@ import { useEffect } from 'react' import { useRouter } from 'next/navigation' import { useTranslations, useLocale } from 'next-intl' -import { useProfile, useHasProAccess } from '@/hooks/use-profile' +import { deriveNextRewardCarrot } from '@orbit/shared/utils' +import { useProfile, useCanViewGamification } from '@/hooks/use-profile' import { useGamificationProfile } from '@/hooks/use-gamification' import { AchievementCategorySection } from './_components/achievement-category-section' import { AchievementXpCard } from './_components/achievement-xp-card' import { AchievementsLockedState } from './_components/achievements-locked-state' +import { NextRewardCarrot } from '../profile/_components/next-reward-carrot' import { AppBar } from '@/components/ui/app-bar' import { ProBadge } from '@/components/ui/pro-badge' import { useGoBackOrFallback } from '@/hooks/use-go-back-or-fallback' @@ -19,19 +21,20 @@ export default function AchievementsPage() { const router = useRouter() const goBackOrFallback = useGoBackOrFallback() const { profile: accountProfile, isLoading: profileLoading } = useProfile() - const hasProAccess = useHasProAccess() + const canViewGamification = useCanViewGamification() const { profile, isLoading, xpProgress, achievementsByCategory, - } = useGamificationProfile(hasProAccess) + } = useGamificationProfile(canViewGamification) + const nextRewardCarrot = deriveNextRewardCarrot(profile, canViewGamification) useEffect(() => { - if (accountProfile && !hasProAccess) { + if (accountProfile && !canViewGamification) { router.replace('/upgrade') } - }, [accountProfile, hasProAccess, router]) + }, [accountProfile, canViewGamification, router]) const subtitle = profile ? `${t('gamification.profileCard.level', { level: profile.level })} · ${profile.levelTitle}` @@ -49,7 +52,7 @@ export default function AchievementsPage() { />
- {!profileLoading && !hasProAccess ? ( + {!profileLoading && !canViewGamification ? ( ) : ( <> @@ -76,6 +79,8 @@ export default function AchievementsPage() { t={t} /> ))} + + )} diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 10d1e3dd4..8ecebed70 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -70,6 +70,7 @@ function AppLayoutContent({ children }: Readonly<{ children: React.ReactNode }>) const { profile } = useProfile() useTimezoneAutoSync(profile) const hasProAccess = profile?.hasProAccess ?? false + const canViewGamification = profile?.canViewGamification ?? false const totalHabitCount = useTotalHabitCount() useEffect(() => { @@ -206,6 +207,7 @@ function AppLayoutContent({ children }: Readonly<{ children: React.ReactNode }>) ) function GlobalOverlays({ profile, hasProAccess, + canViewGamification, streakFreezeRef, showCalendarPrompt, onCalendarPromptOpenChange, @@ -249,6 +252,7 @@ function GlobalOverlays({ }: Readonly<{ profile: ReturnType['profile'] hasProAccess: boolean + canViewGamification: boolean streakFreezeRef: React.RefObject<{ show: () => void } | null> showCalendarPrompt: boolean onCalendarPromptOpenChange: (open: boolean) => void @@ -256,7 +260,7 @@ function GlobalOverlays({ onDismissCalendarPrompt: () => void }>) { const t = useTranslations() - const gamification = useGamificationProfile(hasProAccess) + const gamification = useGamificationProfile(canViewGamification) return (
@@ -269,7 +273,7 @@ function GlobalOverlays({ {hasProAccess && } - {hasProAccess && ( + {canViewGamification && ( ) { + const t = useTranslations() + + if (!carrot) return null + + return ( +
+
+
+
+ + + {t('gamification.carrot.toNextLevel', { + xp: carrot.xpToNextLevel, + level: carrot.nextLevel, + })} + + + {carrot.showProTeaser && ( +
+ + + + {t('common.upgrade')} + +
+ )} +
+
+ ) +} diff --git a/apps/web/app/(app)/profile/_components/profile-stat-tiles.tsx b/apps/web/app/(app)/profile/_components/profile-stat-tiles.tsx index 4f2016764..4fed1fa41 100644 --- a/apps/web/app/(app)/profile/_components/profile-stat-tiles.tsx +++ b/apps/web/app/(app)/profile/_components/profile-stat-tiles.tsx @@ -1,5 +1,6 @@ 'use client' +import { Lock } from 'lucide-react' import { useTranslations } from 'next-intl' import { StatTile } from '@/components/ui/stat-tile' import { plural } from '@/lib/plural' @@ -21,7 +22,7 @@ function StatTileButton({ data-tour={dataTour} aria-label={ariaLabel} onClick={onClick} - className="flex flex-1 cursor-pointer appearance-none rounded-[18px] border-0 bg-transparent p-0 text-left transition-transform duration-[var(--dur-fast)] ease-[var(--ease-standard)] hover:-translate-y-px active:translate-y-0 active:scale-[0.99]" + className="relative flex flex-1 cursor-pointer appearance-none rounded-[18px] border-0 bg-transparent p-0 text-left transition-transform duration-[var(--dur-fast)] ease-[var(--ease-standard)] hover:-translate-y-px active:translate-y-0 active:scale-[0.99]" > {children} @@ -30,7 +31,8 @@ function StatTileButton({ interface ProfileStatTilesProps { streak: number - achievementsEarned: number + achievementsValue: number + achievementsLocked: boolean showAchievements: boolean achievementsDataTour?: string onStreakClick: () => void @@ -39,7 +41,8 @@ interface ProfileStatTilesProps { export function ProfileStatTiles({ streak, - achievementsEarned, + achievementsValue, + achievementsLocked, showAchievements, achievementsDataTour, onStreakClick, @@ -68,9 +71,24 @@ export function ProfileStatTiles({ > + {achievementsLocked && ( + + )} )}
diff --git a/apps/web/app/(app)/profile/page.tsx b/apps/web/app/(app)/profile/page.tsx index 068044620..83c0d4621 100644 --- a/apps/web/app/(app)/profile/page.tsx +++ b/apps/web/app/(app)/profile/page.tsx @@ -16,11 +16,13 @@ import { useTrialExpired, } from '@/hooks/use-profile' import { useAuthStore } from '@/stores/auth-store' +import { deriveNextRewardCarrot } from '@orbit/shared/utils' import { useGamificationProfile } from '@/hooks/use-gamification' import { SectionLabel } from '@/components/ui/section-label' import { SubscriptionCard } from './_components/subscription-card' import { ProfileIdentityHeader } from './_components/profile-identity-header' import { ProfileStatTiles } from './_components/profile-stat-tiles' +import { NextRewardCarrot } from './_components/next-reward-carrot' import { ProfileNavSections } from './_components/profile-nav-sections' import { ProfileAccountActions } from './_components/profile-account-actions' import { ProfileHeaderBar } from './_components/profile-header-bar' @@ -37,9 +39,13 @@ export default function ProfilePage() { const trialExpired = useTrialExpired() const logout = useAuthStore((s) => s.logout) const { isExporting, exportError, exportData } = useDataExport() - const { profile: gamificationProfile } = useGamificationProfile( - profile?.hasProAccess ?? false, - ) + const canViewGamification = profile?.canViewGamification ?? false + const { profile: gamificationProfile } = useGamificationProfile(canViewGamification) + const nextRewardCarrot = deriveNextRewardCarrot(gamificationProfile, canViewGamification) + const achievementsLocked = gamificationProfile?.achievementsLocked ?? false + const achievementsTileValue = achievementsLocked + ? gamificationProfile?.achievementsTotal ?? 0 + : gamificationProfile?.achievementsEarned ?? 0 const streak = profile?.currentStreak ?? 0 const accountNavItems = PROFILE_NAV_ITEMS.filter( (item) => item.section === 'account', @@ -84,7 +90,7 @@ export default function ProfilePage() { : t('common.proBadge') const identityLine = - profile?.hasProAccess && gamificationProfile + canViewGamification && gamificationProfile ? t('gamification.profileCard.level', { level: gamificationProfile.level }) : profile?.email @@ -105,7 +111,8 @@ export default function ProfilePage() { + + ) { - const { t, isPro } = props + const { t, unlocked } = props return (
{t('streakDisplay.freeze.title')} - {isPro ? : } + {unlocked ? : }
) } diff --git a/apps/web/app/(app)/streak/page.tsx b/apps/web/app/(app)/streak/page.tsx index a7fca586e..2072fc3db 100644 --- a/apps/web/app/(app)/streak/page.tsx +++ b/apps/web/app/(app)/streak/page.tsx @@ -20,7 +20,7 @@ export default function StreakPage() { const { displayDate } = useDateFormat() const { profile } = useProfile() const streak = profile?.currentStreak ?? 0 - const isPro = profile?.hasProAccess ?? false + const canViewGamification = profile?.canViewGamification ?? false const { streakQuery, streakInfo, @@ -29,7 +29,7 @@ export default function StreakPage() { maxStreakFreezesAccumulated, freezesUsedThisMonth, maxFreezesPerMonth, - } = useStreakFreeze(profile, isPro) + } = useStreakFreeze(profile, canViewGamification) const freezeCelebrationRef = useRef(null) const wasFrozenTodayRef = useRef(isFrozenToday) @@ -99,7 +99,7 @@ export default function StreakPage() { = {}): Profile { googleCalendarAutoSyncEnabled: false, googleCalendarAutoSyncStatus: 'Idle', googleCalendarLastSyncedAt: null, + canViewGamification: false, ...overrides, } } @@ -172,6 +173,14 @@ export function createMockGamificationProfile( currentStreak: 7, longestStreak: 14, lastActiveDate: '2025-01-15', + isPro: true, + achievementsLocked: false, + nextReward: { + nextLevel: 4, + nextLevelTitle: 'Navigator', + xpToNextLevel: 300, + proTeaser: null, + }, ...overrides, } } diff --git a/packages/shared/src/__tests__/gamification-schema.test.ts b/packages/shared/src/__tests__/gamification-schema.test.ts new file mode 100644 index 000000000..25c7455a5 --- /dev/null +++ b/packages/shared/src/__tests__/gamification-schema.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { + gamificationProfileSchema, + nextRewardCarrotSchema, + recapResponseSchema, +} from '../types/gamification' +import { profileSchema } from '../types/profile' +import { createMockGamificationProfile, createMockProfile } from './factories' + +describe('nextRewardCarrotSchema', () => { + it('parses a Pro carrot with a null teaser', () => { + const parsed = nextRewardCarrotSchema.parse({ + nextLevel: 11, + nextLevelTitle: 'Legend', + xpToNextLevel: 2100, + proTeaser: null, + }) + expect(parsed.proTeaser).toBeNull() + }) + + it('parses a free carrot with a locked achievements teaser', () => { + const parsed = nextRewardCarrotSchema.parse({ + nextLevel: 3, + nextLevelTitle: 'Orbiter', + xpToNextLevel: 50, + proTeaser: { kind: 'achievements', locked: true }, + }) + expect(parsed.proTeaser).toEqual({ kind: 'achievements', locked: true }) + }) +}) + +describe('gamificationProfileSchema', () => { + it('round-trips a profile carrying the additive gamification fields', () => { + const profile = createMockGamificationProfile({ + isPro: false, + achievementsLocked: true, + achievements: [], + nextReward: { + nextLevel: 4, + nextLevelTitle: 'Navigator', + xpToNextLevel: 300, + proTeaser: { kind: 'achievements', locked: true }, + }, + }) + + const parsed = gamificationProfileSchema.parse(profile) + + expect(parsed.isPro).toBe(false) + expect(parsed.achievementsLocked).toBe(true) + expect(parsed.nextReward.nextLevel).toBe(4) + expect(parsed.nextReward.proTeaser?.locked).toBe(true) + }) +}) + +describe('profileSchema.canViewGamification', () => { + it('parses the additive flag', () => { + const parsed = profileSchema.parse(createMockProfile({ canViewGamification: true })) + expect(parsed.canViewGamification).toBe(true) + }) + + it('is optional so older API payloads still parse (rollout safety)', () => { + const { canViewGamification: _omit, ...withoutFlag } = createMockProfile() + const parsed = profileSchema.parse(withoutFlag) + expect(parsed.canViewGamification).toBeUndefined() + }) +}) + +describe('recapResponseSchema', () => { + it('parses a metrics-only recap with a share deep link', () => { + const parsed = recapResponseSchema.parse({ + period: 'week', + shareDeepLink: 'https://app.useorbit.org/r/ABCD2345?recap=week', + metrics: { + completionRate: 80, + totalCompletions: 12, + totalScheduled: 15, + activeDays: 5, + periodDays: 7, + currentStreak: 7, + bestStreak: 20, + badHabitSlips: 0, + weeklyConsistency: [100, 80, 60, 100, 0, 0, 0], + topHabits: [ + { name: 'Read', emoji: '📚', completionRate: 100, completedCount: 7, scheduledCount: 7 }, + ], + needsAttention: [], + }, + }) + + expect(parsed.period).toBe('week') + expect(parsed.shareDeepLink).toContain('?recap=week') + expect(parsed.metrics.topHabits).toHaveLength(1) + }) + + it('rejects a non-whitelisted period', () => { + expect(() => + recapResponseSchema.parse({ + period: 'quarter', + shareDeepLink: 'https://app.useorbit.org/r/ABCD2345?recap=quarter', + metrics: { + completionRate: 0, + totalCompletions: 0, + totalScheduled: 0, + activeDays: 0, + periodDays: 90, + currentStreak: 0, + bestStreak: 0, + badHabitSlips: 0, + weeklyConsistency: [0, 0, 0, 0, 0, 0, 0], + topHabits: [], + needsAttention: [], + }, + }), + ).toThrow() + }) +}) diff --git a/packages/shared/src/__tests__/gamification-selectors.test.ts b/packages/shared/src/__tests__/gamification-selectors.test.ts index 6fae265d4..bdff41cfd 100644 --- a/packages/shared/src/__tests__/gamification-selectors.test.ts +++ b/packages/shared/src/__tests__/gamification-selectors.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { calculateXpProgress, deriveGamificationProfileState, + deriveNextRewardCarrot, deriveStreakFreezeState, detectGamificationMilestones, getAchievementsByCategory, @@ -76,6 +77,14 @@ function makeProfile(overrides: Partial = {}): Gamification currentStreak: 7, longestStreak: 14, lastActiveDate: '2025-01-15', + isPro: true, + achievementsLocked: false, + nextReward: { + nextLevel: 6, + nextLevelTitle: 'Pilot', + xpToNextLevel: 100, + proTeaser: null, + }, ...overrides, } } @@ -222,3 +231,40 @@ describe('gamification-selectors', () => { expect(state.canEarnMore).toBe(false) }) }) + +describe('deriveNextRewardCarrot', () => { + it('returns carrot data for a free-unlocked user with a locked teaser', () => { + const profile = makeProfile({ + isPro: false, + nextReward: { + nextLevel: 6, + nextLevelTitle: 'Pilot', + xpToNextLevel: 100, + proTeaser: { kind: 'achievements', locked: true }, + }, + }) + + const carrot = deriveNextRewardCarrot(profile, true) + + expect(carrot).toEqual({ + nextLevel: 6, + nextLevelTitle: 'Pilot', + xpToNextLevel: 100, + showProTeaser: true, + }) + }) + + it('returns null for a Pro user', () => { + const profile = makeProfile({ isPro: true, nextReward: { nextLevel: 6, nextLevelTitle: 'Pilot', xpToNextLevel: 100, proTeaser: null } }) + expect(deriveNextRewardCarrot(profile, true)).toBeNull() + }) + + it('returns null when gamification is not viewable', () => { + const profile = makeProfile({ isPro: false }) + expect(deriveNextRewardCarrot(profile, false)).toBeNull() + }) + + it('returns null for a missing profile', () => { + expect(deriveNextRewardCarrot(null, true)).toBeNull() + }) +}) diff --git a/packages/shared/src/__tests__/referral.test.ts b/packages/shared/src/__tests__/referral.test.ts index 57c1fc5c2..59120efa9 100644 --- a/packages/shared/src/__tests__/referral.test.ts +++ b/packages/shared/src/__tests__/referral.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildReferralUrl } from '../utils/referral' +import { buildRecapShareUrl, buildReferralUrl } from '../utils/referral' describe('buildReferralUrl', () => { it('builds the hosted referral url for a code', () => { @@ -11,3 +11,15 @@ describe('buildReferralUrl', () => { expect(buildReferralUrl(undefined)).toBe('') }) }) + +describe('buildRecapShareUrl', () => { + it('embeds the referral code and recap period', () => { + expect(buildRecapShareUrl('XYZ789', 'week')).toBe('https://app.useorbit.org/r/XYZ789?recap=week') + expect(buildRecapShareUrl('XYZ789', 'year')).toBe('https://app.useorbit.org/r/XYZ789?recap=year') + }) + + it('returns an empty string when the code is missing', () => { + expect(buildRecapShareUrl(null, 'week')).toBe('') + expect(buildRecapShareUrl(undefined, 'month')).toBe('') + }) +}) diff --git a/packages/shared/src/api/endpoints.ts b/packages/shared/src/api/endpoints.ts index 1bb1036fd..c3e37c968 100644 --- a/packages/shared/src/api/endpoints.ts +++ b/packages/shared/src/api/endpoints.ts @@ -103,6 +103,7 @@ export const API = { profile: '/api/gamification/profile', achievements: '/api/gamification/achievements', streak: '/api/gamification/streak', + recap: '/api/gamification/recap', }, chat: { diff --git a/packages/shared/src/i18n/en.json b/packages/shared/src/i18n/en.json index 7f4953a52..5406b4f12 100644 --- a/packages/shared/src/i18n/en.json +++ b/packages/shared/src/i18n/en.json @@ -1733,6 +1733,14 @@ "lockedDescription": "Upgrade to Pro to track your progress, earn XP, and unlock achievements.", "upgradeButton": "Upgrade to Pro" }, + "carrot": { + "title": "Next reward", + "toNextLevel": "{xp} XP to Level {level}", + "proTeaser": { + "title": "Unlock with Pro", + "achievements": "Achievements & badges" + } + }, "categories": { "GettingStarted": "Getting Started", "Consistency": "Consistency", @@ -1778,6 +1786,14 @@ "name": "Year of Discipline", "description": "Achieve a 365-day streak" }, + "half_year_hero": { + "name": "Half-Year Hero", + "description": "Achieve a 180-day streak" + }, + "streak_titan": { + "name": "Streak Titan", + "description": "Achieve a 500-day streak" + }, "getting_momentum": { "name": "Getting Momentum", "description": "Complete 10 habits total" @@ -1841,6 +1857,10 @@ "bad_habit_breaker": { "name": "Bad Habit Breaker", "description": "Achieve a 30-day streak on a bad habit" + }, + "first_cheer": { + "name": "Good Vibes", + "description": "Send or receive your first cheer" } }, "toast": { diff --git a/packages/shared/src/i18n/pt-BR.json b/packages/shared/src/i18n/pt-BR.json index 6d0859f33..a991aeb2e 100644 --- a/packages/shared/src/i18n/pt-BR.json +++ b/packages/shared/src/i18n/pt-BR.json @@ -1733,6 +1733,14 @@ "lockedDescription": "Assine o Pro para acompanhar seu progresso, ganhar XP e desbloquear conquistas.", "upgradeButton": "Assinar Pro" }, + "carrot": { + "title": "PrĂłxima recompensa", + "toNextLevel": "{xp} XP para o NĂ­vel {level}", + "proTeaser": { + "title": "Desbloqueie com o Pro", + "achievements": "Conquistas e medalhas" + } + }, "categories": { "GettingStarted": "Primeiros Passos", "Consistency": "ConsistĂȘncia", @@ -1778,6 +1786,14 @@ "name": "Ano de Disciplina", "description": "Alcance uma sequĂȘncia de 365 dias" }, + "half_year_hero": { + "name": "HerĂłi do Semestre", + "description": "Alcance uma sequĂȘncia de 180 dias" + }, + "streak_titan": { + "name": "TitĂŁ da SequĂȘncia", + "description": "Alcance uma sequĂȘncia de 500 dias" + }, "getting_momentum": { "name": "Ganhando Ritmo", "description": "Complete 10 hĂĄbitos no total" @@ -1841,6 +1857,10 @@ "bad_habit_breaker": { "name": "Quebrador de Maus HĂĄbitos", "description": "Alcance 30 dias sem um mau hĂĄbito" + }, + "first_cheer": { + "name": "Boas Energias", + "description": "Envie ou receba seu primeiro incentivo" } }, "toast": { diff --git a/packages/shared/src/query/keys.ts b/packages/shared/src/query/keys.ts index 4bc253c55..524529ee3 100644 --- a/packages/shared/src/query/keys.ts +++ b/packages/shared/src/query/keys.ts @@ -52,6 +52,7 @@ export const gamificationKeys = { profile: () => [...gamificationKeys.all, 'profile'] as const, achievements: () => [...gamificationKeys.all, 'achievements'] as const, streak: () => [...gamificationKeys.all, 'streak'] as const, + recap: (period: string) => [...gamificationKeys.all, 'recap', period] as const, } export const subscriptionKeys = { diff --git a/packages/shared/src/types/gamification.ts b/packages/shared/src/types/gamification.ts index 4fe4856b2..a7577d39d 100644 --- a/packages/shared/src/types/gamification.ts +++ b/packages/shared/src/types/gamification.ts @@ -30,6 +30,17 @@ const userAchievementSchema = z.object({ earnedAtUtc: z.string(), }) +export const nextRewardCarrotSchema = z.object({ + nextLevel: z.number(), + nextLevelTitle: z.string(), + xpToNextLevel: z.number(), + proTeaser: z + .object({ kind: z.enum(['achievements']), locked: z.boolean() }) + .nullable(), +}) + +export type NextRewardCarrot = z.infer + export const gamificationProfileSchema = z.object({ totalXp: z.number(), level: z.number(), @@ -44,10 +55,44 @@ export const gamificationProfileSchema = z.object({ currentStreak: z.number(), longestStreak: z.number(), lastActiveDate: z.string().nullable(), + isPro: z.boolean(), + achievementsLocked: z.boolean(), + nextReward: nextRewardCarrotSchema, }) export type GamificationProfile = z.infer +export const retrospectiveHabitStatSchema = z.object({ + name: z.string(), + emoji: z.string().nullable(), + completionRate: z.number(), + completedCount: z.number(), + scheduledCount: z.number(), + isOneTime: z.boolean().optional(), +}) + +export const retrospectiveMetricsSchema = z.object({ + completionRate: z.number(), + totalCompletions: z.number(), + totalScheduled: z.number(), + activeDays: z.number(), + periodDays: z.number(), + currentStreak: z.number(), + bestStreak: z.number(), + badHabitSlips: z.number(), + weeklyConsistency: z.array(z.number()), + topHabits: z.array(retrospectiveHabitStatSchema), + needsAttention: z.array(retrospectiveHabitStatSchema), +}) + +export const recapResponseSchema = z.object({ + period: z.enum(['week', 'month', 'year']), + metrics: retrospectiveMetricsSchema, + shareDeepLink: z.string(), +}) + +export type Recap = z.infer + export const streakInfoSchema = z.object({ currentStreak: z.number(), longestStreak: z.number(), diff --git a/packages/shared/src/types/profile.ts b/packages/shared/src/types/profile.ts index d101c9edb..7b3afd9fc 100644 --- a/packages/shared/src/types/profile.ts +++ b/packages/shared/src/types/profile.ts @@ -52,6 +52,7 @@ export const profileSchema = z.object({ googleCalendarAutoSyncEnabled: z.boolean(), googleCalendarAutoSyncStatus: calendarAutoSyncStatusSchema, googleCalendarLastSyncedAt: z.string().nullable(), + canViewGamification: z.boolean().optional(), }) export type Profile = z.infer diff --git a/packages/shared/src/utils/gamification-selectors.ts b/packages/shared/src/utils/gamification-selectors.ts index 9b2e72180..0108688f0 100644 --- a/packages/shared/src/utils/gamification-selectors.ts +++ b/packages/shared/src/utils/gamification-selectors.ts @@ -154,6 +154,33 @@ export function deriveStreakFreezeState( } } +export interface NextRewardCarrotState { + nextLevel: number + nextLevelTitle: string + xpToNextLevel: number + showProTeaser: boolean +} + +/** + * Display data for the "next reward" carrot, or null when it should not render. The carrot + * is shown only to free-unlocked users (gamification visible but not Pro); Pro users and + * gamification-locked users get null. + */ +export function deriveNextRewardCarrot( + profile: Pick | null | undefined, + canViewGamification: boolean, +): NextRewardCarrotState | null { + if (!profile || !canViewGamification || profile.isPro) return null + + const { nextReward } = profile + return { + nextLevel: nextReward.nextLevel, + nextLevelTitle: nextReward.nextLevelTitle, + xpToNextLevel: nextReward.xpToNextLevel, + showProTeaser: nextReward.proTeaser?.locked === true, + } +} + export function deriveGamificationProfileState( profile: GamificationProfile | null | undefined, ): GamificationProfileDerivedState { diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 5da57b710..ad8882cc7 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -153,7 +153,7 @@ export { getClientTimeZone, } from './client-context' export { isVersionBelow } from './version' -export { buildReferralUrl } from './referral' +export { buildReferralUrl, buildRecapShareUrl } from './referral' export { getOnboardingDisplayStep, getOnboardingDisplayTotal, @@ -262,6 +262,7 @@ export { export { calculateXpProgress, deriveGamificationProfileState, + deriveNextRewardCarrot, detectGamificationMilestones, deriveStreakFreezeState, getAchievementsByCategory, @@ -272,6 +273,7 @@ export { export type { GamificationMilestoneState, GamificationProfileDerivedState, + NextRewardCarrotState, StreakFreezeDerivedState, StreakFreezeFallback, } from './gamification-selectors' diff --git a/packages/shared/src/utils/referral.ts b/packages/shared/src/utils/referral.ts index 31161b8b2..0c5845b20 100644 --- a/packages/shared/src/utils/referral.ts +++ b/packages/shared/src/utils/referral.ts @@ -5,3 +5,14 @@ export function buildReferralUrl(code: string | null | undefined): string { return `https://app.useorbit.org/r/${code}` } + +export function buildRecapShareUrl( + code: string | null | undefined, + period: string, +): string { + if (!code) { + return '' + } + + return `https://app.useorbit.org/r/${code}?recap=${period}` +} diff --git a/packages/shared/src/utils/retrospective.ts b/packages/shared/src/utils/retrospective.ts index 43d581798..07a0581e7 100644 --- a/packages/shared/src/utils/retrospective.ts +++ b/packages/shared/src/utils/retrospective.ts @@ -1,4 +1,9 @@ +import { z } from 'zod' import { API } from '../api' +import { + retrospectiveHabitStatSchema, + retrospectiveMetricsSchema, +} from '../types/gamification' export const RETROSPECTIVE_PERIODS = [ 'week', @@ -10,28 +15,9 @@ export const RETROSPECTIVE_PERIODS = [ export type RetrospectivePeriod = 'week' | 'month' | 'quarter' | 'semester' | 'year' -export interface RetrospectiveHabitStat { - name: string - emoji: string | null - completionRate: number - completedCount: number - scheduledCount: number - isOneTime?: boolean -} +export type RetrospectiveHabitStat = z.infer -export interface RetrospectiveMetrics { - completionRate: number - totalCompletions: number - totalScheduled: number - activeDays: number - periodDays: number - currentStreak: number - bestStreak: number - badHabitSlips: number - weeklyConsistency: number[] - topHabits: RetrospectiveHabitStat[] - needsAttention: RetrospectiveHabitStat[] -} +export type RetrospectiveMetrics = z.infer export interface RetrospectiveNarrative { highlights: string From c90888c4fe3d10c87cc4b83799aa6f7059539134 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sat, 27 Jun 2026 00:52:09 -0300 Subject: [PATCH 2/6] feat(astra): custom Astra avatar identity on web + mobile (#184) New AstraMark/AstraAvatar SVG primitive (violet core + satellite on orbit ring, token-driven, reduced-motion-gated); swapped into nav tab, chat header (additive AppBar titleIcon), meet-astra. Satisfies #185. i18n chat.astraAvatarLabel en+pt-BR. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../onboarding/onboarding-meet-astra.test.tsx | 27 ++++ .../components/ui/astra-avatar.test.tsx | 68 +++++++++ apps/mobile/app/chat.tsx | 4 +- .../components/navigation/bottom-tab-bar.tsx | 4 +- .../onboarding/onboarding-meet-astra.tsx | 51 ++----- apps/mobile/components/ui/app-bar.tsx | 28 +++- apps/mobile/components/ui/astra-avatar.tsx | 132 ++++++++++++++++++ .../onboarding/onboarding-meet-astra.test.tsx | 26 ++++ .../components/ui/astra-avatar.test.tsx | 61 ++++++++ apps/web/app/(chat)/chat/page.tsx | 4 +- apps/web/app/globals.css | 9 ++ .../components/navigation/bottom-tab-bar.tsx | 8 +- .../onboarding/onboarding-meet-astra.tsx | 36 ++--- apps/web/components/ui/app-bar.tsx | 34 +++-- apps/web/components/ui/astra-avatar.tsx | 96 +++++++++++++ packages/shared/src/i18n/en.json | 1 + packages/shared/src/i18n/pt-BR.json | 1 + 17 files changed, 498 insertions(+), 92 deletions(-) create mode 100644 apps/mobile/__tests__/components/onboarding/onboarding-meet-astra.test.tsx create mode 100644 apps/mobile/__tests__/components/ui/astra-avatar.test.tsx create mode 100644 apps/mobile/components/ui/astra-avatar.tsx create mode 100644 apps/web/__tests__/components/onboarding/onboarding-meet-astra.test.tsx create mode 100644 apps/web/__tests__/components/ui/astra-avatar.test.tsx create mode 100644 apps/web/components/ui/astra-avatar.tsx diff --git a/apps/mobile/__tests__/components/onboarding/onboarding-meet-astra.test.tsx b/apps/mobile/__tests__/components/onboarding/onboarding-meet-astra.test.tsx new file mode 100644 index 000000000..398d0395b --- /dev/null +++ b/apps/mobile/__tests__/components/onboarding/onboarding-meet-astra.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +import { OnboardingMeetAstra } from '@/components/onboarding/onboarding-meet-astra' + +const TestRenderer = require('react-test-renderer') + +describe('OnboardingMeetAstra (mobile)', () => { + it('renders Astra avatar with an accessibility label', async () => { + let tree: any + await TestRenderer.act(async () => { + tree = TestRenderer.create() + }) + expect(tree.root.findByProps({ accessibilityLabel: 'chat.astraAvatarLabel' })).toBeDefined() + }) + + it('renders the orbital mark for the hero and the bubble', async () => { + let tree: any + await TestRenderer.act(async () => { + tree = TestRenderer.create() + }) + expect(tree.root.findAllByType('Svg').length).toBeGreaterThanOrEqual(2) + }) +}) diff --git a/apps/mobile/__tests__/components/ui/astra-avatar.test.tsx b/apps/mobile/__tests__/components/ui/astra-avatar.test.tsx new file mode 100644 index 000000000..d80056a44 --- /dev/null +++ b/apps/mobile/__tests__/components/ui/astra-avatar.test.tsx @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' + +import { AstraMark, AstraAvatar } from '@/components/ui/astra-avatar' + +const TestRenderer = require('react-test-renderer') + +describe('AstraMark (mobile)', () => { + it('renders the orbital svg with default size 24', async () => { + let tree: any + await TestRenderer.act(async () => { + tree = TestRenderer.create() + }) + const svg = tree.root.findByType('Svg') + expect(svg.props.width).toBe(24) + expect(svg.props.height).toBe(24) + expect(tree.root.findAllByType('Circle')).toHaveLength(3) + expect(tree.root.findAllByType('Path')).toHaveLength(1) + }) + + it('respects the size prop', async () => { + let tree: any + await TestRenderer.act(async () => { + tree = TestRenderer.create() + }) + expect(tree.root.findByType('Svg').props.width).toBe(40) + }) + + it('keeps the ring strokeable and unfilled in the duotone default', async () => { + let tree: any + await TestRenderer.act(async () => { + tree = TestRenderer.create() + }) + const ring = tree.root + .findAllByType('Circle') + .find((node: { props: { stroke?: string; fill?: string } }) => node.props.stroke != null) + expect(ring).toBeDefined() + expect(ring.props.fill).toBeUndefined() + }) + + it('renders monochrome when a color is given', async () => { + let tree: any + await TestRenderer.act(async () => { + tree = TestRenderer.create() + }) + const ring = tree.root + .findAllByType('Circle') + .find((node: { props: { stroke?: string } }) => node.props.stroke != null) + expect(ring.props.stroke).toBe('#abcdef') + }) +}) + +describe('AstraAvatar (mobile)', () => { + it('renders the mark on a disc', async () => { + let tree: any + await TestRenderer.act(async () => { + tree = TestRenderer.create() + }) + expect(tree.root.findByType('Svg')).toBeDefined() + }) + + it('exposes an accessibility label when provided', async () => { + let tree: any + await TestRenderer.act(async () => { + tree = TestRenderer.create() + }) + expect(tree.root.findByProps({ accessibilityLabel: 'Astra avatar' })).toBeDefined() + }) +}) diff --git a/apps/mobile/app/chat.tsx b/apps/mobile/app/chat.tsx index 3933031c8..013256b53 100644 --- a/apps/mobile/app/chat.tsx +++ b/apps/mobile/app/chat.tsx @@ -9,7 +9,6 @@ import { import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context"; import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; -import { Orbit } from "lucide-react-native"; import type { ChatMessage } from "@orbit/shared/types"; import { CHAT_GOAL_ACTION_TYPES } from "@orbit/shared/hooks"; import { habitDetailToNormalized } from "@orbit/shared/utils"; @@ -24,6 +23,7 @@ import { ChatEmptyState } from "@/components/chat/chat-empty-state"; import { GoalDetailDrawer } from "@/components/goals/goal-detail-drawer"; import { HabitDetailDrawer } from "@/components/habits/habit-detail-drawer"; import { AppBar } from "@/components/ui/app-bar"; +import { AstraMark } from "@/components/ui/astra-avatar"; import { GradientTop } from "@/components/ui/gradient-top"; import { KeyboardAwareFlatList } from "@/components/ui/keyboard-aware-scroll-view"; import { createStyles } from "@/app/chat.styles"; @@ -186,7 +186,7 @@ export default function ChatScreen() { back onBack={() => goBackOrFallback("/")} backLabel={t("common.goBack")} - LeadingIcon={Orbit} + titleIcon={} title={t("chat.title")} /> diff --git a/apps/mobile/components/navigation/bottom-tab-bar.tsx b/apps/mobile/components/navigation/bottom-tab-bar.tsx index aa9f7d942..b973a387a 100644 --- a/apps/mobile/components/navigation/bottom-tab-bar.tsx +++ b/apps/mobile/components/navigation/bottom-tab-bar.tsx @@ -4,11 +4,11 @@ import { CalendarDays, Home, type LucideProps, - MessageCircle, Plus, User, } from 'lucide-react-native' import { useTranslation } from 'react-i18next' +import { AstraMark } from '@/components/ui/astra-avatar' import { createTokensV2, primaryGlow } from '@/lib/theme' import { useAppTheme } from '@/lib/use-app-theme' @@ -35,7 +35,7 @@ interface TabDef { const TABS: readonly TabDef[] = [ { id: 'today', labelKey: 'nav.home', Icon: Home }, - { id: 'chat', labelKey: 'nav.astra', Icon: MessageCircle }, + { id: 'chat', labelKey: 'nav.astra', Icon: AstraMark }, { id: 'calendar', labelKey: 'nav.calendar', Icon: CalendarDays }, { id: 'profile', labelKey: 'nav.you', Icon: User }, ] diff --git a/apps/mobile/components/onboarding/onboarding-meet-astra.tsx b/apps/mobile/components/onboarding/onboarding-meet-astra.tsx index a915b12ca..c3bfbfaec 100644 --- a/apps/mobile/components/onboarding/onboarding-meet-astra.tsx +++ b/apps/mobile/components/onboarding/onboarding-meet-astra.tsx @@ -1,10 +1,10 @@ import { useEffect, useMemo } from 'react' import { Animated, StyleSheet, Text, View } from 'react-native' -import { Sparkles } from 'lucide-react-native' import { useTranslation } from 'react-i18next' -import { createTokensV2, easings, tintFromPrimary, type AppTokensV2 } from '@/lib/theme' +import { createTokensV2, easings, type AppTokensV2 } from '@/lib/theme' import { toAnimatedEasing, usePrefersReducedMotion } from '@/lib/motion' import { useAppTheme } from '@/lib/use-app-theme' +import { AstraAvatar } from '@/components/ui/astra-avatar' /** ob-2 onboarding step: tinted hero disc + Astra intro in the kit chat-bubble language. */ export function OnboardingMeetAstra() { @@ -48,22 +48,19 @@ export function OnboardingMeetAstra() { return ( - + {t('onboarding.flow.meetAstra.title')} @@ -84,9 +81,7 @@ export function OnboardingMeetAstra() { }, ]} > - - - + {t('onboarding.flow.meetAstra.subtitle')} @@ -105,14 +100,6 @@ function createStyles(tokens: AppTokensV2) { paddingTop: 24, paddingBottom: 8, }, - heroDisc: { - width: 116, - height: 116, - borderRadius: 999, - backgroundColor: tintFromPrimary(tokens, 0.14), - alignItems: 'center', - justifyContent: 'center', - }, title: { fontFamily: 'Rubik_500Medium', fontSize: 28, @@ -128,14 +115,6 @@ function createStyles(tokens: AppTokensV2) { alignSelf: 'stretch', maxWidth: 340, }, - avatarDisc: { - width: 30, - height: 30, - borderRadius: 999, - backgroundColor: tintFromPrimary(tokens, 0.18), - alignItems: 'center', - justifyContent: 'center', - }, bubble: { flex: 1, backgroundColor: tokens.bgElev, diff --git a/apps/mobile/components/ui/app-bar.tsx b/apps/mobile/components/ui/app-bar.tsx index 1c1e5cf8a..74727655a 100644 --- a/apps/mobile/components/ui/app-bar.tsx +++ b/apps/mobile/components/ui/app-bar.tsx @@ -22,6 +22,8 @@ interface AppBarProps { onBack?: () => void /** Leading lucide-react-native icon (ignored when `back` is true). */ LeadingIcon?: LucideIcon + /** Mark rendered immediately before the centered title (e.g. Astra's avatar). */ + titleIcon?: ReactNode /** Centered uppercase label. Omit for bars whose content carries its own heading. */ title?: string subtitle?: string @@ -42,6 +44,7 @@ export function AppBar({ back = false, onBack, LeadingIcon, + titleIcon, title, subtitle, trailing, @@ -112,14 +115,19 @@ export function AppBar({ ) : null} - {title ? ( + {title || titleIcon ? ( - - {title} - + + {titleIcon} + {title ? ( + + {title} + + ) : null} + {subtitle ? ( ) { + const { currentScheme, currentTheme } = useAppTheme() + const tokens = useMemo( + () => createTokensV2(currentScheme, currentTheme), + [currentScheme, currentTheme], + ) + const prefersReducedMotion = usePrefersReducedMotion() + const dimension = typeof size === 'number' ? size : Number(size) + const stroke = typeof strokeWidth === 'number' ? strokeWidth : Number(strokeWidth) + const monochrome = color != null + const ringColor = monochrome ? color : tokens.fg4 + const accentColor = monochrome ? color : tokens.primary + + const spin = useMemo(() => new Animated.Value(0), []) + + useEffect(() => { + if (!animate || prefersReducedMotion) { + spin.setValue(0) + return + } + const loop = Animated.loop( + Animated.timing(spin, { + toValue: 1, + duration: ORBIT_DURATION_MS, + easing: Easing.linear, + useNativeDriver: true, + }), + ) + loop.start() + return () => loop.stop() + }, [animate, prefersReducedMotion, spin]) + + return ( + + + + + + + + + ) +} + +interface AstraAvatarProps { + /** Disc diameter in px. */ + size?: number + /** Accessible name. When omitted the avatar is decorative (hidden from assistive tech). */ + label?: string + /** Slowly orbit the satellite (reduced-motion gated). */ + animate?: boolean + style?: StyleProp +} + +/** Astra's avatar: the orbital mark centered on a primary-tinted disc, for hero and chat-bubble use. */ +export function AstraAvatar({ size = 116, label, animate = false, style }: Readonly) { + const { currentScheme, currentTheme } = useAppTheme() + const tokens = useMemo( + () => createTokensV2(currentScheme, currentTheme), + [currentScheme, currentTheme], + ) + const decorative = label == null + + return ( + + + + ) +} diff --git a/apps/web/__tests__/components/onboarding/onboarding-meet-astra.test.tsx b/apps/web/__tests__/components/onboarding/onboarding-meet-astra.test.tsx new file mode 100644 index 000000000..677403edc --- /dev/null +++ b/apps/web/__tests__/components/onboarding/onboarding-meet-astra.test.tsx @@ -0,0 +1,26 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})) + +import { OnboardingMeetAstra } from '@/components/onboarding/onboarding-meet-astra' + +describe('OnboardingMeetAstra', () => { + it('renders Astra avatar with an accessible label', () => { + render() + expect(screen.getByRole('img', { name: 'chat.astraAvatarLabel' })).toBeInTheDocument() + }) + + it('renders the orbital mark (svg) for both the hero and the bubble', () => { + const { container } = render() + expect(container.querySelectorAll('svg').length).toBeGreaterThanOrEqual(2) + }) + + it('shows the intro copy', () => { + render() + expect(screen.getByText('onboarding.flow.meetAstra.title')).toBeInTheDocument() + expect(screen.getByText('onboarding.flow.meetAstra.subtitle')).toBeInTheDocument() + }) +}) diff --git a/apps/web/__tests__/components/ui/astra-avatar.test.tsx b/apps/web/__tests__/components/ui/astra-avatar.test.tsx new file mode 100644 index 000000000..c6134f2f9 --- /dev/null +++ b/apps/web/__tests__/components/ui/astra-avatar.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { AstraMark, AstraAvatar } from '@/components/ui/astra-avatar' + +describe('AstraMark', () => { + it('renders a 24px svg by default', () => { + const { container } = render() + const svg = container.querySelector('svg') + expect(svg).not.toBeNull() + expect(svg).toHaveAttribute('width', '24') + expect(svg).toHaveAttribute('height', '24') + }) + + it('respects the size prop', () => { + const { container } = render() + expect(container.querySelector('svg')).toHaveAttribute('width', '40') + }) + + it('draws the ring, trail, satellite and core', () => { + const { container } = render() + expect(container.querySelectorAll('circle')).toHaveLength(3) + expect(container.querySelector('path')).not.toBeNull() + }) + + it('uses the violet/hairline duotone when no color is given', () => { + const { container } = render() + const ring = container.querySelector('circle[stroke]') + expect(ring).toHaveAttribute('stroke', 'var(--fg-4)') + }) + + it('renders monochrome when a color is given (icon contexts)', () => { + const { container } = render() + const ring = container.querySelector('circle[stroke]') + expect(ring).toHaveAttribute('stroke', 'currentColor') + }) + + it('adds the orbit animation class only when animated', () => { + const { container, rerender } = render() + expect(container.querySelector('svg')).not.toHaveClass('astra-orbit') + rerender() + expect(container.querySelector('svg')).toHaveClass('astra-orbit') + }) +}) + +describe('AstraAvatar', () => { + it('is decorative (hidden from assistive tech) without a label', () => { + const { container } = render() + expect(container.firstChild).toHaveAttribute('aria-hidden', 'true') + expect(container.querySelector('svg')).not.toBeNull() + }) + + it('exposes an accessible image when labelled', () => { + render() + expect(screen.getByRole('img', { name: 'Astra avatar' })).toBeInTheDocument() + }) + + it('applies a custom class', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('custom') + }) +}) diff --git a/apps/web/app/(chat)/chat/page.tsx b/apps/web/app/(chat)/chat/page.tsx index f1ad2f436..bece5c718 100644 --- a/apps/web/app/(chat)/chat/page.tsx +++ b/apps/web/app/(chat)/chat/page.tsx @@ -2,11 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useRouter } from 'next/navigation' -import { Orbit } from 'lucide-react' import { useTranslations } from 'next-intl' import { CHAT_GOAL_ACTION_TYPES } from '@orbit/shared/hooks' import { habitDetailToNormalized } from '@orbit/shared/utils' import { AppBar } from '@/components/ui/app-bar' +import { AstraMark } from '@/components/ui/astra-avatar' import { GradientTop } from '@/components/ui/gradient-top' import { useChatComposer } from '@/hooks/use-chat-composer' import { useGoBackOrFallback } from '@/hooks/use-go-back-or-fallback' @@ -102,7 +102,7 @@ export default function ChatPage() { back backLabel={t('common.goBack')} onBack={() => goBackOrFallback('/')} - leadingIcon={} + titleIcon={} title={t('chat.title')} />
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index d84df71f3..37bd82fe7 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -480,6 +480,15 @@ button { animation: orbit-pulse 2.5s ease-in-out infinite; } +@keyframes astra-orbit { + to { transform: rotate(360deg); } +} + +.astra-orbit { + transform-origin: center; + animation: astra-orbit 14s linear infinite; +} + /* ═══════════════════════════════════════════════════════ PAGE & LAYOUT TRANSITIONS ═══════════════════════════════════════════════════════ */ diff --git a/apps/web/components/navigation/bottom-tab-bar.tsx b/apps/web/components/navigation/bottom-tab-bar.tsx index a5c322baa..3c6ba4c2d 100644 --- a/apps/web/components/navigation/bottom-tab-bar.tsx +++ b/apps/web/components/navigation/bottom-tab-bar.tsx @@ -1,12 +1,16 @@ 'use client' +import type { ComponentType } from 'react' import { useTranslations } from 'next-intl' -import { Home, MessageCircle, CalendarDays, User, Plus, type LucideIcon } from 'lucide-react' +import { Home, CalendarDays, User, Plus, type LucideProps } from 'lucide-react' +import { AstraMark } from '@/components/ui/astra-avatar' /** Kit 4-tab bar (Home / Astra / Calendar / You) + centered 60px Plus FAB. * FAB hidden on Astra (has its own composer); rendered disabled off Today. */ export type BottomTab = 'today' | 'chat' | 'calendar' | 'profile' +type LucideIcon = ComponentType + interface TabDef { id: BottomTab labelKey: string @@ -24,7 +28,7 @@ interface BottomTabBarProps { const DEFAULT_TABS: TabDef[] = [ { id: 'today', labelKey: 'home', icon: Home }, - { id: 'chat', labelKey: 'astra', icon: MessageCircle }, + { id: 'chat', labelKey: 'astra', icon: AstraMark }, { id: 'calendar', labelKey: 'calendar', icon: CalendarDays }, { id: 'profile', labelKey: 'you', icon: User }, ] diff --git a/apps/web/components/onboarding/onboarding-meet-astra.tsx b/apps/web/components/onboarding/onboarding-meet-astra.tsx index 56956ef92..dd6a216f4 100644 --- a/apps/web/components/onboarding/onboarding-meet-astra.tsx +++ b/apps/web/components/onboarding/onboarding-meet-astra.tsx @@ -1,7 +1,7 @@ 'use client' -import { Sparkles } from 'lucide-react' import { useTranslations } from 'next-intl' +import { AstraAvatar } from '@/components/ui/astra-avatar' /** ob-2 onboarding step: tinted hero disc + Astra intro in the kit chat-bubble language. */ export function OnboardingMeetAstra() { @@ -9,22 +9,12 @@ export function OnboardingMeetAstra() { return (
-
-
+
- +
void leadingIcon?: ReactNode + /** Mark rendered immediately before the centered title (e.g. Astra's avatar). */ + titleIcon?: ReactNode /** Centered uppercase label. Omit for bars whose content carries its own heading. */ title?: string subtitle?: string @@ -41,6 +43,7 @@ export function AppBar({ backLabel, onBack, leadingIcon, + titleIcon, title, subtitle, trailing, @@ -102,20 +105,25 @@ export function AppBar({ ) : null}
- {title && ( + {(title || titleIcon) && (
-
- {title} +
+ {titleIcon} + {title && ( + + {title} + + )}
{subtitle && (
) { + const dimension = typeof size === 'number' ? size : Number(size) + const stroke = typeof strokeWidth === 'number' ? strokeWidth : Number(strokeWidth) + const monochrome = color != null + const ringColor = monochrome ? color : 'var(--fg-4)' + const accentColor = monochrome ? color : 'var(--primary)' + + return ( + + ) +} + +interface AstraAvatarProps { + /** Disc diameter in px. */ + size?: number + /** Accessible name. When omitted the avatar is decorative (hidden from assistive tech). */ + label?: string + /** Slowly orbit the satellite (reduced-motion gated). */ + animate?: boolean + className?: string + style?: CSSProperties +} + +/** Astra's avatar: the orbital mark centered on a primary-tinted disc, for hero and chat-bubble use. */ +export function AstraAvatar({ + size = 116, + label, + animate = false, + className, + style, +}: Readonly): ReactNode { + const decorative = label == null + + return ( + + + + ) +} diff --git a/packages/shared/src/i18n/en.json b/packages/shared/src/i18n/en.json index 5406b4f12..fbde0c0cf 100644 --- a/packages/shared/src/i18n/en.json +++ b/packages/shared/src/i18n/en.json @@ -750,6 +750,7 @@ "messagesUsed": "messages used this month", "senderYou": "You", "senderOrbit": "Astra", + "astraAvatarLabel": "Astra avatar", "unknownEntity": "Unknown", "starterChips": { "logHabit": "Log a habit", diff --git a/packages/shared/src/i18n/pt-BR.json b/packages/shared/src/i18n/pt-BR.json index a991aeb2e..bd56d26d0 100644 --- a/packages/shared/src/i18n/pt-BR.json +++ b/packages/shared/src/i18n/pt-BR.json @@ -750,6 +750,7 @@ "messagesUsed": "mensagens usadas neste mĂȘs", "senderYou": "VocĂȘ", "senderOrbit": "Astra", + "astraAvatarLabel": "Avatar da Astra", "unknownEntity": "Desconhecido", "starterChips": { "logHabit": "Registrar um hĂĄbito", From 042e299a1101e335754f7ec9349d42c9a36f1afc Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sat, 27 Jun 2026 01:35:46 -0300 Subject: [PATCH 3/6] chore(skills): strengthen /audit-tests audit Add a quantified Suite-health metric (share of suite that's happy-path-only vs pins all three axes), bound the adversarial skeptic pass (Criticals get own skeptic, Highs batched/capped at top-15) + a Fix-first top-10 list, and a Tests-to-delete/rewrite section for false-safety tests. Rubric gains a delete-vs-rewrite + aggregate-rollup note. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/audit-tests/SKILL.md | 42 +++++++++++++++++++++++----- .claude/skills/audit-tests/rubric.md | 16 +++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/.claude/skills/audit-tests/SKILL.md b/.claude/skills/audit-tests/SKILL.md index e9db036e7..b0478a8e3 100644 --- a/.claude/skills/audit-tests/SKILL.md +++ b/.claude/skills/audit-tests/SKILL.md @@ -108,12 +108,15 @@ Before writing the report, run `.claude/skills/_shared/verification-protocol.md` ships only after it survives a challenge, and the sweep must prove it covered the paths that matter. -1. **Adversarial pass (§2).** For every **Critical / High** finding, spawn an independent - skeptic subagent (3 concurrent) whose only job is to *refute* it — read the cited test - + source in full context and argue the gap is a false positive (the path is actually - pinned by a test elsewhere, the test *would* fail on a real break, a duplicate, the - severity inflated). Default to refuted when uncertain. Drop or downgrade anything the - skeptic disproves; survivors ship with confidence. +1. **Adversarial pass (§2).** Refute before shipping — but **bound the fan-out** so the + audit stays affordable on a systemically-weak suite (where Critical/High findings can + run to dozens). **Every Critical finding gets its own skeptic; High findings are batched + (one skeptic per ~5, grouped by area) or capped at the top 15 by leverage, the remainder + rolled into Deferred.** Each skeptic (3 concurrent) reads the cited test + source in full + context and argues the gap is a false positive (pinned by a test elsewhere, the test + *would* fail on a real break, a duplicate, the severity inflated). Default to refuted + when uncertain. Drop or downgrade anything the skeptic disproves; survivors ship with + confidence. 2. **Completeness critic + loop-until-dry (§3).** Run a fresh critic asking *"what did this audit NOT examine — a critical path never mapped, a test area skipped, a suite only half-read?"* Spawn a focused finder round on each gap it names; repeat until a round @@ -139,6 +142,17 @@ mkdir -p .claude/audits **Rubric**: `.claude/skills/audit-tests/rubric.md` (behavior + edge + failure) **Verdict**: {1 line — e.g. "Critical paths covered except Play webhook rejection; 3 happy-path-only suites"} +## Suite health — the scale of the rot + +> Quantify it so pervasive weakness reads as a number, not something buried in a list of +> individual findings. This is the section that answers "are our tests systemically bad?" + +- **Files scored**: {N} of {total} +- **Behavior-only (happy-path)**: {X} ({X/N %}) +- **Carry a smell** (rubber-stamp / over-mocked / assertion-free / impl-coupled / snapshot-crutch): {Y} ({Y/N %}) +- **Pin behavior on all three axes**: {Z} ({Z/N %}) +- **One-line read**: {e.g. "~60% happy-path-only — the green bar is mostly theater" vs "largely healthy; gaps are localized"} + ## Critical-path coverage | Path | Tested? | Quality | Gap | @@ -161,11 +175,24 @@ mkdir -p .claude/audits ### Medium — missing edge/failure case off the critical path {
 or "None"} +## Fix first — top 10 by leverage + +{The 10 highest-leverage tests to write or rewrite FIRST, ranked — so a systemically-weak +suite is actionable instead of paralyzing. Each: one line · severity · the path it protects. +Drawn from the Critical/High findings below. Fewer than 10 only if the suite is healthy.} + ## Concrete tests to add {A numbered, ready-to-write list. Each: name · file it goes in · arrange/act/assert · the factory to use. This is the actionable core — make it copy-pasteable-into-a-task.} +## Tests to delete or rewrite — false safety + +{Existing tests to REMOVE or rewrite because they give false safety (rubber-stamp / +assertion-free / tautological / snapshot-as-crutch). Deleting a test that can't fail is a +real, valuable action — it's a liability, not coverage. Each: file:line · which smell · +delete vs rewrite · if rewrite, the observable assertion that would make it real.} + ## Deferred — in scope but not verdicted {Per the verification protocol §4: paths or test areas the sweep did not score with a @@ -203,6 +230,7 @@ reason. "Nothing deferred — full coverage" if the contract was met.} **Scope**: {what was audited} **Verdict**: {1-line} +**Suite health**: {X}% happy-path-only · {Y}% smell-carrying · {Z}% pin all three axes (of {N} files scored) | Severity | Count | |---|---| @@ -211,5 +239,5 @@ reason. "Nothing deferred — full coverage" if the contract was met.} | Medium (missing edge/failure) | {N} | **Report**: `.claude/audits/tests-{scope}.md` -**Top gap**: {the single most important test to write first} +**Fix first**: {the single most important test to write or delete first} ``` diff --git a/.claude/skills/audit-tests/rubric.md b/.claude/skills/audit-tests/rubric.md index 1a902007b..794e710ad 100644 --- a/.claude/skills/audit-tests/rubric.md +++ b/.claude/skills/audit-tests/rubric.md @@ -72,6 +72,22 @@ is **High** or **Critical**; off a critical path it's **Medium**. --- +## Aggregate read & delete-vs-rewrite + +The per-test axis scores roll up into the skill's **Suite-health** metric — *what share of the +suite is Behavior-only vs pins all three axes* — so systemic rot reads as a number, not a pile +of individual findings. + +A smelled test is not only something to add coverage *around* — it is a **delete-or-rewrite** +action in its own right: +- **Delete** when it can't fail and there's nothing real to assert (assertion-free, tautological, + `expect(mock).toHaveBeenCalled()` with no outcome, a snapshot nobody reads). It is a liability; + removing it removes false safety. +- **Rewrite** when the path *is* worth pinning but the assertion is wrong (asserts internals / call + order → assert the observable outcome instead). + +--- + ## Every finding ships the fix A finding is not "this test is weak." It is the **concrete test to add or rewrite**: From 2b29e3949951395b927d5afd8bdc010448bac010 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sat, 27 Jun 2026 02:02:18 -0300 Subject: [PATCH 4/6] feat(social): social Zod types, endpoints, query keys (#193) 15 social schemas/types, data-export rows, friends.* + profile.handle/socialOptIn endpoints, friendKeys/cheerKeys. Additive. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../shared/src/__tests__/data-export.test.ts | 43 ++ packages/shared/src/__tests__/factories.ts | 42 ++ packages/shared/src/__tests__/social.test.ts | 485 ++++++++++++++++++ packages/shared/src/api/endpoints.ts | 14 + packages/shared/src/query/index.ts | 2 + packages/shared/src/query/keys.ts | 11 + packages/shared/src/types/data-export.ts | 43 ++ packages/shared/src/types/index.ts | 1 + packages/shared/src/types/social.ts | 129 +++++ 9 files changed, 770 insertions(+) create mode 100644 packages/shared/src/__tests__/social.test.ts create mode 100644 packages/shared/src/types/social.ts diff --git a/packages/shared/src/__tests__/data-export.test.ts b/packages/shared/src/__tests__/data-export.test.ts index 59a9d7765..88fa72dec 100644 --- a/packages/shared/src/__tests__/data-export.test.ts +++ b/packages/shared/src/__tests__/data-export.test.ts @@ -105,6 +105,49 @@ const validExport = { isRevoked: false, }, ], + friendships: [ + { + requesterId: '55555555-5555-5555-5555-555555555555', + addresseeId: '66666666-6666-6666-6666-666666666666', + status: 'Accepted', + createdAtUtc: '2026-04-01T00:00:00Z', + respondedAtUtc: '2026-04-02T00:00:00Z', + }, + ], + cheers: [ + { + senderId: '66666666-6666-6666-6666-666666666666', + recipientId: '55555555-5555-5555-5555-555555555555', + habitId: '11111111-1111-1111-1111-111111111111', + note: 'Nice streak!', + createdAtUtc: '2026-04-03T00:00:00Z', + }, + ], + blockedUsers: [ + { + blockerId: '55555555-5555-5555-5555-555555555555', + blockedId: '77777777-7777-7777-7777-777777777777', + createdAtUtc: '2026-04-04T00:00:00Z', + }, + ], + reports: [ + { + reportedUserId: '77777777-7777-7777-7777-777777777777', + reason: 'Spam', + details: null, + cheerId: null, + status: 'Pending', + createdAtUtc: '2026-04-05T00:00:00Z', + }, + ], + friendFeedEvents: [ + { + type: 'StreakMilestone', + value: 30, + achievementId: null, + createdAtUtc: '2026-04-06T00:00:00Z', + }, + ], } describe('userDataExportSchema', () => { diff --git a/packages/shared/src/__tests__/factories.ts b/packages/shared/src/__tests__/factories.ts index 1503d39bd..5317c6cd1 100644 --- a/packages/shared/src/__tests__/factories.ts +++ b/packages/shared/src/__tests__/factories.ts @@ -5,6 +5,7 @@ import type { NotificationItem } from '../types/notification' import type { Achievement, GamificationProfile } from '../types/gamification' import type { AppConfig } from '../types/config' import { DEFAULT_CONFIG } from '../types/config' +import type { FriendSummary, Cheer, FriendFeedItem } from '../types/social' export function createMockHabit(overrides: Partial = {}): NormalizedHabit { @@ -192,3 +193,44 @@ export function createMockConfig(overrides: Partial = {}): AppConfig ...overrides, } } + + +export function createMockFriendSummary(overrides: Partial = {}): FriendSummary { + return { + userId: 'user-1', + handle: 'ada_lovelace', + displayName: 'Ada Lovelace', + currentStreak: 7, + ...overrides, + } +} + + +export function createMockCheer(overrides: Partial = {}): Cheer { + return { + id: 'cheer-1', + senderId: 'user-2', + recipientId: 'user-1', + habitId: 'habit-1', + note: 'Keep it up!', + createdAtUtc: '2026-01-01T00:00:00Z', + senderHandle: 'grace_h', + senderDisplayName: 'Grace Hopper', + ...overrides, + } +} + + +export function createMockFriendFeedItem(overrides: Partial = {}): FriendFeedItem { + return { + id: 'feed-1', + actorUserId: 'user-2', + actorHandle: 'grace_h', + actorDisplayName: 'Grace Hopper', + type: 'StreakMilestone', + value: 30, + achievementId: null, + createdAtUtc: '2026-01-01T00:00:00Z', + ...overrides, + } +} diff --git a/packages/shared/src/__tests__/social.test.ts b/packages/shared/src/__tests__/social.test.ts new file mode 100644 index 000000000..25b91a60b --- /dev/null +++ b/packages/shared/src/__tests__/social.test.ts @@ -0,0 +1,485 @@ +import { describe, it, expect } from 'vitest' +import { + reportReasonSchema, + friendFeedEventTypeSchema, + handleSchema, + setHandleRequestSchema, + socialOptInRequestSchema, + friendSummarySchema, + friendRequestSummarySchema, + friendsResponseSchema, + friendFeedItemSchema, + friendFeedPageSchema, + cheerSchema, + sendCheerRequestSchema, + cheersPageSchema, + sendFriendRequestSchema, + blockUserRequestSchema, + reportUserRequestSchema, +} from '../types/social' +import { userDataExportSchema } from '../types/data-export' +import { API } from '../api/endpoints' +import { friendKeys, cheerKeys } from '../query/keys' +import { + createMockFriendSummary, + createMockCheer, + createMockFriendFeedItem, +} from './factories' + + +describe('reportReasonSchema', () => { + it('parses each whitelisted reason', () => { + for (const reason of ['Spam', 'Harassment', 'InappropriateContent', 'Impersonation', 'Other']) { + expect(reportReasonSchema.safeParse(reason).success).toBe(true) + } + }) + + it('rejects an unknown reason', () => { + expect(reportReasonSchema.safeParse('Toxic').success).toBe(false) + }) +}) + + +describe('friendFeedEventTypeSchema', () => { + it('parses each whitelisted event type', () => { + for (const type of ['StreakMilestone', 'AchievementUnlocked', 'HabitCompletedMilestone']) { + expect(friendFeedEventTypeSchema.safeParse(type).success).toBe(true) + } + }) + + it('rejects an unknown event type', () => { + expect(friendFeedEventTypeSchema.safeParse('FriendJoined').success).toBe(false) + }) +}) + + +describe('handleSchema', () => { + it('accepts a valid handle', () => { + expect(handleSchema.safeParse('user_abc12').success).toBe(true) + }) + + it('rejects a too-short handle', () => { + expect(handleSchema.safeParse('ab').success).toBe(false) + }) + + it('rejects a handle with a space', () => { + expect(handleSchema.safeParse('has space').success).toBe(false) + }) + + it('rejects a too-long handle', () => { + expect(handleSchema.safeParse('a'.repeat(21)).success).toBe(false) + }) +}) + + +describe('setHandleRequestSchema', () => { + it('parses a valid set-handle request', () => { + expect(setHandleRequestSchema.safeParse({ handle: 'grace_h' }).success).toBe(true) + }) + + it('rejects an invalid handle', () => { + expect(setHandleRequestSchema.safeParse({ handle: 'no' }).success).toBe(false) + }) +}) + + +describe('socialOptInRequestSchema', () => { + it('parses a valid opt-in request', () => { + expect(socialOptInRequestSchema.safeParse({ enabled: true }).success).toBe(true) + }) + + it('rejects a non-boolean enabled', () => { + expect(socialOptInRequestSchema.safeParse({ enabled: 'yes' }).success).toBe(false) + }) +}) + + +describe('friendSummarySchema', () => { + it('parses a valid friend summary', () => { + expect(friendSummarySchema.safeParse(createMockFriendSummary()).success).toBe(true) + }) + + it('rejects a summary missing currentStreak', () => { + const { currentStreak: _omit, ...withoutStreak } = createMockFriendSummary() + expect(friendSummarySchema.safeParse(withoutStreak).success).toBe(false) + }) +}) + + +describe('friendRequestSummarySchema', () => { + it('parses a valid friend request summary', () => { + const result = friendRequestSummarySchema.safeParse({ + id: 'friendship-1', + userId: 'user-9', + handle: 'turing_a', + displayName: 'Alan Turing', + createdAtUtc: '2026-01-01T00:00:00Z', + }) + expect(result.success).toBe(true) + }) + + it('rejects a request summary missing the friendship id', () => { + const result = friendRequestSummarySchema.safeParse({ + userId: 'user-9', + handle: 'turing_a', + displayName: 'Alan Turing', + createdAtUtc: '2026-01-01T00:00:00Z', + }) + expect(result.success).toBe(false) + }) +}) + + +describe('friendsResponseSchema', () => { + it('parses friends with incoming and outgoing requests', () => { + const result = friendsResponseSchema.safeParse({ + friends: [createMockFriendSummary()], + incomingRequests: [ + { + id: 'friendship-2', + userId: 'user-3', + handle: 'hopper_g', + displayName: 'Grace Hopper', + createdAtUtc: '2026-01-02T00:00:00Z', + }, + ], + outgoingRequests: [], + }) + expect(result.success).toBe(true) + }) + + it('rejects when a request collection is not an array', () => { + const result = friendsResponseSchema.safeParse({ + friends: [], + incomingRequests: null, + outgoingRequests: [], + }) + expect(result.success).toBe(false) + }) +}) + + +describe('friendFeedItemSchema', () => { + it('round-trips a milestone item with a numeric value and null achievementId', () => { + const item = createMockFriendFeedItem({ value: 30, achievementId: null }) + const parsed = friendFeedItemSchema.parse(item) + expect(parsed.value).toBe(30) + expect(parsed.achievementId).toBeNull() + }) + + it('round-trips an achievement item with a null value and a non-null achievementId', () => { + const item = createMockFriendFeedItem({ + type: 'AchievementUnlocked', + value: null, + achievementId: 'first-habit', + }) + const parsed = friendFeedItemSchema.parse(item) + expect(parsed.value).toBeNull() + expect(parsed.achievementId).toBe('first-habit') + }) + + it('rejects an unknown feed event type', () => { + const result = friendFeedItemSchema.safeParse({ + ...createMockFriendFeedItem(), + type: 'FriendJoined', + }) + expect(result.success).toBe(false) + }) +}) + + +describe('friendFeedPageSchema', () => { + it('parses a page with a next cursor', () => { + const result = friendFeedPageSchema.safeParse({ + items: [createMockFriendFeedItem()], + nextCursor: 'cursor-abc', + }) + expect(result.success).toBe(true) + }) + + it('parses a terminal page with a null cursor', () => { + const result = friendFeedPageSchema.safeParse({ items: [], nextCursor: null }) + expect(result.success).toBe(true) + }) + + it('rejects a page missing items', () => { + expect(friendFeedPageSchema.safeParse({ nextCursor: null }).success).toBe(false) + }) +}) + + +describe('cheerSchema', () => { + it('parses a valid cheer with sender display fields', () => { + expect(cheerSchema.safeParse(createMockCheer()).success).toBe(true) + }) + + it('accepts a null note', () => { + expect(cheerSchema.safeParse(createMockCheer({ note: null })).success).toBe(true) + }) + + it('rejects a cheer missing the sender handle', () => { + const { senderHandle: _omit, ...withoutHandle } = createMockCheer() + expect(cheerSchema.safeParse(withoutHandle).success).toBe(false) + }) +}) + + +describe('sendCheerRequestSchema', () => { + it('parses a cheer request with a note', () => { + const result = sendCheerRequestSchema.safeParse({ + recipientId: 'user-1', + habitId: 'habit-1', + note: 'Proud of you!', + }) + expect(result.success).toBe(true) + }) + + it('parses a cheer request without a note', () => { + const result = sendCheerRequestSchema.safeParse({ + recipientId: 'user-1', + habitId: 'habit-1', + }) + expect(result.success).toBe(true) + }) + + it('rejects a note longer than 200 characters', () => { + const result = sendCheerRequestSchema.safeParse({ + recipientId: 'user-1', + habitId: 'habit-1', + note: 'a'.repeat(201), + }) + expect(result.success).toBe(false) + }) + + it('rejects a request missing the recipient', () => { + expect(sendCheerRequestSchema.safeParse({ habitId: 'habit-1' }).success).toBe(false) + }) +}) + + +describe('cheersPageSchema', () => { + it('parses a cheers page', () => { + const result = cheersPageSchema.safeParse({ items: [createMockCheer()] }) + expect(result.success).toBe(true) + }) + + it('rejects a page whose items are not an array', () => { + expect(cheersPageSchema.safeParse({ items: createMockCheer() }).success).toBe(false) + }) +}) + + +describe('sendFriendRequestSchema', () => { + it('parses a request by handle', () => { + expect(sendFriendRequestSchema.safeParse({ handle: 'grace_h' }).success).toBe(true) + }) + + it('parses a request by referral code', () => { + expect(sendFriendRequestSchema.safeParse({ referralCode: 'REF123' }).success).toBe(true) + }) + + it('parses an empty request (server enforces exactly-one)', () => { + expect(sendFriendRequestSchema.safeParse({}).success).toBe(true) + }) + + it('rejects a non-string handle', () => { + expect(sendFriendRequestSchema.safeParse({ handle: 42 }).success).toBe(false) + }) +}) + + +describe('blockUserRequestSchema', () => { + it('parses a valid block request', () => { + expect(blockUserRequestSchema.safeParse({ blockedUserId: 'user-7' }).success).toBe(true) + }) + + it('rejects a request missing the blocked user id', () => { + expect(blockUserRequestSchema.safeParse({}).success).toBe(false) + }) +}) + + +describe('reportUserRequestSchema', () => { + it('parses a full report with details and a cheer reference', () => { + const result = reportUserRequestSchema.safeParse({ + reportedUserId: 'user-7', + reason: 'Harassment', + details: 'Repeated unwanted cheers.', + cheerId: 'cheer-1', + }) + expect(result.success).toBe(true) + }) + + it('parses a minimal report with only a reason', () => { + const result = reportUserRequestSchema.safeParse({ + reportedUserId: 'user-7', + reason: 'Spam', + }) + expect(result.success).toBe(true) + }) + + it('rejects an unknown reason', () => { + const result = reportUserRequestSchema.safeParse({ + reportedUserId: 'user-7', + reason: 'Toxic', + }) + expect(result.success).toBe(false) + }) + + it('rejects details longer than 500 characters', () => { + const result = reportUserRequestSchema.safeParse({ + reportedUserId: 'user-7', + reason: 'Other', + details: 'a'.repeat(501), + }) + expect(result.success).toBe(false) + }) +}) + + +describe('userDataExportSchema social collections', () => { + const exportWithSocial = { + exportedAtUtc: '2026-06-04T10:00:00Z', + account: { + name: 'Ada', + email: 'ada@example.com', + createdAtUtc: '2026-01-01T00:00:00Z', + plan: 'pro', + }, + settings: { + timeZone: 'America/Sao_Paulo', + language: 'en', + weekStartDay: 1, + themePreference: 'dark', + colorScheme: 'blue', + aiMemoryEnabled: true, + aiSummaryEnabled: false, + }, + subscription: { + plan: 'pro', + isLifetimePro: false, + source: 'stripe', + interval: 'month', + planExpiresAtUtc: null, + trialEndsAtUtc: null, + }, + habits: [], + goals: [], + tags: [], + facts: [], + notifications: [], + checklistTemplates: [], + achievements: [], + streakFreezes: [], + referrals: [], + apiKeys: [], + friendships: [ + { + requesterId: 'user-1', + addresseeId: 'user-2', + status: 'Accepted', + createdAtUtc: '2026-04-01T00:00:00Z', + respondedAtUtc: '2026-04-02T00:00:00Z', + }, + ], + cheers: [ + { + senderId: 'user-2', + recipientId: 'user-1', + habitId: 'habit-1', + note: 'Nice streak!', + createdAtUtc: '2026-04-03T00:00:00Z', + }, + ], + blockedUsers: [ + { + blockerId: 'user-1', + blockedId: 'user-3', + createdAtUtc: '2026-04-04T00:00:00Z', + }, + ], + reports: [ + { + reportedUserId: 'user-3', + reason: 'Spam', + details: null, + cheerId: null, + status: 'Pending', + createdAtUtc: '2026-04-05T00:00:00Z', + }, + ], + friendFeedEvents: [ + { + type: 'StreakMilestone', + value: 30, + achievementId: null, + createdAtUtc: '2026-04-06T00:00:00Z', + }, + ], + } + + it('parses an export payload that includes the social collections', () => { + const result = userDataExportSchema.parse(exportWithSocial) + expect(result.friendships[0]?.status).toBe('Accepted') + expect(result.cheers[0]?.note).toBe('Nice streak!') + expect(result.blockedUsers[0]?.blockedId).toBe('user-3') + expect(result.reports[0]?.reason).toBe('Spam') + expect(result.friendFeedEvents[0]?.value).toBe(30) + }) + + it('rejects an export payload missing the new social collections', () => { + const { friendships: _omit, ...withoutFriendships } = exportWithSocial + expect(userDataExportSchema.safeParse(withoutFriendships).success).toBe(false) + }) +}) + + +describe('friends API endpoints', () => { + it('has correct static paths', () => { + expect(API.profile.handle).toBe('/api/profile/handle') + expect(API.profile.socialOptIn).toBe('/api/profile/social-opt-in') + expect(API.friends.list).toBe('/api/friends') + expect(API.friends.requests).toBe('/api/friends/requests') + expect(API.friends.feed).toBe('/api/friends/feed') + expect(API.friends.cheers).toBe('/api/friends/cheers') + expect(API.friends.block).toBe('/api/friends/block') + expect(API.friends.report).toBe('/api/friends/report') + }) + + it('has correct parameterized paths', () => { + expect(API.friends.acceptRequest('fr-1')).toBe('/api/friends/requests/fr-1/accept') + expect(API.friends.remove('user-1')).toBe('/api/friends/user-1') + expect(API.friends.unblock('user-2')).toBe('/api/friends/block/user-2') + }) +}) + + +describe('friendKeys', () => { + it('all returns base key', () => { + expect(friendKeys.all).toEqual(['friends']) + }) + + it('list returns list key', () => { + expect(friendKeys.list()).toEqual(['friends', 'list']) + }) + + it('feed returns feed key', () => { + expect(friendKeys.feed()).toEqual(['friends', 'feed']) + }) +}) + + +describe('cheerKeys', () => { + it('all returns base key', () => { + expect(cheerKeys.all).toEqual(['cheers']) + }) + + it('list appends direction', () => { + expect(cheerKeys.list('received')).toEqual(['cheers', 'list', 'received']) + expect(cheerKeys.list('sent')).toEqual(['cheers', 'list', 'sent']) + }) + + it('list produces distinct keys per direction', () => { + expect(cheerKeys.list('received')).not.toEqual(cheerKeys.list('sent')) + }) +}) diff --git a/packages/shared/src/api/endpoints.ts b/packages/shared/src/api/endpoints.ts index c3e37c968..9121a7ec2 100644 --- a/packages/shared/src/api/endpoints.ts +++ b/packages/shared/src/api/endpoints.ts @@ -23,6 +23,8 @@ export const API = { colorScheme: '/api/profile/color-scheme', reset: '/api/profile/reset', export: '/api/profile/export', + handle: '/api/profile/handle', + socialOptIn: '/api/profile/social-opt-in', }, habits: { @@ -153,6 +155,18 @@ export const API = { dashboard: '/api/referrals/dashboard', }, + friends: { + list: '/api/friends', + requests: '/api/friends/requests', + acceptRequest: (id: string) => `/api/friends/requests/${id}/accept` as const, + remove: (friendUserId: string) => `/api/friends/${friendUserId}` as const, + feed: '/api/friends/feed', + cheers: '/api/friends/cheers', + block: '/api/friends/block', + unblock: (blockedUserId: string) => `/api/friends/block/${blockedUserId}` as const, + report: '/api/friends/report', + }, + apiKeys: { list: '/api/api-keys', create: '/api/api-keys', diff --git a/packages/shared/src/query/index.ts b/packages/shared/src/query/index.ts index 3fb2a900f..2ec6e12e3 100644 --- a/packages/shared/src/query/index.ts +++ b/packages/shared/src/query/index.ts @@ -8,6 +8,8 @@ export { gamificationKeys, subscriptionKeys, referralKeys, + friendKeys, + cheerKeys, apiKeyKeys, configKeys, calendarKeys, diff --git a/packages/shared/src/query/keys.ts b/packages/shared/src/query/keys.ts index 524529ee3..da67235db 100644 --- a/packages/shared/src/query/keys.ts +++ b/packages/shared/src/query/keys.ts @@ -66,6 +66,17 @@ export const referralKeys = { all: ['referral'] as const, } +export const friendKeys = { + all: ['friends'] as const, + list: () => [...friendKeys.all, 'list'] as const, + feed: () => [...friendKeys.all, 'feed'] as const, +} + +export const cheerKeys = { + all: ['cheers'] as const, + list: (direction: 'received' | 'sent') => [...cheerKeys.all, 'list', direction] as const, +} + export const apiKeyKeys = { all: ['apiKeys'] as const, lists: () => [...apiKeyKeys.all, 'list'] as const, diff --git a/packages/shared/src/types/data-export.ts b/packages/shared/src/types/data-export.ts index 4e2c04e8b..061b8438c 100644 --- a/packages/shared/src/types/data-export.ts +++ b/packages/shared/src/types/data-export.ts @@ -135,6 +135,44 @@ const exportedApiKeySchema = z.object({ isRevoked: z.boolean(), }) +const exportedFriendshipSchema = z.object({ + requesterId: z.string(), + addresseeId: z.string(), + status: z.string(), + createdAtUtc: z.string(), + respondedAtUtc: z.string().nullable(), +}) + +const exportedCheerSchema = z.object({ + senderId: z.string(), + recipientId: z.string(), + habitId: z.string(), + note: z.string().nullable(), + createdAtUtc: z.string(), +}) + +const exportedBlockedUserSchema = z.object({ + blockerId: z.string(), + blockedId: z.string(), + createdAtUtc: z.string(), +}) + +const exportedReportSchema = z.object({ + reportedUserId: z.string(), + reason: z.string(), + details: z.string().nullable(), + cheerId: z.string().nullable(), + status: z.string(), + createdAtUtc: z.string(), +}) + +const exportedFriendFeedEventSchema = z.object({ + type: z.string(), + value: z.number().nullable(), + achievementId: z.string().nullable(), + createdAtUtc: z.string(), +}) + export const userDataExportSchema = z.object({ exportedAtUtc: z.string(), account: exportedAccountSchema, @@ -150,6 +188,11 @@ export const userDataExportSchema = z.object({ streakFreezes: z.array(exportedStreakFreezeSchema), referrals: z.array(exportedReferralSchema), apiKeys: z.array(exportedApiKeySchema), + friendships: z.array(exportedFriendshipSchema), + cheers: z.array(exportedCheerSchema), + blockedUsers: z.array(exportedBlockedUserSchema), + reports: z.array(exportedReportSchema), + friendFeedEvents: z.array(exportedFriendFeedEventSchema), }) export type UserDataExport = z.infer diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 0a29de5f0..a045cfca7 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -18,3 +18,4 @@ export * from './ai' export * from './tour' export * from './data-export' export * from './upload' +export * from './social' diff --git a/packages/shared/src/types/social.ts b/packages/shared/src/types/social.ts new file mode 100644 index 000000000..f2f051cb8 --- /dev/null +++ b/packages/shared/src/types/social.ts @@ -0,0 +1,129 @@ +import { z } from 'zod' + +export const reportReasonSchema = z.enum([ + 'Spam', + 'Harassment', + 'InappropriateContent', + 'Impersonation', + 'Other', +]) + +export type ReportReason = z.infer + +export const friendFeedEventTypeSchema = z.enum([ + 'StreakMilestone', + 'AchievementUnlocked', + 'HabitCompletedMilestone', +]) + +export type FriendFeedEventType = z.infer + +export const handleSchema = z.string().regex(/^[A-Za-z0-9_]{3,20}$/) + +export const setHandleRequestSchema = z.object({ + handle: handleSchema, +}) + +export type SetHandleRequest = z.infer + +export const socialOptInRequestSchema = z.object({ + enabled: z.boolean(), +}) + +export type SocialOptInRequest = z.infer + +export const friendSummarySchema = z.object({ + userId: z.string(), + handle: z.string(), + displayName: z.string(), + currentStreak: z.number(), +}) + +export type FriendSummary = z.infer + +export const friendRequestSummarySchema = z.object({ + id: z.string(), + userId: z.string(), + handle: z.string(), + displayName: z.string(), + createdAtUtc: z.string(), +}) + +export type FriendRequestSummary = z.infer + +export const friendsResponseSchema = z.object({ + friends: z.array(friendSummarySchema), + incomingRequests: z.array(friendRequestSummarySchema), + outgoingRequests: z.array(friendRequestSummarySchema), +}) + +export type FriendsResponse = z.infer + +export const friendFeedItemSchema = z.object({ + id: z.string(), + actorUserId: z.string(), + actorHandle: z.string(), + actorDisplayName: z.string(), + type: friendFeedEventTypeSchema, + value: z.number().nullable(), + achievementId: z.string().nullable(), + createdAtUtc: z.string(), +}) + +export type FriendFeedItem = z.infer + +export const friendFeedPageSchema = z.object({ + items: z.array(friendFeedItemSchema), + nextCursor: z.string().nullable(), +}) + +export type FriendFeedPage = z.infer + +export const cheerSchema = z.object({ + id: z.string(), + senderId: z.string(), + recipientId: z.string(), + habitId: z.string(), + note: z.string().nullable(), + createdAtUtc: z.string(), + senderHandle: z.string(), + senderDisplayName: z.string(), +}) + +export type Cheer = z.infer + +export const sendCheerRequestSchema = z.object({ + recipientId: z.string(), + habitId: z.string(), + note: z.string().max(200).optional(), +}) + +export type SendCheerRequest = z.infer + +export const cheersPageSchema = z.object({ + items: z.array(cheerSchema), +}) + +export type CheersPage = z.infer + +export const sendFriendRequestSchema = z.object({ + handle: z.string().optional(), + referralCode: z.string().optional(), +}) + +export type SendFriendRequest = z.infer + +export const blockUserRequestSchema = z.object({ + blockedUserId: z.string(), +}) + +export type BlockUserRequest = z.infer + +export const reportUserRequestSchema = z.object({ + reportedUserId: z.string(), + reason: reportReasonSchema, + details: z.string().max(500).optional(), + cheerId: z.string().optional(), +}) + +export type ReportUserRequest = z.infer From 9cea47af4ac250aeac36c12267489a72b62d93a7 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sat, 27 Jun 2026 03:33:09 -0300 Subject: [PATCH 5/6] feat(onboarding): template packs, first-run refresh, setup checklist, no-card copy (#187, #188, #189, #192) Starter template packs (single-pack->customize->bulk-create) + wizard pack-picker keeping create-my-own (#187). Progressive disclosure >=5 habits + first-run coach-marks via tour engine, auto-tour retired for new users (#188). Auto-tracked setup-checklist Today card + onboarding achievement (#189). Onboarding-complete no-card trial copy (#192). web+mobile parity, both locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../onboarding/onboarding-flow.test.tsx | 13 +- .../onboarding-template-packs.test.tsx | 141 ++++++++ .../onboarding/onboarding-welcome.test.tsx | 8 +- .../today/setup-checklist-card.test.tsx | 140 ++++++++ .../__tests__/hooks/use-coach-mark.test.tsx | 87 +++++ .../__tests__/screens/today-screen.test.tsx | 12 + apps/mobile/__tests__/stores/ui-store.test.ts | 1 + apps/mobile/app/(tabs)/calendar.tsx | 2 + apps/mobile/app/(tabs)/index.tsx | 9 + apps/mobile/app/_layout.tsx | 15 - apps/mobile/app/chat.tsx | 2 + .../components/onboarding/onboarding-flow.tsx | 36 +- .../onboarding/onboarding-template-packs.tsx | 310 ++++++++++++++++++ .../components/today/setup-checklist-card.tsx | 159 +++++++++ .../components/today/today-habits-header.tsx | 36 +- apps/mobile/components/tour/tour-overlay.tsx | 5 + .../components/tour/tour-replay-modal.tsx | 3 + apps/mobile/hooks/use-coach-mark.ts | 48 +++ apps/web/__tests__/app/today-page.test.tsx | 31 +- .../onboarding/onboarding-flow.test.tsx | 32 +- .../onboarding-template-packs.test.tsx | 89 +++++ .../today/setup-checklist-card.test.tsx | 103 ++++++ .../__tests__/hooks/use-coach-mark.test.tsx | 71 ++++ apps/web/app/(app)/calendar/page.tsx | 2 + apps/web/app/(app)/layout.tsx | 15 - apps/web/app/(app)/page.tsx | 57 ++-- .../profile/_components/tour-replay-modal.tsx | 14 +- apps/web/app/(chat)/chat/page.tsx | 2 + .../components/onboarding/onboarding-flow.tsx | 30 +- .../onboarding/onboarding-template-packs.tsx | 252 ++++++++++++++ .../components/today/setup-checklist-card.tsx | 133 ++++++++ apps/web/components/tour/tour-overlay.tsx | 5 + apps/web/hooks/use-coach-mark.ts | 51 +++ packages/shared/src/__tests__/factories.ts | 4 + .../shared/src/__tests__/onboarding.test.ts | 13 +- .../src/__tests__/template-packs.test.ts | 110 +++++++ .../shared/src/__tests__/tour-store.test.ts | 23 ++ .../shared/src/__tests__/ui-store.test.ts | 2 + packages/shared/src/i18n/en.json | 101 +++++- packages/shared/src/i18n/pt-BR.json | 101 +++++- packages/shared/src/stores/tour-store.ts | 5 +- packages/shared/src/stores/ui-store.ts | 14 + packages/shared/src/tour/tour-steps.ts | 28 ++ packages/shared/src/types/habit.ts | 2 + packages/shared/src/types/profile.ts | 4 + packages/shared/src/types/tour.ts | 20 +- packages/shared/src/utils/index.ts | 11 + packages/shared/src/utils/onboarding.ts | 14 +- packages/shared/src/utils/template-packs.ts | 108 ++++++ 49 files changed, 2366 insertions(+), 108 deletions(-) create mode 100644 apps/mobile/__tests__/components/onboarding/onboarding-template-packs.test.tsx create mode 100644 apps/mobile/__tests__/components/today/setup-checklist-card.test.tsx create mode 100644 apps/mobile/__tests__/hooks/use-coach-mark.test.tsx create mode 100644 apps/mobile/components/onboarding/onboarding-template-packs.tsx create mode 100644 apps/mobile/components/today/setup-checklist-card.tsx create mode 100644 apps/mobile/hooks/use-coach-mark.ts create mode 100644 apps/web/__tests__/components/onboarding/onboarding-template-packs.test.tsx create mode 100644 apps/web/__tests__/components/today/setup-checklist-card.test.tsx create mode 100644 apps/web/__tests__/hooks/use-coach-mark.test.tsx create mode 100644 apps/web/components/onboarding/onboarding-template-packs.tsx create mode 100644 apps/web/components/today/setup-checklist-card.tsx create mode 100644 apps/web/hooks/use-coach-mark.ts create mode 100644 packages/shared/src/__tests__/template-packs.test.ts create mode 100644 packages/shared/src/utils/template-packs.ts diff --git a/apps/mobile/__tests__/components/onboarding/onboarding-flow.test.tsx b/apps/mobile/__tests__/components/onboarding/onboarding-flow.test.tsx index 1452d8bae..4d03d5da9 100644 --- a/apps/mobile/__tests__/components/onboarding/onboarding-flow.test.tsx +++ b/apps/mobile/__tests__/components/onboarding/onboarding-flow.test.tsx @@ -10,17 +10,17 @@ import { describe('OnboardingFlow helpers', () => { it('keeps pro users on the full step sequence', () => { - expect(getOnboardingDisplayTotal(true)).toBe(6) + expect(getOnboardingDisplayTotal(true)).toBe(7) expect(getOnboardingDisplayStep(0, true)).toBe(1) expect(getOnboardingNextStep(2, true)).toBe(3) expect(getOnboardingPreviousStep(3, true)).toBe(2) }) it('skips the goal creation step for free users', () => { - expect(getOnboardingDisplayTotal(false)).toBe(5) - expect(getOnboardingNextStep(2, false)).toBe(4) - expect(getOnboardingDisplayStep(4, false)).toBe(4) - expect(getOnboardingPreviousStep(4, false)).toBe(2) + expect(getOnboardingDisplayTotal(false)).toBe(6) + expect(getOnboardingNextStep(3, false)).toBe(5) + expect(getOnboardingDisplayStep(5, false)).toBe(5) + expect(getOnboardingPreviousStep(5, false)).toBe(3) }) it('hides the footer only on interactive onboarding steps', () => { @@ -28,7 +28,8 @@ describe('OnboardingFlow helpers', () => { expect(shouldHideOnboardingFooter(1)).toBe(true) expect(shouldHideOnboardingFooter(2)).toBe(true) expect(shouldHideOnboardingFooter(3)).toBe(true) - expect(shouldHideOnboardingFooter(4)).toBe(false) + expect(shouldHideOnboardingFooter(4)).toBe(true) + expect(shouldHideOnboardingFooter(5)).toBe(false) expect(shouldHideOnboardingFooter(ONBOARDING_COMPLETE_STEP)).toBe(true) }) }) diff --git a/apps/mobile/__tests__/components/onboarding/onboarding-template-packs.test.tsx b/apps/mobile/__tests__/components/onboarding/onboarding-template-packs.test.tsx new file mode 100644 index 000000000..a5d2ad38d --- /dev/null +++ b/apps/mobile/__tests__/components/onboarding/onboarding-template-packs.test.tsx @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + TEMPLATE_PACKS, + templatePackHabitTitleKey, + templatePackNameKey, + templatePackTagKey, +} from '@orbit/shared/utils' + +import { OnboardingTemplatePacks } from '@/components/onboarding/onboarding-template-packs' +import { PillButton } from '@/components/ui/pill-button' + +const TestRenderer = require('react-test-renderer') + +const mutate = vi.fn((_vars: unknown, opts?: { onSuccess?: () => void }) => opts?.onSuccess?.()) +const onCreated = vi.fn() +const onCreateOwn = vi.fn() +const onSkip = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => + params ? `${key}(${JSON.stringify(params)})` : key, + i18n: { language: 'en' }, + }), +})) + +vi.mock('@/lib/use-app-theme', () => ({ + useAppTheme: () => ({ currentScheme: 'purple', currentTheme: 'dark' }), +})) + +vi.mock('@/lib/theme', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createTokensV2: () => ({ + bgCard: '#111', + hairline: '#222', + hairlineStrong: '#333', + primary: '#7f46f7', + primarySoft: '#a07cff', + fgOnPrimary: '#fff', + fg1: '#fff', + fg2: '#ccc', + fg3: '#999', + fg4: '#666', + }), + tintFromPrimary: () => 'rgba(127,70,247,0.15)', + } +}) + +vi.mock('@/hooks/use-habits', () => ({ + useBulkCreateHabits: () => ({ mutate, isPending: false }), +})) + +vi.mock('@/hooks/use-app-toast', () => ({ + useAppToast: () => ({ showError: vi.fn() }), +})) + +function renderPicker() { + let tree: ReturnType + TestRenderer.act(() => { + tree = TestRenderer.create( + , + ) + }) + return tree +} + +function pressByLabel(tree: ReturnType, label: string) { + const node = tree.root.findAll( + (candidate: { props?: Record; type: unknown }) => + candidate.props?.accessibilityLabel === label && + typeof candidate.props?.onPress === 'function' && + typeof candidate.type !== 'string', + )[0] + TestRenderer.act(() => { + ;(node.props.onPress as () => void)() + }) +} + +describe('OnboardingTemplatePacks (mobile)', () => { + beforeEach(() => { + mutate.mockClear() + onCreated.mockClear() + onCreateOwn.mockClear() + onSkip.mockClear() + }) + + it('lists all four packs', () => { + const tree = renderPicker() + for (const pack of TEMPLATE_PACKS) { + const rows = tree.root.findAll( + (candidate: { props?: Record }) => + candidate.props?.accessibilityLabel === templatePackNameKey(pack.id), + ) + expect(rows.length).toBeGreaterThan(0) + } + }) + + it('invokes onCreateOwn from the pack grid', () => { + const tree = renderPicker() + pressByLabel(tree, 'onboarding.flow.templatePacks.createOwn') + expect(onCreateOwn).toHaveBeenCalledTimes(1) + }) + + it('selects a pack, drops a toggled-off habit, and bulk-creates with tags', () => { + const pack = TEMPLATE_PACKS[0] + if (!pack) throw new Error('expected a template pack') + const firstHabit = pack.habits[0] + const secondHabit = pack.habits[1] + if (!firstHabit || !secondHabit) throw new Error('expected pack habits') + + const tree = renderPicker() + pressByLabel(tree, templatePackNameKey(pack.id)) + pressByLabel(tree, templatePackHabitTitleKey(pack.id, firstHabit.key)) + + const cta = tree.root.findByType(PillButton) + TestRenderer.act(() => { + ;(cta.props.onPress as () => void)() + }) + + expect(mutate).toHaveBeenCalledTimes(1) + const call = mutate.mock.calls[0] + if (!call) throw new Error('expected a bulk-create call') + const payload = call[0] as { + habits: Array<{ title: string; isGeneral: boolean; tags: string[]; emoji: string }> + } + expect(payload.habits).toHaveLength(pack.habits.length - 1) + expect(payload.habits.map((habit) => habit.title)).not.toContain( + templatePackHabitTitleKey(pack.id, firstHabit.key), + ) + + const secondItem = payload.habits.find( + (habit) => habit.title === templatePackHabitTitleKey(pack.id, secondHabit.key), + ) + expect(secondItem?.emoji).toBe(secondHabit.emoji) + expect(secondItem?.tags).toEqual(secondHabit.tags.map((slug) => templatePackTagKey(slug))) + + expect(onCreated).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/mobile/__tests__/components/onboarding/onboarding-welcome.test.tsx b/apps/mobile/__tests__/components/onboarding/onboarding-welcome.test.tsx index 78cb3de88..62ea43735 100644 --- a/apps/mobile/__tests__/components/onboarding/onboarding-welcome.test.tsx +++ b/apps/mobile/__tests__/components/onboarding/onboarding-welcome.test.tsx @@ -14,13 +14,13 @@ describe('OnboardingWelcome helpers', () => { }) it('keeps the free-user display total one step shorter', () => { - expect(getOnboardingDisplayTotal(true)).toBe(6) - expect(getOnboardingDisplayTotal(false)).toBe(5) + expect(getOnboardingDisplayTotal(true)).toBe(7) + expect(getOnboardingDisplayTotal(false)).toBe(6) }) it('compresses the display step after the skipped goal step for free users', () => { expect(getOnboardingDisplayStep(0, false)).toBe(1) - expect(getOnboardingDisplayStep(4, false)).toBe(4) - expect(getOnboardingDisplayStep(4, true)).toBe(5) + expect(getOnboardingDisplayStep(5, false)).toBe(5) + expect(getOnboardingDisplayStep(5, true)).toBe(6) }) }) diff --git a/apps/mobile/__tests__/components/today/setup-checklist-card.test.tsx b/apps/mobile/__tests__/components/today/setup-checklist-card.test.tsx new file mode 100644 index 000000000..46ef95dab --- /dev/null +++ b/apps/mobile/__tests__/components/today/setup-checklist-card.test.tsx @@ -0,0 +1,140 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { createMockProfile } from '@orbit/shared/__tests__/factories' +import type { Profile } from '@orbit/shared/types' + +import { SetupChecklistCard } from '@/components/today/setup-checklist-card' + +const TestRenderer = require('react-test-renderer') + +type MockUiState = { + setupChecklistDismissed: boolean + setSetupChecklistDismissed: (dismissed: boolean) => void +} + +let mockProfile: Profile | undefined +const setDismissed = vi.fn() +const uiState: MockUiState = { + setupChecklistDismissed: false, + setSetupChecklistDismissed: setDismissed, +} + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => + params ? `${key}(${JSON.stringify(params)})` : key, + i18n: { language: 'en' }, + }), +})) + +vi.mock('@/lib/use-app-theme', () => ({ + useAppTheme: () => ({ currentScheme: 'purple', currentTheme: 'dark' }), +})) + +vi.mock('@/lib/theme', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createTokensV2: () => ({ + bgCard: 'rgba(255,255,255,0.04)', + hairline: 'rgba(255,255,255,0.1)', + hairlineStrong: 'rgba(255,255,255,0.18)', + primary: '#7f46f7', + fgOnPrimary: '#ffffff', + fg1: '#f8fafc', + fg3: '#90a1b9', + fg4: '#62748e', + }), + } +}) + +vi.mock('@/hooks/use-profile', () => ({ + useProfile: () => ({ profile: mockProfile }), +})) + +vi.mock('@/stores/ui-store', () => ({ + useUIStore: (selector: (state: MockUiState) => unknown) => selector(uiState), +})) + +function renderCard() { + let tree: ReturnType + TestRenderer.act(() => { + tree = TestRenderer.create() + }) + return tree +} + +function textValues(tree: ReturnType): unknown[] { + return tree.root + .findAllByType('Text') + .map((node: { props: { children: unknown } }) => node.props.children) +} + +function findDismissButton(tree: ReturnType) { + return tree.root.findAll( + (node: { props?: Record; type: unknown }) => + Boolean(node.props) && + node.props?.accessibilityRole === 'button' && + typeof node.props?.onPress === 'function' && + typeof node.type !== 'string', + )[0] +} + +describe('SetupChecklistCard (mobile)', () => { + beforeEach(() => { + mockProfile = createMockProfile({ + hasCreatedFirstHabit: false, + hasLoggedFirstHabit: false, + hasTriedAstra: false, + hasCompletedOnboardingChecklist: false, + }) + setDismissed.mockClear() + }) + + it('renders nothing while the profile is loading', () => { + mockProfile = undefined + expect(renderCard().toJSON()).toBeNull() + }) + + it('renders nothing once the checklist is completed server-side', () => { + mockProfile = createMockProfile({ hasCompletedOnboardingChecklist: true }) + expect(renderCard().toJSON()).toBeNull() + }) + + it('renders the three items and progress from the flags', () => { + mockProfile = createMockProfile({ + hasCreatedFirstHabit: true, + hasLoggedFirstHabit: false, + hasTriedAstra: false, + hasCompletedOnboardingChecklist: false, + }) + const texts = textValues(renderCard()) + expect(texts).toEqual( + expect.arrayContaining([ + 'today.setupChecklist.items.createHabit', + 'today.setupChecklist.items.logHabit', + 'today.setupChecklist.items.tryAstra', + 'today.setupChecklist.progress({"done":1,"total":3})', + ]), + ) + }) + + it('shows the completion message when all signals are done', () => { + mockProfile = createMockProfile({ + hasCreatedFirstHabit: true, + hasLoggedFirstHabit: true, + hasTriedAstra: true, + }) + const texts = textValues(renderCard()) + expect(texts).toContain('today.setupChecklist.complete') + }) + + it('persists dismissal when the close button is pressed', () => { + const tree = renderCard() + const button = findDismissButton(tree) + expect(button).toBeTruthy() + TestRenderer.act(() => { + button?.props.onPress?.() + }) + expect(setDismissed).toHaveBeenCalledWith(true) + }) +}) diff --git a/apps/mobile/__tests__/hooks/use-coach-mark.test.tsx b/apps/mobile/__tests__/hooks/use-coach-mark.test.tsx new file mode 100644 index 000000000..847cc9f2a --- /dev/null +++ b/apps/mobile/__tests__/hooks/use-coach-mark.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createMockProfile } from '@orbit/shared/__tests__/factories' +import type { Profile } from '@orbit/shared/types' + +const TestRenderer = require('react-test-renderer') + +let mockProfile: Profile | undefined +const startSectionReplay = vi.fn() +const storeState = { isActive: false, startSectionReplay } +const asyncStore: Record = {} + +vi.mock('expo-router', () => ({ + useFocusEffect: (callback: () => void | (() => void)) => { + callback() + }, +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (key: string) => asyncStore[key] ?? null), + setItem: vi.fn(async (key: string, value: string) => { + asyncStore[key] = value + }), + }, +})) + +vi.mock('@/hooks/use-profile', () => ({ + useProfile: () => ({ profile: mockProfile }), +})) + +vi.mock('@/stores/tour-store', () => ({ + useTourStore: { getState: () => storeState }, +})) + +import { useCoachMark } from '@/hooks/use-coach-mark' + +function HookHost() { + useCoachMark('coach-today') + return null +} + +describe('useCoachMark (mobile)', () => { + beforeEach(() => { + vi.useFakeTimers() + startSectionReplay.mockClear() + storeState.isActive = false + for (const key of Object.keys(asyncStore)) delete asyncStore[key] + mockProfile = createMockProfile({ + hasCompletedOnboarding: true, + hasCompletedTour: false, + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('triggers a section replay once for an unseen surface and records it as seen', async () => { + TestRenderer.act(() => { + TestRenderer.create() + }) + await vi.advanceTimersByTimeAsync(700) + + expect(startSectionReplay).toHaveBeenCalledWith('coach-today') + expect(asyncStore['orbit_tour_sections']).toContain('coach-today') + }) + + it('does not trigger again once the surface has been seen', async () => { + asyncStore['orbit_tour_sections'] = JSON.stringify({ 'coach-today': true }) + TestRenderer.act(() => { + TestRenderer.create() + }) + await vi.advanceTimersByTimeAsync(700) + + expect(startSectionReplay).not.toHaveBeenCalled() + }) + + it('does not trigger before onboarding is complete', async () => { + mockProfile = createMockProfile({ hasCompletedOnboarding: false }) + TestRenderer.act(() => { + TestRenderer.create() + }) + await vi.advanceTimersByTimeAsync(700) + + expect(startSectionReplay).not.toHaveBeenCalled() + }) +}) diff --git a/apps/mobile/__tests__/screens/today-screen.test.tsx b/apps/mobile/__tests__/screens/today-screen.test.tsx index 0ba75670d..f804234c3 100644 --- a/apps/mobile/__tests__/screens/today-screen.test.tsx +++ b/apps/mobile/__tests__/screens/today-screen.test.tsx @@ -144,6 +144,18 @@ vi.mock("@react-native-async-storage/async-storage", () => ({ }, })); +vi.mock("@/hooks/use-habit-queries", () => ({ + useTotalHabitCount: () => 10, +})); + +vi.mock("@/hooks/use-coach-mark", () => ({ + useCoachMark: () => {}, +})); + +vi.mock("@/components/today/setup-checklist-card", () => ({ + SetupChecklistCard: () => null, +})); + vi.mock("expo-router", () => ({ useLocalSearchParams: () => dateParamState.value ? { date: dateParamState.value } : {}, diff --git a/apps/mobile/__tests__/stores/ui-store.test.ts b/apps/mobile/__tests__/stores/ui-store.test.ts index 2f2950575..2d96af04a 100644 --- a/apps/mobile/__tests__/stores/ui-store.test.ts +++ b/apps/mobile/__tests__/stores/ui-store.test.ts @@ -213,6 +213,7 @@ describe("mobile ui store", () => { selectedFrequency: null, selectedTagIds: [], showCompleted: true, + setupChecklistDismissed: false, }); }); diff --git a/apps/mobile/app/(tabs)/calendar.tsx b/apps/mobile/app/(tabs)/calendar.tsx index 167628d68..72136e397 100644 --- a/apps/mobile/app/(tabs)/calendar.tsx +++ b/apps/mobile/app/(tabs)/calendar.tsx @@ -32,6 +32,7 @@ import { formatAPIDate, parseAPIDate } from "@orbit/shared/utils"; import type { CalendarDayEntry } from "@orbit/shared/types/calendar"; import { useCalendarData } from "@/hooks/use-habits"; import { useProfile } from "@/hooks/use-profile"; +import { useCoachMark } from "@/hooks/use-coach-mark"; import { useTimeFormat } from "@/hooks/use-time-format"; import { useHorizontalSwipe } from "@/hooks/use-horizontal-swipe"; import { createTokensV2 } from "@/lib/theme"; @@ -70,6 +71,7 @@ export default function CalendarScreen() { const router = useRouter(); const { profile } = useProfile(); const { displayTime } = useTimeFormat(); + useCoachMark("coach-calendar"); const { currentScheme, currentTheme } = useAppTheme(); const tokens = useMemo( () => createTokensV2(currentScheme, currentTheme), diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index 85cc68814..655e1b029 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -39,6 +39,8 @@ import { useDeleteHabit, } from "@/hooks/use-habits"; import { useTags } from "@/hooks/use-tags"; +import { useTotalHabitCount } from "@/hooks/use-habit-queries"; +import { useCoachMark } from "@/hooks/use-coach-mark"; import { useUIStore } from "@/stores/ui-store"; import { HabitList, type HabitListHandle } from "@/components/habit-list"; import { CreateHabitModal } from "@/components/habits/create-habit-modal"; @@ -52,6 +54,7 @@ import { GradientTop } from "@/components/ui/gradient-top"; import { TrialBanner } from "@/components/ui/trial-banner"; import { TodayHabitsHeader } from "@/components/today/today-habits-header"; import { ReviewReminderCard } from "@/components/review-reminder-card"; +import { SetupChecklistCard } from "@/components/today/setup-checklist-card"; import { useHorizontalSwipe } from "@/hooks/use-horizontal-swipe"; import type { MenuAnchorRect } from "@/lib/anchored-menu"; import { useBulkActions } from "@/hooks/use-bulk-actions"; @@ -126,6 +129,8 @@ export default function TodayScreen() { const { profile } = useProfile(); const reviewReminder = useReviewReminder(profile); const { tags } = useTags(); + const totalHabitCount = useTotalHabitCount(); + useCoachMark("coach-today"); const deleteHabit = useDeleteHabit(); const activeView = useUIStore((s) => s.activeView); @@ -839,6 +844,8 @@ export default function TodayScreen() { + {currentActiveView === "today" ? : null} + {reviewReminder.shouldShow ? ( = 5} showControlsMenu={showControlsMenu} controlsMenuAnchorRect={controlsMenuAnchorRect} showFreqMenu={showFreqMenu} @@ -964,6 +972,7 @@ export default function TodayScreen() { swipeGesture, tags, toggleTagFilter, + totalHabitCount, freqMenuAnchorRect, showFreqMenu, ], diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 9a46c445c..5bf064225 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -37,7 +37,6 @@ import { import { dismissTopOverlay } from '@/lib/overlay-stack' import { buildUpgradeHref } from '@/lib/upgrade-route' import { useUIStore } from '@/stores/ui-store' -import { useTourStore } from '@/stores/tour-store' import { OnboardingFlow } from '@/components/onboarding/onboarding-flow' import { CalendarImportPrompt } from '@/components/onboarding/calendar-import-prompt' import { BottomTabBar, type BottomTabId } from '@/components/navigation/bottom-tab-bar' @@ -299,24 +298,10 @@ function GlobalOverlays({ showSharedCelebrations: boolean }>) { const streakFreezeRef = useRef(null) - const tourStarted = useRef(false) const hasProAccess = profile?.hasProAccess ?? false const canViewGamification = profile?.canViewGamification ?? false const gamification = useGamificationProfile(canViewGamification) - useEffect(() => { - if ( - profile && - profile.hasCompletedOnboarding && - !profile.hasCompletedTour && - !tourStarted.current && - !useTourStore.getState().isActive - ) { - tourStarted.current = true - setTimeout(() => useTourStore.getState().startFullTour(), 500) - } - }, [profile]) - return ( <> diff --git a/apps/mobile/app/chat.tsx b/apps/mobile/app/chat.tsx index 013256b53..621fa6c5c 100644 --- a/apps/mobile/app/chat.tsx +++ b/apps/mobile/app/chat.tsx @@ -30,6 +30,7 @@ import { createStyles } from "@/app/chat.styles"; import { createTokensV2 } from "@/lib/theme"; import { useAppTheme } from "@/lib/use-app-theme"; import { useOffline } from "@/hooks/use-offline"; +import { useCoachMark } from "@/hooks/use-coach-mark"; export default function ChatScreen() { const { t } = useTranslation(); @@ -43,6 +44,7 @@ export default function ChatScreen() { const { isOnline } = useOffline(); const goBackOrFallback = useGoBackOrFallback(); const insets = useSafeAreaInsets(); + useCoachMark("coach-astra"); const chatAreaRef = useRef(null); const chatInputRef = useRef(null); const chatVoiceRef = useRef(null); diff --git a/apps/mobile/components/onboarding/onboarding-flow.tsx b/apps/mobile/components/onboarding/onboarding-flow.tsx index c24f83bda..928ec6b68 100644 --- a/apps/mobile/components/onboarding/onboarding-flow.tsx +++ b/apps/mobile/components/onboarding/onboarding-flow.tsx @@ -8,7 +8,9 @@ import { getOnboardingDisplayTotal, getOnboardingNextStep, getOnboardingPreviousStep, + ONBOARDING_COMPLETE_HABIT_STEP, ONBOARDING_COMPLETE_STEP, + ONBOARDING_CREATE_HABIT_STEP, shouldHideOnboardingFooter, } from '@orbit/shared/utils/onboarding' import { profileKeys } from '@orbit/shared/query' @@ -23,6 +25,7 @@ import { OnboardingCompleteHabit } from './onboarding-complete-habit' import { OnboardingCreateGoal } from './onboarding-create-goal' import { OnboardingFeatures } from './onboarding-features' import { OnboardingComplete } from './onboarding-complete' +import { OnboardingTemplatePacks } from './onboarding-template-packs' import { KeyboardAwareScrollView } from '@/components/ui/keyboard-aware-scroll-view' import { GradientTop } from '@/components/ui/gradient-top' import { PillButton } from '@/components/ui/pill-button' @@ -87,6 +90,8 @@ interface OnboardingStepContentProps { onHabitCompleted: () => void onGoalCreated: () => void onGoalSkipped: () => void + onPackCreateOwn: () => void + onAdvancePastHabits: () => void onFinish: () => void } @@ -100,6 +105,8 @@ function OnboardingStepContent({ onHabitCompleted, onGoalCreated, onGoalSkipped, + onPackCreateOwn, + onAdvancePastHabits, onFinish, }: Readonly) { if (viewingAstra) return @@ -107,13 +114,22 @@ function OnboardingStepContent({ case 0: return case 1: + return ( + + ) + case 2: return ( ) - case 2: + case 3: return ( ) - case 3: + case 4: return ( ) - case 4: - return case 5: + return + case 6: return ( diff --git a/apps/mobile/components/onboarding/onboarding-template-packs.tsx b/apps/mobile/components/onboarding/onboarding-template-packs.tsx new file mode 100644 index 000000000..6fd0a311a --- /dev/null +++ b/apps/mobile/components/onboarding/onboarding-template-packs.tsx @@ -0,0 +1,310 @@ +import { useCallback, useMemo, useState } from 'react' +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native' +import { Check, ChevronRight } from 'lucide-react-native' +import { useTranslation } from 'react-i18next' +import { + buildBulkItemsFromPack, + getFriendlyErrorMessage, + getTemplatePackById, + TEMPLATE_PACKS, + templatePackDescriptionKey, + templatePackHabitTitleKey, + templatePackNameKey, +} from '@orbit/shared/utils' +import { useBulkCreateHabits } from '@/hooks/use-habits' +import { useAppToast } from '@/hooks/use-app-toast' +import { PillButton } from '@/components/ui/pill-button' +import { createTokensV2, tintFromPrimary, type AppTokensV2 } from '@/lib/theme' +import { useAppTheme } from '@/lib/use-app-theme' + +interface OnboardingTemplatePacksProps { + onCreated: () => void + onCreateOwn: () => void + onSkip: () => void +} + +/** + * Starter template-pack picker: choose a pack, toggle habits off, then bulk-create. + * Secondary actions branch to manual create or skip. + */ +export function OnboardingTemplatePacks({ + onCreated, + onCreateOwn, + onSkip, +}: Readonly) { + const { t } = useTranslation() + const translate = useCallback( + (key: string, values?: Record) => t(key, values), + [t], + ) + const { currentScheme, currentTheme } = useAppTheme() + const tokens = useMemo( + () => createTokensV2(currentScheme, currentTheme), + [currentScheme, currentTheme], + ) + const styles = useMemo(() => createStyles(tokens), [tokens]) + const { showError } = useAppToast() + const bulkCreate = useBulkCreateHabits() + const [selectedPackId, setSelectedPackId] = useState(null) + const [disabledKeys, setDisabledKeys] = useState>(new Set()) + + const selectedPack = selectedPackId ? getTemplatePackById(selectedPackId) : undefined + const isCreating = bulkCreate.isPending + + const toggleHabit = useCallback((key: string) => { + setDisabledKeys((previous) => { + const next = new Set(previous) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + }, []) + + const enabledCount = selectedPack + ? selectedPack.habits.filter((habit) => !disabledKeys.has(habit.key)).length + : 0 + + const handleAdd = useCallback(() => { + if (!selectedPack || enabledCount === 0 || isCreating) return + const items = buildBulkItemsFromPack(selectedPack, disabledKeys, translate) + bulkCreate.mutate( + { habits: items }, + { + onSuccess: () => onCreated(), + onError: (error: unknown) => + showError( + getFriendlyErrorMessage(error, translate, 'errors.createHabit', 'habit'), + ), + }, + ) + }, [selectedPack, enabledCount, isCreating, disabledKeys, translate, bulkCreate, onCreated, showError]) + + if (!selectedPack) { + return ( + + {t('onboarding.flow.templatePacks.title')} + {t('onboarding.flow.templatePacks.subtitle')} + + + {TEMPLATE_PACKS.map((pack) => ( + setSelectedPackId(pack.id)} + style={styles.packRow} + accessibilityRole="button" + accessibilityLabel={t(templatePackNameKey(pack.id))} + > + + {pack.emoji} + + + {t(templatePackNameKey(pack.id))} + {t(templatePackDescriptionKey(pack.id))} + + + + ))} + + + + + + {t('onboarding.flow.templatePacks.createOwn')} + + + + {t('onboarding.flow.templatePacks.skip')} + + + + ) + } + + return ( + + {t('onboarding.flow.templatePacks.customizeTitle')} + {t('onboarding.flow.templatePacks.customizeSubtitle')} + + + {selectedPack.habits.map((habit) => { + const enabled = !disabledKeys.has(habit.key) + return ( + toggleHabit(habit.key)} + style={[styles.habitRow, { opacity: enabled ? 1 : 0.55 }]} + accessibilityRole="button" + accessibilityState={{ selected: enabled }} + accessibilityLabel={t(templatePackHabitTitleKey(selectedPack.id, habit.key))} + > + {habit.emoji} + + {t(templatePackHabitTitleKey(selectedPack.id, habit.key))} + + + {enabled ? : null} + + + ) + })} + + + + : undefined + } + > + {enabledCount === 1 + ? t('onboarding.flow.templatePacks.createCtaOne') + : t('onboarding.flow.templatePacks.createCta', { count: enabledCount })} + + setSelectedPackId(null)} + style={styles.secondaryButton} + accessibilityRole="button" + accessibilityLabel={t('onboarding.flow.back')} + > + {t('onboarding.flow.back')} + + + + ) +} + +function createStyles(tokens: AppTokensV2) { + return StyleSheet.create({ + container: { + gap: 16, + paddingTop: 16, + paddingBottom: 12, + }, + title: { + fontFamily: 'Rubik_500Medium', + fontSize: 24, + letterSpacing: -0.24, + lineHeight: 31, + color: tokens.fg1, + textAlign: 'center', + }, + subtitle: { + fontFamily: 'Rubik_400Regular', + fontSize: 15, + lineHeight: 23, + color: tokens.fg2, + textAlign: 'center', + }, + list: { + gap: 10, + }, + packRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 14, + padding: 14, + borderRadius: 16, + borderWidth: 1, + borderColor: tokens.hairline, + backgroundColor: tokens.bgCard, + }, + packEmoji: { + width: 44, + height: 44, + borderRadius: 999, + alignItems: 'center', + justifyContent: 'center', + }, + packEmojiText: { + fontSize: 22, + }, + packText: { + flex: 1, + gap: 3, + }, + packName: { + fontFamily: 'Rubik_500Medium', + fontSize: 16, + color: tokens.fg1, + }, + packDesc: { + fontFamily: 'Rubik_400Regular', + fontSize: 13, + lineHeight: 18, + color: tokens.fg3, + }, + habitList: { + gap: 8, + }, + habitRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingVertical: 12, + paddingHorizontal: 14, + borderRadius: 14, + backgroundColor: tokens.bgCard, + }, + habitEmoji: { + fontSize: 20, + }, + habitTitle: { + flex: 1, + fontFamily: 'Rubik_400Regular', + fontSize: 15, + color: tokens.fg1, + }, + check: { + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + }, + checkOn: { + backgroundColor: tokens.primary, + }, + checkOff: { + borderWidth: 2, + borderColor: tokens.hairlineStrong, + }, + ctaWrap: { + marginTop: 8, + gap: 4, + }, + secondaryActions: { + alignItems: 'center', + gap: 4, + marginTop: 4, + }, + secondaryButton: { + minHeight: 44, + paddingHorizontal: 12, + alignItems: 'center', + justifyContent: 'center', + }, + secondaryEmphasis: { + fontFamily: 'Rubik_500Medium', + fontSize: 13, + color: tokens.primarySoft, + }, + secondaryText: { + fontFamily: 'Rubik_400Regular', + fontSize: 13, + color: tokens.fg3, + }, + }) +} diff --git a/apps/mobile/components/today/setup-checklist-card.tsx b/apps/mobile/components/today/setup-checklist-card.tsx new file mode 100644 index 000000000..9b655b93e --- /dev/null +++ b/apps/mobile/components/today/setup-checklist-card.tsx @@ -0,0 +1,159 @@ +import { useMemo } from 'react' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { useTranslation } from 'react-i18next' +import { Check, X } from 'lucide-react-native' +import type { Profile } from '@orbit/shared/types' +import { createTokensV2, type AppTokensV2 } from '@/lib/theme' +import { useAppTheme } from '@/lib/use-app-theme' +import { useProfile } from '@/hooks/use-profile' +import { useUIStore } from '@/stores/ui-store' + +const CHECKLIST_ITEMS: readonly { key: string; flag: keyof Profile }[] = [ + { key: 'createHabit', flag: 'hasCreatedFirstHabit' }, + { key: 'logHabit', flag: 'hasLoggedFirstHabit' }, + { key: 'tryAstra', flag: 'hasTriedAstra' }, +] + +/** Auto-tracked first-run setup checklist on Today; hides once completed or dismissed. */ +export function SetupChecklistCard() { + const { t } = useTranslation() + const { currentScheme, currentTheme } = useAppTheme() + const tokens = useMemo( + () => createTokensV2(currentScheme, currentTheme), + [currentScheme, currentTheme], + ) + const styles = useMemo(() => createStyles(tokens), [tokens]) + const { profile } = useProfile() + const dismissed = useUIStore((state) => state.setupChecklistDismissed) + const setDismissed = useUIStore((state) => state.setSetupChecklistDismissed) + + if (!profile || dismissed || profile.hasCompletedOnboardingChecklist) { + return null + } + + const states = CHECKLIST_ITEMS.map((item) => Boolean(profile[item.flag])) + const doneCount = states.filter(Boolean).length + const total = CHECKLIST_ITEMS.length + const allDone = doneCount === total + + return ( + + + + {t('today.setupChecklist.title')} + + {allDone + ? t('today.setupChecklist.complete') + : t('today.setupChecklist.subtitle')} + + + setDismissed(true)} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('today.setupChecklist.dismiss')} + > + + + + + + {CHECKLIST_ITEMS.map((item, index) => { + const done = states[index] + return ( + + + {done ? ( + + ) : null} + + + {t(`today.setupChecklist.items.${item.key}`)} + + + ) + })} + + + + {t('today.setupChecklist.progress', { done: doneCount, total })} + + + ) +} + +function createStyles(tokens: AppTokensV2) { + return StyleSheet.create({ + card: { + gap: 12, + marginHorizontal: 20, + marginBottom: 16, + borderRadius: 18, + borderWidth: 1, + borderColor: tokens.hairline, + backgroundColor: tokens.bgCard, + padding: 16, + }, + headerRow: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + gap: 12, + }, + headerText: { + flex: 1, + gap: 3, + }, + title: { + fontFamily: 'Rubik_500Medium', + fontSize: 18, + color: tokens.fg1, + }, + subtitle: { + fontFamily: 'Rubik_400Regular', + fontSize: 13, + lineHeight: 18, + color: tokens.fg3, + }, + items: { + gap: 10, + }, + itemRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + check: { + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + }, + checkDone: { + backgroundColor: tokens.primary, + }, + checkPending: { + borderWidth: 2, + borderColor: tokens.hairlineStrong, + }, + itemLabel: { + flex: 1, + fontFamily: 'Rubik_400Regular', + fontSize: 15, + color: tokens.fg1, + }, + itemLabelDone: { + color: tokens.fg3, + textDecorationLine: 'line-through', + }, + progress: { + fontFamily: 'Roboto_400Regular', + fontSize: 12, + color: tokens.fg3, + fontVariant: ['tabular-nums'], + }, + }) +} diff --git a/apps/mobile/components/today/today-habits-header.tsx b/apps/mobile/components/today/today-habits-header.tsx index 738b852d9..07ad7fb1c 100644 --- a/apps/mobile/components/today/today-habits-header.tsx +++ b/apps/mobile/components/today/today-habits-header.tsx @@ -189,6 +189,7 @@ interface TodayHabitsHeaderProps { showCompleted: boolean; isFetching: boolean; allCollapsed: boolean; + showFilters: boolean; showControlsMenu: boolean; controlsMenuAnchorRect: MenuAnchorRect | null; showFreqMenu: boolean; @@ -239,6 +240,7 @@ export function TodayHabitsHeader({ showCompleted, isFetching, allCollapsed, + showFilters, showControlsMenu, controlsMenuAnchorRect, showFreqMenu, @@ -302,6 +304,8 @@ export function TodayHabitsHeader({ {dayProgress.done}/{dayProgress.total} ) : null} + {showFilters ? ( + <> + + ) : null} } > @@ -402,20 +408,22 @@ export function TodayHabitsHeader({ /> ) : null} - - {tags.map((tag) => ( - onTagToggle(tag.id)} - /> - ))} - + {showFilters ? ( + + {tags.map((tag) => ( + onTagToggle(tag.id)} + /> + ))} + + ) : null} { + const { replaySection } = useTourStore.getState() endTour() + if (replaySection && COACH_MARK_SECTIONS.includes(replaySection)) { + return + } try { await apiClient(API.profile.tour, { method: 'PUT' }) } catch { diff --git a/apps/mobile/components/tour/tour-replay-modal.tsx b/apps/mobile/components/tour/tour-replay-modal.tsx index 63c6ee6f4..8ccb2aba3 100644 --- a/apps/mobile/components/tour/tour-replay-modal.tsx +++ b/apps/mobile/components/tour/tour-replay-modal.tsx @@ -59,6 +59,9 @@ export function TourReplayModal({ visible, onClose }: TourReplayModalProps) { chat: false, calendar: false, profile: false, + 'coach-today': false, + 'coach-astra': false, + 'coach-calendar': false, }) const availableSections = TOUR_SECTIONS.filter((section) => profile?.hasProAccess ? true : section !== 'goals', diff --git a/apps/mobile/hooks/use-coach-mark.ts b/apps/mobile/hooks/use-coach-mark.ts new file mode 100644 index 000000000..62ee40420 --- /dev/null +++ b/apps/mobile/hooks/use-coach-mark.ts @@ -0,0 +1,48 @@ +import { useCallback, useRef } from 'react' +import { useFocusEffect } from 'expo-router' +import AsyncStorage from '@react-native-async-storage/async-storage' +import type { TourSection } from '@orbit/shared/types' +import { useTourStore } from '@/stores/tour-store' +import { useProfile } from '@/hooks/use-profile' + +const SEEN_KEY = 'orbit_tour_sections' + +/** + * Fires a one-time first-run coach-mark spotlight when a surface gains focus, once + * onboarding is done, the full tour is inactive, and the surface has not been seen before. + */ +export function useCoachMark(section: TourSection) { + const { profile } = useProfile() + const triggered = useRef(false) + + useFocusEffect( + useCallback(() => { + if (triggered.current) return + if (!profile?.hasCompletedOnboarding || profile.hasCompletedTour) return + if (useTourStore.getState().isActive) return + + let cancelled = false + const timer = setTimeout(() => { + void (async () => { + try { + const raw = await AsyncStorage.getItem(SEEN_KEY) + const seen = raw ? (JSON.parse(raw) as Record) : {} + if (cancelled || seen[section] === true) return + if (useTourStore.getState().isActive) return + triggered.current = true + seen[section] = true + await AsyncStorage.setItem(SEEN_KEY, JSON.stringify(seen)) + useTourStore.getState().startSectionReplay(section) + } catch { + return + } + })() + }, 600) + + return () => { + cancelled = true + clearTimeout(timer) + } + }, [profile?.hasCompletedOnboarding, profile?.hasCompletedTour, section]), + ) +} diff --git a/apps/web/__tests__/app/today-page.test.tsx b/apps/web/__tests__/app/today-page.test.tsx index c06b2e967..907eb6472 100644 --- a/apps/web/__tests__/app/today-page.test.tsx +++ b/apps/web/__tests__/app/today-page.test.tsx @@ -5,7 +5,10 @@ import { createMockHabit, createMockProfile } from '@orbit/shared/__tests__/fact import type { NormalizedHabit } from '@orbit/shared/types/habit' import { computeHabitCardStatus } from '@orbit/shared/utils' -const { useHabitsMock } = vi.hoisted(() => ({ useHabitsMock: vi.fn() })) +const { useHabitsMock, totalHabitCountRef } = vi.hoisted(() => ({ + useHabitsMock: vi.fn(), + totalHabitCountRef: { value: 10 }, +})) const dateParamState = { value: null as string | null } @@ -126,6 +129,18 @@ vi.mock('@/hooks/use-habits', () => ({ useBulkSkipHabits: () => ({ mutateAsync: bulkSkipMutateAsync }), })) +vi.mock('@/hooks/use-habit-queries', () => ({ + useTotalHabitCount: () => totalHabitCountRef.value, +})) + +vi.mock('@/hooks/use-coach-mark', () => ({ + useCoachMark: () => {}, +})) + +vi.mock('@/components/today/setup-checklist-card', () => ({ + SetupChecklistCard: () => null, +})) + vi.mock('@/components/habits/habit-list', () => ({ HabitList: React.forwardRef(function MockHabitList(props: Record, ref) { React.useImperativeHandle(ref, () => habitListHandle) @@ -194,12 +209,26 @@ describe('TodayPage bulk parent prompts', () => { uiState.selectedTagIds = [] uiState.showCompleted = false uiState.selectedHabitIds = new Set() + totalHabitCountRef.value = 10 }) afterEach(() => { vi.useRealTimers() }) + it('hides the advanced filter row until the user has five habits', () => { + totalHabitCountRef.value = 4 + const { unmount } = render() + expect(screen.queryByTestId('today-utility-row')).toBeNull() + expect(screen.getByTestId('habit-list')).toBeInTheDocument() + unmount() + + totalHabitCountRef.value = 5 + render() + expect(screen.getByTestId('today-utility-row')).toBeInTheDocument() + expect(screen.getByTestId('habit-list')).toBeInTheDocument() + }) + it('suppresses descendant successes when bulk log finishes', async () => { const root = createMockHabit({ id: 'root', title: 'Root', hasSubHabits: true }) const parent = createMockHabit({ id: 'parent', title: 'Parent', parentId: 'root', hasSubHabits: true }) diff --git a/apps/web/__tests__/components/onboarding/onboarding-flow.test.tsx b/apps/web/__tests__/components/onboarding/onboarding-flow.test.tsx index eeda6a5e5..0300a86bf 100644 --- a/apps/web/__tests__/components/onboarding/onboarding-flow.test.tsx +++ b/apps/web/__tests__/components/onboarding/onboarding-flow.test.tsx @@ -34,6 +34,23 @@ vi.mock('@/components/onboarding/onboarding-welcome', () => ({ vi.mock('@/components/onboarding/onboarding-meet-astra', () => ({ OnboardingMeetAstra: () =>
Meet Astra
, })) +vi.mock('@/components/onboarding/onboarding-template-packs', () => ({ + OnboardingTemplatePacks: ({ + onCreated, + onCreateOwn, + onSkip, + }: { + onCreated: () => void + onCreateOwn: () => void + onSkip: () => void + }) => ( +
+ + + +
+ ), +})) vi.mock('@/components/onboarding/onboarding-create-habit', () => ({ OnboardingCreateHabit: ({ onCreated }: { onCreated: (id: string, title: string) => void }) => (
@@ -100,12 +117,15 @@ describe('OnboardingFlow', () => { expect(screen.getByTestId('step-meet-astra')).toBeInTheDocument() }) - it('advances through all steps via interactions', () => { + it('advances through the create-my-own branch via interactions', () => { render() fireEvent.click(screen.getByText('onboarding.flow.begin')) expect(screen.getByTestId('step-meet-astra')).toBeInTheDocument() fireEvent.click(screen.getByText('onboarding.flow.next')) + expect(screen.getByTestId('step-template-packs')).toBeInTheDocument() + + fireEvent.click(screen.getByText('Create Own')) expect(screen.getByTestId('step-create-habit')).toBeInTheDocument() fireEvent.click(screen.getByText('Create')) @@ -115,6 +135,16 @@ describe('OnboardingFlow', () => { expect(screen.getByTestId('step-create-goal')).toBeInTheDocument() }) + it('jumps past the manual habit steps after a pack is created', () => { + render() + fireEvent.click(screen.getByText('onboarding.flow.begin')) + fireEvent.click(screen.getByText('onboarding.flow.next')) + expect(screen.getByTestId('step-template-packs')).toBeInTheDocument() + + fireEvent.click(screen.getByText('Pack Created')) + expect(screen.getByTestId('step-create-goal')).toBeInTheDocument() + }) + it('skips to final step when skip is clicked', () => { render() fireEvent.click(screen.getByText('onboarding.flow.skip')) diff --git a/apps/web/__tests__/components/onboarding/onboarding-template-packs.test.tsx b/apps/web/__tests__/components/onboarding/onboarding-template-packs.test.tsx new file mode 100644 index 000000000..fe78e0e92 --- /dev/null +++ b/apps/web/__tests__/components/onboarding/onboarding-template-packs.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { + TEMPLATE_PACKS, + templatePackHabitTitleKey, + templatePackNameKey, + templatePackTagKey, +} from '@orbit/shared/utils' + +const mutate = vi.fn((_vars: unknown, opts?: { onSuccess?: () => void }) => opts?.onSuccess?.()) +const onCreated = vi.fn() +const onCreateOwn = vi.fn() +const onSkip = vi.fn() + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, params?: Record) => + params ? `${key}(${JSON.stringify(params)})` : key, +})) + +vi.mock('@/hooks/use-habits', () => ({ + useBulkCreateHabits: () => ({ mutate, isPending: false }), +})) + +vi.mock('@/hooks/use-app-toast', () => ({ + useAppToast: () => ({ showError: vi.fn() }), +})) + +import { OnboardingTemplatePacks } from '@/components/onboarding/onboarding-template-packs' + +function renderPicker() { + return render( + , + ) +} + +describe('OnboardingTemplatePacks', () => { + beforeEach(() => { + mutate.mockClear() + onCreated.mockClear() + onCreateOwn.mockClear() + onSkip.mockClear() + }) + + it('lists all four packs', () => { + renderPicker() + for (const pack of TEMPLATE_PACKS) { + expect(screen.getByText(templatePackNameKey(pack.id))).toBeTruthy() + } + }) + + it('invokes onCreateOwn from the pack grid', () => { + renderPicker() + fireEvent.click(screen.getByText('onboarding.flow.templatePacks.createOwn')) + expect(onCreateOwn).toHaveBeenCalledTimes(1) + }) + + it('selects a pack, drops a toggled-off habit, and bulk-creates the rest with tags', () => { + const pack = TEMPLATE_PACKS[0] + if (!pack) throw new Error('expected a template pack') + const firstHabit = pack.habits[0] + const secondHabit = pack.habits[1] + if (!firstHabit || !secondHabit) throw new Error('expected pack habits') + + renderPicker() + fireEvent.click(screen.getByText(templatePackNameKey(pack.id))) + fireEvent.click(screen.getByText(templatePackHabitTitleKey(pack.id, firstHabit.key))) + fireEvent.click(screen.getByRole('button', { name: /createCta/ })) + + expect(mutate).toHaveBeenCalledTimes(1) + const call = mutate.mock.calls[0] + if (!call) throw new Error('expected a bulk-create call') + const payload = call[0] as { + habits: Array<{ title: string; isGeneral: boolean; tags: string[]; emoji: string }> + } + expect(payload.habits).toHaveLength(pack.habits.length - 1) + + const titles = payload.habits.map((habit) => habit.title) + expect(titles).not.toContain(templatePackHabitTitleKey(pack.id, firstHabit.key)) + expect(payload.habits.every((habit) => habit.isGeneral === false)).toBe(true) + + const secondItem = payload.habits.find( + (habit) => habit.title === templatePackHabitTitleKey(pack.id, secondHabit.key), + ) + expect(secondItem?.emoji).toBe(secondHabit.emoji) + expect(secondItem?.tags).toEqual(secondHabit.tags.map((slug) => templatePackTagKey(slug))) + + expect(onCreated).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/web/__tests__/components/today/setup-checklist-card.test.tsx b/apps/web/__tests__/components/today/setup-checklist-card.test.tsx new file mode 100644 index 000000000..359b2ad3f --- /dev/null +++ b/apps/web/__tests__/components/today/setup-checklist-card.test.tsx @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { createMockProfile } from '@orbit/shared/__tests__/factories' +import type { Profile } from '@orbit/shared/types' + +let mockProfile: Profile | undefined +let mockDismissed = false +const setDismissed = vi.fn() + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, params?: Record) => + params ? `${key}(${JSON.stringify(params)})` : key, +})) + +vi.mock('@/hooks/use-profile', () => ({ + useProfile: () => ({ profile: mockProfile }), +})) + +vi.mock('@/stores/ui-store', () => ({ + useUIStore: ( + selector: (state: { + setupChecklistDismissed: boolean + setSetupChecklistDismissed: typeof setDismissed + }) => T, + ) => + selector({ + setupChecklistDismissed: mockDismissed, + setSetupChecklistDismissed: setDismissed, + }), +})) + +import { SetupChecklistCard } from '@/components/today/setup-checklist-card' + +describe('SetupChecklistCard', () => { + beforeEach(() => { + mockProfile = createMockProfile({ + hasCreatedFirstHabit: false, + hasLoggedFirstHabit: false, + hasTriedAstra: false, + hasCompletedOnboardingChecklist: false, + }) + mockDismissed = false + setDismissed.mockClear() + }) + + it('renders nothing while the profile is loading', () => { + mockProfile = undefined + const { container } = render() + expect(container.innerHTML).toBe('') + }) + + it('renders nothing once the checklist is completed server-side', () => { + mockProfile = createMockProfile({ hasCompletedOnboardingChecklist: true }) + const { container } = render() + expect(container.innerHTML).toBe('') + }) + + it('renders nothing when dismissed', () => { + mockDismissed = true + const { container } = render() + expect(container.innerHTML).toBe('') + }) + + it('reflects per-item completion and progress from the profile flags', () => { + mockProfile = createMockProfile({ + hasCreatedFirstHabit: true, + hasLoggedFirstHabit: false, + hasTriedAstra: false, + hasCompletedOnboardingChecklist: false, + }) + render() + + expect(screen.getByTestId('setup-checklist-progress').textContent).toBe( + 'today.setupChecklist.progress({"done":1,"total":3})', + ) + const items = screen.getAllByRole('listitem') + expect(items).toHaveLength(3) + expect(items[0]?.getAttribute('data-done')).toBe('true') + expect(items[1]?.getAttribute('data-done')).toBe('false') + expect(items[2]?.getAttribute('data-done')).toBe('false') + }) + + it('shows the completion message when all three signals are done', () => { + mockProfile = createMockProfile({ + hasCreatedFirstHabit: true, + hasLoggedFirstHabit: true, + hasTriedAstra: true, + hasCompletedOnboardingChecklist: false, + }) + render() + + expect(screen.getByText('today.setupChecklist.complete')).toBeTruthy() + expect(screen.getByTestId('setup-checklist-progress').textContent).toBe( + 'today.setupChecklist.progress({"done":3,"total":3})', + ) + }) + + it('persists dismissal when the close button is clicked', () => { + render() + fireEvent.click(screen.getByLabelText('today.setupChecklist.dismiss')) + expect(setDismissed).toHaveBeenCalledWith(true) + }) +}) diff --git a/apps/web/__tests__/hooks/use-coach-mark.test.tsx b/apps/web/__tests__/hooks/use-coach-mark.test.tsx new file mode 100644 index 000000000..560795af2 --- /dev/null +++ b/apps/web/__tests__/hooks/use-coach-mark.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook } from '@testing-library/react' +import { createMockProfile } from '@orbit/shared/__tests__/factories' +import type { Profile } from '@orbit/shared/types' + +let mockProfile: Profile | undefined +const startSectionReplay = vi.fn() +const storeState = { isActive: false, startSectionReplay } + +vi.mock('@/hooks/use-profile', () => ({ + useProfile: () => ({ profile: mockProfile }), +})) + +vi.mock('@/stores/tour-store', () => ({ + useTourStore: { getState: () => storeState }, +})) + +import { useCoachMark } from '@/hooks/use-coach-mark' + +describe('useCoachMark', () => { + beforeEach(() => { + vi.useFakeTimers() + startSectionReplay.mockClear() + storeState.isActive = false + localStorage.clear() + mockProfile = createMockProfile({ + hasCompletedOnboarding: true, + hasCompletedTour: false, + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('triggers a section replay once for an unseen surface and records it as seen', () => { + renderHook(() => useCoachMark('coach-today')) + vi.advanceTimersByTime(700) + + expect(startSectionReplay).toHaveBeenCalledWith('coach-today') + const seen = JSON.parse(localStorage.getItem('orbit_tour_sections') ?? '{}') + expect(seen['coach-today']).toBe(true) + }) + + it('does not trigger again once the surface has been seen', () => { + localStorage.setItem('orbit_tour_sections', JSON.stringify({ 'coach-today': true })) + renderHook(() => useCoachMark('coach-today')) + vi.advanceTimersByTime(700) + + expect(startSectionReplay).not.toHaveBeenCalled() + }) + + it('does not trigger before onboarding is complete', () => { + mockProfile = createMockProfile({ hasCompletedOnboarding: false }) + renderHook(() => useCoachMark('coach-today')) + vi.advanceTimersByTime(700) + + expect(startSectionReplay).not.toHaveBeenCalled() + }) + + it('does not trigger once the full tour is completed', () => { + mockProfile = createMockProfile({ + hasCompletedOnboarding: true, + hasCompletedTour: true, + }) + renderHook(() => useCoachMark('coach-today')) + vi.advanceTimersByTime(700) + + expect(startSectionReplay).not.toHaveBeenCalled() + }) +}) diff --git a/apps/web/app/(app)/calendar/page.tsx b/apps/web/app/(app)/calendar/page.tsx index 7b10ef36d..75f717531 100644 --- a/apps/web/app/(app)/calendar/page.tsx +++ b/apps/web/app/(app)/calendar/page.tsx @@ -13,6 +13,7 @@ import { enUS, ptBR } from 'date-fns/locale' import { useLocale, useTranslations } from 'next-intl' import { formatAPIDate } from '@orbit/shared/utils' import { useCalendarData } from '@/hooks/use-calendar-data' +import { useCoachMark } from '@/hooks/use-coach-mark' import type { CalendarDayEntry } from '@orbit/shared/types/calendar' import { CalendarGrid } from '@/components/calendar/calendar-grid' import { CalendarDayDetail } from '@/components/calendar/calendar-day-detail' @@ -33,6 +34,7 @@ export default function CalendarPage() { const t = useTranslations() const locale = useLocale() const dateFnsLocale = locale === 'pt-BR' ? ptBR : enUS + useCoachMark('coach-calendar') const [currentMonth, setCurrentMonth] = useState(() => startOfMonth(new Date())) const [monthSlide, setMonthSlide] = useState(null) const [selectedDay, setSelectedDay] = useState(() => diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 8ecebed70..fdc3c44c3 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -29,7 +29,6 @@ import { useAuthStore } from '@/stores/auth-store' import { useTotalHabitCount } from '@/hooks/use-habits' import { useGamificationProfile } from '@/hooks/use-gamification' import { useUIStore } from '@/stores/ui-store' -import { useTourStore } from '@/stores/tour-store' import { getSupabaseClient } from '@/lib/supabase' import { dismissCalendarImport } from '@/app/actions/calendar' import { TourProvider } from '@/components/tour/tour-provider' @@ -93,20 +92,6 @@ function AppLayoutContent({ children }: Readonly<{ children: React.ReactNode }>) const [showCalendarPrompt, setShowCalendarPrompt] = useState(false) - const tourStarted = useRef(false) - useEffect(() => { - if ( - profile && - profile.hasCompletedOnboarding && - !profile.hasCompletedTour && - !tourStarted.current && - !useTourStore.getState().isActive - ) { - tourStarted.current = true - setTimeout(() => useTourStore.getState().startFullTour(), 500) - } - }, [profile]) - const calendarPromptCriteriaMet = !!( profile && profile.hasCompletedOnboarding && diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 842b911bd..4923066f4 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -30,6 +30,7 @@ import { BulkActionBarV2 } from '@/components/habits/bulk-action-bar-v2' import { GradientTop } from '@/components/ui/gradient-top' import { ProgressBar } from '@/components/ui/progress-bar' import { SectionLabel } from '@/components/ui/section-label' +import { SetupChecklistCard } from '@/components/today/setup-checklist-card' import { useUIStore } from '@/stores/ui-store' import { useProfile } from '@/hooks/use-profile' import { @@ -38,6 +39,8 @@ import { useHabits, } from '@/hooks/use-habits' import { useTags } from '@/hooks/use-tags' +import { useTotalHabitCount } from '@/hooks/use-habit-queries' +import { useCoachMark } from '@/hooks/use-coach-mark' import { useBulkActions } from '@/hooks/use-bulk-actions' import { TodayHeader, @@ -66,6 +69,8 @@ export default function TodayPage() { const queryClient = useQueryClient() const { profile } = useProfile() const { tags } = useTags() + const totalHabitCount = useTotalHabitCount() + useCoachMark('coach-today') const listMotionPreset = resolveMotionPreset('list-enter', Boolean(prefersReducedMotion)) const listTransition = { duration: listMotionPreset.enterDuration / 1000, @@ -436,30 +441,34 @@ export default function TodayPage() {
)} - - setLocalSearchQuery('')} - onFrequencyChange={setSelectedFrequency} - onTagToggle={toggleTagFilter} - onToggleSelect={toggleSelectMode} - onToggleCollapse={handleToggleCollapse} - onRefresh={handleRefresh} - onToggleCompleted={() => setShowCompleted(!showCompleted)} - /> - + {currentActiveView === 'today' && } + + {totalHabitCount >= 5 && ( + + setLocalSearchQuery('')} + onFrequencyChange={setSelectedFrequency} + onTagToggle={toggleTagFilter} + onToggleSelect={toggleSelectMode} + onToggleCollapse={handleToggleCollapse} + onRefresh={handleRefresh} + onToggleCompleted={() => setShowCompleted(!showCompleted)} + /> + + )} {!hasFetched && (
diff --git a/apps/web/app/(app)/profile/_components/tour-replay-modal.tsx b/apps/web/app/(app)/profile/_components/tour-replay-modal.tsx index 0f8f8f3e2..5c7555537 100644 --- a/apps/web/app/(app)/profile/_components/tour-replay-modal.tsx +++ b/apps/web/app/(app)/profile/_components/tour-replay-modal.tsx @@ -42,7 +42,16 @@ function getSectionCompletion(): Record { if (stored) return JSON.parse(stored) } catch { } - return { habits: false, goals: false, chat: false, calendar: false, profile: false } + return { + habits: false, + goals: false, + chat: false, + calendar: false, + profile: false, + 'coach-today': false, + 'coach-astra': false, + 'coach-calendar': false, + } } export function TourReplayModal({ open, onOpenChange }: Readonly) { @@ -83,6 +92,9 @@ export function TourReplayModal({ open, onOpenChange }: Readonly case 1: - return + return ( + + ) case 2: + return + case 3: return ( ) - case 3: + case 4: return ( ) - case 4: - return case 5: + return + case 6: return ( void + onCreateOwn: () => void + onSkip: () => void +} + +export function OnboardingTemplatePacks({ + onCreated, + onCreateOwn, + onSkip, +}: Readonly) { + const t = useTranslations() + const translate = useCallback( + (key: string, values?: Record) => t(key, values), + [t], + ) + const { showError } = useAppToast() + const bulkCreate = useBulkCreateHabits() + const [selectedPackId, setSelectedPackId] = useState(null) + const [disabledKeys, setDisabledKeys] = useState>(new Set()) + + const selectedPack = selectedPackId ? getTemplatePackById(selectedPackId) : undefined + const isCreating = bulkCreate.isPending + + const toggleHabit = useCallback((key: string) => { + setDisabledKeys((previous) => { + const next = new Set(previous) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + }, []) + + const enabledCount = selectedPack + ? selectedPack.habits.filter((habit) => !disabledKeys.has(habit.key)).length + : 0 + + const handleAdd = useCallback(() => { + if (!selectedPack || enabledCount === 0 || isCreating) return + const items = buildBulkItemsFromPack(selectedPack, disabledKeys, translate) + bulkCreate.mutate( + { habits: items }, + { + onSuccess: () => onCreated(), + onError: (error: unknown) => + showError( + getFriendlyErrorMessage(error, translate, 'errors.createHabit', 'habit'), + ), + }, + ) + }, [selectedPack, enabledCount, isCreating, disabledKeys, translate, bulkCreate, onCreated, showError]) + + if (!selectedPack) { + return ( +
+ + +
+ {TEMPLATE_PACKS.map((pack) => ( + + ))} +
+ +
+ + {t('onboarding.flow.templatePacks.createOwn')} + + + {t('onboarding.flow.templatePacks.skip')} + +
+
+ ) + } + + return ( +
+ + +
+ {selectedPack.habits.map((habit) => { + const enabled = !disabledKeys.has(habit.key) + return ( + + ) + })} +
+ +
+ : undefined} + > + {enabledCount === 1 + ? t('onboarding.flow.templatePacks.createCtaOne') + : t('onboarding.flow.templatePacks.createCta', { count: enabledCount })} + + setSelectedPackId(null)}> + {t('onboarding.flow.back')} + +
+
+ ) +} + +interface HeadingProps { + title: string + subtitle: string +} + +function Heading({ title, subtitle }: Readonly) { + return ( +
+
+ {title} +
+
+ {subtitle} +
+
+ ) +} + +interface SecondaryActionProps { + onClick: () => void + emphasis?: boolean + children: string +} + +function SecondaryAction({ onClick, emphasis, children }: Readonly) { + return ( + + ) +} diff --git a/apps/web/components/today/setup-checklist-card.tsx b/apps/web/components/today/setup-checklist-card.tsx new file mode 100644 index 000000000..fdd74feff --- /dev/null +++ b/apps/web/components/today/setup-checklist-card.tsx @@ -0,0 +1,133 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { Check, X } from 'lucide-react' +import type { Profile } from '@orbit/shared/types' +import { useProfile } from '@/hooks/use-profile' +import { useUIStore } from '@/stores/ui-store' + +const CHECKLIST_ITEMS: readonly { key: string; flag: keyof Profile }[] = [ + { key: 'createHabit', flag: 'hasCreatedFirstHabit' }, + { key: 'logHabit', flag: 'hasLoggedFirstHabit' }, + { key: 'tryAstra', flag: 'hasTriedAstra' }, +] + +/** Auto-tracked first-run setup checklist on Today; hides once completed or dismissed. */ +export function SetupChecklistCard() { + const t = useTranslations() + const { profile } = useProfile() + const dismissed = useUIStore((state) => state.setupChecklistDismissed) + const setDismissed = useUIStore((state) => state.setSetupChecklistDismissed) + + if (!profile || dismissed || profile.hasCompletedOnboardingChecklist) { + return null + } + + const states = CHECKLIST_ITEMS.map((item) => Boolean(profile[item.flag])) + const doneCount = states.filter(Boolean).length + const total = CHECKLIST_ITEMS.length + const allDone = doneCount === total + + return ( +
+
+
+
+ + {t('today.setupChecklist.title')} + + + {allDone + ? t('today.setupChecklist.complete') + : t('today.setupChecklist.subtitle')} + +
+ +
+ +
    + {CHECKLIST_ITEMS.map((item, index) => { + const done = states[index] + return ( +
  • + + + {t(`today.setupChecklist.items.${item.key}`)} + +
  • + ) + })} +
+ + + {t('today.setupChecklist.progress', { done: doneCount, total })} + +
+
+ ) +} diff --git a/apps/web/components/tour/tour-overlay.tsx b/apps/web/components/tour/tour-overlay.tsx index 937119861..5d3c03dfd 100644 --- a/apps/web/components/tour/tour-overlay.tsx +++ b/apps/web/components/tour/tour-overlay.tsx @@ -8,6 +8,7 @@ import { completeTour } from '@/app/actions/profile' import { useQueryClient } from '@tanstack/react-query' import { profileKeys } from '@orbit/shared/query' import type { Profile } from '@orbit/shared/types' +import { COACH_MARK_SECTIONS } from '@orbit/shared/types' import { useOverlayEscape } from '@/hooks/use-overlay-escape' /** @@ -38,7 +39,11 @@ export function TourOverlay() { const isLastStep = currentStepIndex === totalSteps - 1 const handleEnd = useCallback(() => { + const { replaySection } = useTourStore.getState() endTour() + if (replaySection && COACH_MARK_SECTIONS.includes(replaySection)) { + return + } completeTour().catch(() => {}) queryClient.setQueryData(profileKeys.detail(), (old: Profile | undefined) => { if (!old) return old diff --git a/apps/web/hooks/use-coach-mark.ts b/apps/web/hooks/use-coach-mark.ts new file mode 100644 index 000000000..eb1f72a12 --- /dev/null +++ b/apps/web/hooks/use-coach-mark.ts @@ -0,0 +1,51 @@ +'use client' + +import { useEffect, useRef } from 'react' +import type { TourSection } from '@orbit/shared/types' +import { useTourStore } from '@/stores/tour-store' +import { useProfile } from '@/hooks/use-profile' + +const SEEN_KEY = 'orbit_tour_sections' + +function readSeenSections(): Record { + try { + const raw = localStorage.getItem(SEEN_KEY) + return raw ? (JSON.parse(raw) as Record) : {} + } catch { + return {} + } +} + +function markSectionSeen(section: TourSection) { + try { + const seen = readSeenSections() + seen[section] = true + localStorage.setItem(SEEN_KEY, JSON.stringify(seen)) + } catch { + return + } +} + +/** + * Fires a one-time first-run coach-mark spotlight for a surface once onboarding is done, + * the full tour is inactive, and the surface has not been seen before. + */ +export function useCoachMark(section: TourSection) { + const { profile } = useProfile() + const triggered = useRef(false) + + useEffect(() => { + if (triggered.current) return + if (!profile?.hasCompletedOnboarding || profile.hasCompletedTour) return + if (useTourStore.getState().isActive) return + if (readSeenSections()[section]) return + + triggered.current = true + const timer = setTimeout(() => { + if (useTourStore.getState().isActive) return + markSectionSeen(section) + useTourStore.getState().startSectionReplay(section) + }, 600) + return () => clearTimeout(timer) + }, [profile?.hasCompletedOnboarding, profile?.hasCompletedTour, section]) +} diff --git a/packages/shared/src/__tests__/factories.ts b/packages/shared/src/__tests__/factories.ts index 5317c6cd1..e6edb3128 100644 --- a/packages/shared/src/__tests__/factories.ts +++ b/packages/shared/src/__tests__/factories.ts @@ -116,6 +116,10 @@ export function createMockProfile(overrides: Partial = {}): Profile { googleCalendarAutoSyncStatus: 'Idle', googleCalendarLastSyncedAt: null, canViewGamification: false, + hasCreatedFirstHabit: false, + hasLoggedFirstHabit: false, + hasTriedAstra: false, + hasCompletedOnboardingChecklist: false, ...overrides, } } diff --git a/packages/shared/src/__tests__/onboarding.test.ts b/packages/shared/src/__tests__/onboarding.test.ts index b183e4d10..95dd6432d 100644 --- a/packages/shared/src/__tests__/onboarding.test.ts +++ b/packages/shared/src/__tests__/onboarding.test.ts @@ -34,14 +34,15 @@ describe('onboarding helpers', () => { }) it('derives onboarding progress consistently', () => { - expect(getOnboardingDisplayTotal(true)).toBe(6) - expect(getOnboardingDisplayTotal(false)).toBe(5) + expect(getOnboardingDisplayTotal(true)).toBe(7) + expect(getOnboardingDisplayTotal(false)).toBe(6) expect(getOnboardingDisplayStep(0, true)).toBe(1) - expect(getOnboardingDisplayStep(4, false)).toBe(4) - expect(getOnboardingNextStep(2, true)).toBe(3) - expect(getOnboardingNextStep(2, false)).toBe(4) - expect(getOnboardingPreviousStep(4, false)).toBe(2) + expect(getOnboardingDisplayStep(5, false)).toBe(5) + expect(getOnboardingNextStep(3, true)).toBe(4) + expect(getOnboardingNextStep(3, false)).toBe(5) + expect(getOnboardingPreviousStep(5, false)).toBe(3) expect(shouldHideOnboardingFooter(1)).toBe(true) + expect(shouldHideOnboardingFooter(5)).toBe(false) expect(shouldHideOnboardingFooter(0)).toBe(false) }) diff --git a/packages/shared/src/__tests__/template-packs.test.ts b/packages/shared/src/__tests__/template-packs.test.ts new file mode 100644 index 000000000..f10b11141 --- /dev/null +++ b/packages/shared/src/__tests__/template-packs.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import en from '../i18n/en.json' +import ptBR from '../i18n/pt-BR.json' +import { + buildBulkItemsFromPack, + getTemplatePackById, + TEMPLATE_PACKS, + templatePackDescriptionKey, + templatePackHabitTitleKey, + templatePackNameKey, + templatePackTagKey, +} from '../utils/template-packs' + +type LocaleSource = Record + +function resolveKey(source: LocaleSource, key: string): unknown { + return key.split('.').reduce((accumulator, part) => { + if ( + accumulator !== null && + typeof accumulator === 'object' && + part in (accumulator as LocaleSource) + ) { + return (accumulator as LocaleSource)[part] + } + return undefined + }, source) +} + +const locales: ReadonlyArray = [ + ['en', en as LocaleSource], + ['pt-BR', ptBR as LocaleSource], +] + +describe('template packs', () => { + it('exposes four packs with 4-6 habits each', () => { + expect(TEMPLATE_PACKS).toHaveLength(4) + expect(TEMPLATE_PACKS.map((pack) => pack.id)).toEqual([ + 'morningRoutine', + 'fitnessMovement', + 'studyFocus', + 'mindfulnessWellbeing', + ]) + for (const pack of TEMPLATE_PACKS) { + expect(pack.habits.length).toBeGreaterThanOrEqual(4) + expect(pack.habits.length).toBeLessThanOrEqual(6) + } + }) + + it('uses unique habit keys within each pack', () => { + for (const pack of TEMPLATE_PACKS) { + const keys = pack.habits.map((habit) => habit.key) + expect(new Set(keys).size).toBe(keys.length) + } + }) + + it('resolves every pack name, description, habit title, and tag in both locales', () => { + for (const [localeName, source] of locales) { + for (const pack of TEMPLATE_PACKS) { + expect( + resolveKey(source, templatePackNameKey(pack.id)), + `${localeName} ${pack.id} name`, + ).toBeTypeOf('string') + expect( + resolveKey(source, templatePackDescriptionKey(pack.id)), + `${localeName} ${pack.id} description`, + ).toBeTypeOf('string') + for (const habit of pack.habits) { + expect( + resolveKey(source, templatePackHabitTitleKey(pack.id, habit.key)), + `${localeName} ${pack.id}.${habit.key}`, + ).toBeTypeOf('string') + for (const slug of habit.tags) { + expect( + resolveKey(source, templatePackTagKey(slug)), + `${localeName} tag ${slug}`, + ).toBeTypeOf('string') + } + } + } + } + }) + + it('builds bulk items, dropping disabled habits and localizing title + tags', () => { + const pack = getTemplatePackById('morningRoutine') + expect(pack).toBeDefined() + if (!pack) return + + const translate = (key: string) => `t:${key}` + const items = buildBulkItemsFromPack(pack, new Set(['makeBed', 'water']), translate) + + expect(items).toHaveLength(pack.habits.length - 2) + expect(items.every((item) => item.isGeneral === false)).toBe(true) + + const stretch = items.find( + (item) => item.title === `t:${templatePackHabitTitleKey('morningRoutine', 'stretch')}`, + ) + expect(stretch).toBeDefined() + expect(stretch?.emoji).toBe('🧘') + expect(stretch?.frequencyUnit).toBe('Day') + expect(stretch?.frequencyQuantity).toBe(1) + expect(stretch?.tags).toEqual([ + `t:${templatePackTagKey('morning')}`, + `t:${templatePackTagKey('movement')}`, + ]) + }) + + it('returns undefined for an unknown pack id', () => { + expect(getTemplatePackById('nope')).toBeUndefined() + }) +}) diff --git a/packages/shared/src/__tests__/tour-store.test.ts b/packages/shared/src/__tests__/tour-store.test.ts index ab7a7b2ff..ec5f6f4f3 100644 --- a/packages/shared/src/__tests__/tour-store.test.ts +++ b/packages/shared/src/__tests__/tour-store.test.ts @@ -183,4 +183,27 @@ describe('createTourStoreState', () => { expect(getState().isActive).toBe(false) expect(getState().replaySection).toBeNull() }) + + it('excludes coach-mark sections from the full tour', () => { + getState().startFullTour() + const steps = getState().getActiveSteps() + + expect(steps.length).toBeGreaterThan(0) + expect( + steps.every( + (step) => + step.section !== 'coach-today' && + step.section !== 'coach-astra' && + step.section !== 'coach-calendar', + ), + ).toBe(true) + }) + + it('replays a single coach-mark section in isolation', () => { + getState().startSectionReplay('coach-today') + const steps = getState().getActiveSteps() + + expect(steps).toHaveLength(1) + expect(steps[0]?.section).toBe('coach-today') + }) }) diff --git a/packages/shared/src/__tests__/ui-store.test.ts b/packages/shared/src/__tests__/ui-store.test.ts index f7a72c6a0..9158166eb 100644 --- a/packages/shared/src/__tests__/ui-store.test.ts +++ b/packages/shared/src/__tests__/ui-store.test.ts @@ -162,6 +162,7 @@ describe("shared ui store", () => { selectedFrequency: null, selectedTagIds: [], showCompleted: true, + setupChecklistDismissed: false, }); }); @@ -218,6 +219,7 @@ describe("shared ui store", () => { selectedFrequency: "Month", selectedTagIds: ["deep-work"], showCompleted: true, + setupChecklistDismissed: false, }); }); }); diff --git a/packages/shared/src/i18n/en.json b/packages/shared/src/i18n/en.json index fbde0c0cf..c986e24e8 100644 --- a/packages/shared/src/i18n/en.json +++ b/packages/shared/src/i18n/en.json @@ -1127,6 +1127,75 @@ }, "onboarding": { "flow": { + "templatePacks": { + "title": "Start with a pack", + "subtitle": "Pick a set of habits to begin. You can tweak everything later.", + "customizeTitle": "Customize your pack", + "customizeSubtitle": "Turn off anything you don't want. We'll add the rest.", + "createCta": "Add {count} habits", + "createCtaOne": "Add 1 habit", + "createOwn": "Create my own instead", + "skip": "Skip for now", + "created": "Your habits are ready!", + "packs": { + "morningRoutine": { + "name": "Morning Routine", + "description": "Start every day with intention.", + "habits": { + "makeBed": "Make your bed", + "water": "Drink a glass of water", + "stretch": "5-minute stretch", + "breakfast": "Eat a healthy breakfast", + "planDay": "Plan your top 3 tasks" + } + }, + "fitnessMovement": { + "name": "Fitness & Movement", + "description": "Keep your body active and strong.", + "habits": { + "walk": "30-minute walk", + "strength": "Strength workout", + "mobility": "10-minute mobility", + "stairs": "Take the stairs", + "steps": "Hit your step goal" + } + }, + "studyFocus": { + "name": "Study & Focus", + "description": "Build deep, distraction-free focus.", + "habits": { + "deepWork": "Deep work block", + "read": "Read for 20 minutes", + "reviewNotes": "Review your notes", + "phoneFree": "Phone-free first hour", + "singleTask": "Single-task one session" + } + }, + "mindfulnessWellbeing": { + "name": "Mindfulness & Wellbeing", + "description": "Slow down and check in with yourself.", + "habits": { + "meditate": "Meditate 10 minutes", + "gratitude": "Gratitude journal", + "digitalSunset": "Digital sunset", + "breathing": "Deep breathing", + "reachOut": "Reach out to someone" + } + } + }, + "tags": { + "morning": "morning", + "health": "health", + "movement": "movement", + "focus": "focus", + "fitness": "fitness", + "learning": "learning", + "mindfulness": "mindfulness", + "journal": "journal", + "sleep": "sleep", + "social": "social" + } + }, "step": "{current} of {total}", "skip": "Skip", "next": "Continue", @@ -1221,8 +1290,8 @@ "theme": "Theme personalized", "astra": "AI assistant" }, - "trialTitle": "Your Pro Trial is Active", - "trialDesc": "Enjoy full Pro access until {date}." + "trialTitle": "7 days of Pro, free", + "trialDesc": "No card required. You have full Pro access until {date}." } }, "wizard": { @@ -2048,7 +2117,35 @@ "banner": "A new version of Orbit is available.", "refresh": "Refresh" }, + "today": { + "setupChecklist": { + "title": "Get started", + "subtitle": "Three quick steps to set up Orbit.", + "items": { + "createHabit": "Create your first habit", + "logHabit": "Log a habit today", + "tryAstra": "Ask Astra anything" + }, + "progress": "{done} of {total} done", + "complete": "You're all set. Nice work!", + "dismiss": "Dismiss" + } + }, "tour": { + "coachmarks": { + "today": { + "title": "Your day, at a glance", + "description": "Every habit due today lives here. Tap a circle to mark it done." + }, + "astra": { + "title": "Meet Astra", + "description": "Your AI assistant. Ask it to create habits, plan ahead, or recap your day." + }, + "calendar": { + "title": "See your history", + "description": "A color-coded heatmap of everything you've completed over time." + } + }, "mockData": { "habits": { "meditation": { diff --git a/packages/shared/src/i18n/pt-BR.json b/packages/shared/src/i18n/pt-BR.json index bd56d26d0..51bc71c85 100644 --- a/packages/shared/src/i18n/pt-BR.json +++ b/packages/shared/src/i18n/pt-BR.json @@ -1127,6 +1127,75 @@ }, "onboarding": { "flow": { + "templatePacks": { + "title": "Comece com um pacote", + "subtitle": "Escolha um conjunto de hĂĄbitos para começar. VocĂȘ pode ajustar tudo depois.", + "customizeTitle": "Personalize seu pacote", + "customizeSubtitle": "Desative o que nĂŁo quiser. Adicionamos o resto.", + "createCta": "Adicionar {count} hĂĄbitos", + "createCtaOne": "Adicionar 1 hĂĄbito", + "createOwn": "Prefiro criar o meu", + "skip": "Pular por agora", + "created": "Seus hĂĄbitos estĂŁo prontos!", + "packs": { + "morningRoutine": { + "name": "Rotina Matinal", + "description": "Comece todo dia com intenção.", + "habits": { + "makeBed": "Arrume sua cama", + "water": "Beba um copo de ĂĄgua", + "stretch": "Alongamento de 5 minutos", + "breakfast": "Tome um cafĂ© da manhĂŁ saudĂĄvel", + "planDay": "Planeje suas 3 tarefas principais" + } + }, + "fitnessMovement": { + "name": "Fitness e Movimento", + "description": "Mantenha o corpo ativo e forte.", + "habits": { + "walk": "Caminhada de 30 minutos", + "strength": "Treino de força", + "mobility": "Mobilidade de 10 minutos", + "stairs": "Use as escadas", + "steps": "Atinja sua meta de passos" + } + }, + "studyFocus": { + "name": "Estudo e Foco", + "description": "Desenvolva foco profundo, sem distraçÔes.", + "habits": { + "deepWork": "Bloco de trabalho focado", + "read": "Leia por 20 minutos", + "reviewNotes": "Revise suas anotaçÔes", + "phoneFree": "Primeira hora sem celular", + "singleTask": "Faça uma tarefa por vez" + } + }, + "mindfulnessWellbeing": { + "name": "Mindfulness e Bem-estar", + "description": "Desacelere e cuide de vocĂȘ.", + "habits": { + "meditate": "Medite por 10 minutos", + "gratitude": "DiĂĄrio de gratidĂŁo", + "digitalSunset": "PĂŽr do sol digital", + "breathing": "Respiração profunda", + "reachOut": "Fale com alguĂ©m" + } + } + }, + "tags": { + "morning": "manhĂŁ", + "health": "saĂșde", + "movement": "movimento", + "focus": "foco", + "fitness": "fitness", + "learning": "aprendizado", + "mindfulness": "mindfulness", + "journal": "diĂĄrio", + "sleep": "sono", + "social": "social" + } + }, "step": "{current} de {total}", "skip": "Pular", "next": "Continuar", @@ -1221,8 +1290,8 @@ "theme": "Tema personalizado", "astra": "Assistente de IA" }, - "trialTitle": "Seu teste Pro estĂĄ ativo", - "trialDesc": "Aproveite o acesso Pro completo atĂ© {date}." + "trialTitle": "7 dias de Pro, grĂĄtis", + "trialDesc": "Sem cartĂŁo. VocĂȘ tem acesso Pro completo atĂ© {date}." } }, "wizard": { @@ -2048,7 +2117,35 @@ "banner": "Uma nova versĂŁo do Orbit estĂĄ disponĂ­vel.", "refresh": "Atualizar" }, + "today": { + "setupChecklist": { + "title": "Primeiros passos", + "subtitle": "TrĂȘs passos rĂĄpidos para configurar o Orbit.", + "items": { + "createHabit": "Crie seu primeiro hĂĄbito", + "logHabit": "Registre um hĂĄbito hoje", + "tryAstra": "Pergunte algo Ă  Astra" + }, + "progress": "{done} de {total} concluĂ­dos", + "complete": "Tudo pronto. Bom trabalho!", + "dismiss": "Dispensar" + } + }, "tour": { + "coachmarks": { + "today": { + "title": "Seu dia, num relance", + "description": "Todo hĂĄbito de hoje fica aqui. Toque no cĂ­rculo para concluir." + }, + "astra": { + "title": "Conheça a Astra", + "description": "Seu assistente de IA. Peça para criar hĂĄbitos, planejar ou resumir o dia." + }, + "calendar": { + "title": "Veja seu histĂłrico", + "description": "Um mapa de calor colorido de tudo que vocĂȘ concluiu ao longo do tempo." + } + }, "mockData": { "habits": { "meditation": { diff --git a/packages/shared/src/stores/tour-store.ts b/packages/shared/src/stores/tour-store.ts index 0ec86873c..cbe7182ec 100644 --- a/packages/shared/src/stores/tour-store.ts +++ b/packages/shared/src/stores/tour-store.ts @@ -1,4 +1,5 @@ import type { TourSection, TourStep } from '../types/tour' +import { COACH_MARK_SECTIONS } from '../types/tour' import { TOUR_STEPS, getTourStepsBySection, getSectionStepCount } from '../tour/tour-steps' type TourStoreSet = { @@ -60,7 +61,7 @@ export function createTourStoreState(set: TourStoreSet, get: TourStoreGet): Tour const visibleSteps = TOUR_STEPS.filter((step) => !hiddenSections.includes(step.section)) return replaySection ? visibleSteps.filter((step) => step.section === replaySection) - : visibleSteps + : visibleSteps.filter((step) => !COACH_MARK_SECTIONS.includes(step.section)) }, getCurrentStep: () => { @@ -156,7 +157,7 @@ export function createTourStoreState(set: TourStoreSet, get: TourStoreGet): Tour set((state) => { const nextSteps = (state.replaySection ? TOUR_STEPS.filter((step) => step.section === state.replaySection) - : TOUR_STEPS + : TOUR_STEPS.filter((step) => !COACH_MARK_SECTIONS.includes(step.section)) ).filter((step) => !sections.includes(step.section)) return { diff --git a/packages/shared/src/stores/ui-store.ts b/packages/shared/src/stores/ui-store.ts index 8740db510..f9f692a6d 100644 --- a/packages/shared/src/stores/ui-store.ts +++ b/packages/shared/src/stores/ui-store.ts @@ -65,6 +65,7 @@ export interface PersistedUIState { selectedFrequency: HabitFrequencyFilter | null; selectedTagIds: string[]; showCompleted: boolean; + setupChecklistDismissed: boolean; } export function migratePersistedUIState( @@ -88,6 +89,10 @@ export function migratePersistedUIState( : [], showCompleted: typeof state.showCompleted === "boolean" ? state.showCompleted : false, + setupChecklistDismissed: + typeof state.setupChecklistDismissed === "boolean" + ? state.setupChecklistDismissed + : false, }; } @@ -149,6 +154,9 @@ export interface UIStoreState { showCompleted: boolean; setShowCompleted: (show: boolean) => void; + + setupChecklistDismissed: boolean; + setSetupChecklistDismissed: (dismissed: boolean) => void; } export function getPersistedUIState(state: UIStoreState): PersistedUIState { @@ -159,6 +167,7 @@ export function getPersistedUIState(state: UIStoreState): PersistedUIState { selectedFrequency: state.selectedFrequency, selectedTagIds: [...state.selectedTagIds], showCompleted: state.showCompleted, + setupChecklistDismissed: state.setupChecklistDismissed, }; } @@ -170,6 +179,7 @@ export function createTourUIState(): PersistedUIState { selectedFrequency: null, selectedTagIds: [], showCompleted: true, + setupChecklistDismissed: false, }; } @@ -376,5 +386,9 @@ export function createUIStoreState( showCompleted: false, setShowCompleted: (show) => set({ showCompleted: show }), + + setupChecklistDismissed: false, + setSetupChecklistDismissed: (dismissed) => + set({ setupChecklistDismissed: dismissed }), }; } diff --git a/packages/shared/src/tour/tour-steps.ts b/packages/shared/src/tour/tour-steps.ts index aefa10b3f..c68913de8 100644 --- a/packages/shared/src/tour/tour-steps.ts +++ b/packages/shared/src/tour/tour-steps.ts @@ -218,6 +218,34 @@ export const TOUR_STEPS: TourStep[] = [ route: '/profile', proBadge: true, }, + + { + id: 'coach-today', + section: 'coach-today', + targetId: 'tour-habit-list', + titleKey: 'tour.coachmarks.today.title', + descriptionKey: 'tour.coachmarks.today.description', + placement: 'bottom', + route: '/', + }, + { + id: 'coach-astra', + section: 'coach-astra', + targetId: 'tour-chat-input', + titleKey: 'tour.coachmarks.astra.title', + descriptionKey: 'tour.coachmarks.astra.description', + placement: 'top', + route: '/chat', + }, + { + id: 'coach-calendar', + section: 'coach-calendar', + targetId: 'tour-calendar-grid', + titleKey: 'tour.coachmarks.calendar.title', + descriptionKey: 'tour.coachmarks.calendar.description', + placement: 'bottom', + route: '/calendar', + }, ] /** Get all steps for a specific section */ diff --git a/packages/shared/src/types/habit.ts b/packages/shared/src/types/habit.ts index a0e83dd0d..380369bd3 100644 --- a/packages/shared/src/types/habit.ts +++ b/packages/shared/src/types/habit.ts @@ -389,6 +389,7 @@ export const bulkHabitItemSchema: z.ZodType<{ subHabits?: BulkHabitItem[] | null endDate?: string | null googleEventId?: string | null + tags?: string[] | null }> = z.object({ title: z.string(), description: z.string().nullable().optional(), @@ -409,6 +410,7 @@ export const bulkHabitItemSchema: z.ZodType<{ subHabits: z.lazy(() => z.array(bulkHabitItemSchema).nullable().optional()), endDate: z.string().nullable().optional(), googleEventId: z.string().nullable().optional(), + tags: z.array(z.string()).nullable().optional(), }) export type BulkHabitItem = z.infer diff --git a/packages/shared/src/types/profile.ts b/packages/shared/src/types/profile.ts index 7b3afd9fc..b2928861a 100644 --- a/packages/shared/src/types/profile.ts +++ b/packages/shared/src/types/profile.ts @@ -53,6 +53,10 @@ export const profileSchema = z.object({ googleCalendarAutoSyncStatus: calendarAutoSyncStatusSchema, googleCalendarLastSyncedAt: z.string().nullable(), canViewGamification: z.boolean().optional(), + hasCreatedFirstHabit: z.boolean().optional(), + hasLoggedFirstHabit: z.boolean().optional(), + hasTriedAstra: z.boolean().optional(), + hasCompletedOnboardingChecklist: z.boolean().optional(), }) export type Profile = z.infer diff --git a/packages/shared/src/types/tour.ts b/packages/shared/src/types/tour.ts index 1dc501368..fa3f53cd3 100644 --- a/packages/shared/src/types/tour.ts +++ b/packages/shared/src/types/tour.ts @@ -1,4 +1,12 @@ -export type TourSection = 'habits' | 'goals' | 'chat' | 'calendar' | 'profile' +export type TourSection = + | 'habits' + | 'goals' + | 'chat' + | 'calendar' + | 'profile' + | 'coach-today' + | 'coach-astra' + | 'coach-calendar' type TourPlacement = 'top' | 'bottom' | 'left' | 'right' @@ -37,10 +45,20 @@ export const TOUR_SECTIONS: TourSection[] = [ 'profile', ] +/** First-run coach-mark sections — excluded from the full tour and the replay modal. */ +export const COACH_MARK_SECTIONS: TourSection[] = [ + 'coach-today', + 'coach-astra', + 'coach-calendar', +] + export const TOUR_SECTION_ICONS: Record = { habits: 'check-circle', goals: 'target', chat: 'message-circle', calendar: 'calendar-days', profile: 'user', + 'coach-today': 'check-circle', + 'coach-astra': 'message-circle', + 'coach-calendar': 'calendar-days', } diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index ad8882cc7..b4edb5d58 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -168,10 +168,21 @@ export { ONBOARDING_GOAL_SUGGESTIONS, ONBOARDING_HABIT_FREQUENCIES, ONBOARDING_HABIT_SUGGESTIONS, + ONBOARDING_TEMPLATE_PACKS_STEP, ONBOARDING_TOTAL_STEPS, ONBOARDING_WEEK_START_OPTIONS, shouldHideOnboardingFooter, } from './onboarding' +export { + buildBulkItemsFromPack, + getTemplatePackById, + TEMPLATE_PACKS, + templatePackDescriptionKey, + templatePackHabitTitleKey, + templatePackNameKey, + templatePackTagKey, +} from './template-packs' +export type { TemplatePack, TemplatePackHabit } from './template-packs' export { isMissingBillingError, isMissingBillingStatus, diff --git a/packages/shared/src/utils/onboarding.ts b/packages/shared/src/utils/onboarding.ts index 95d957c47..fce272cbd 100644 --- a/packages/shared/src/utils/onboarding.ts +++ b/packages/shared/src/utils/onboarding.ts @@ -1,11 +1,12 @@ export type OnboardingFrequencyUnit = 'Day' | 'Week' | 'Month' | 'Year' -export const ONBOARDING_TOTAL_STEPS = 6 -export const ONBOARDING_CREATE_HABIT_STEP = 1 -export const ONBOARDING_COMPLETE_HABIT_STEP = 2 -export const ONBOARDING_CREATE_GOAL_STEP = 3 -export const ONBOARDING_FEATURES_STEP = 4 -export const ONBOARDING_COMPLETE_STEP = 5 +export const ONBOARDING_TOTAL_STEPS = 7 +export const ONBOARDING_TEMPLATE_PACKS_STEP = 1 +export const ONBOARDING_CREATE_HABIT_STEP = 2 +export const ONBOARDING_COMPLETE_HABIT_STEP = 3 +export const ONBOARDING_CREATE_GOAL_STEP = 4 +export const ONBOARDING_FEATURES_STEP = 5 +export const ONBOARDING_COMPLETE_STEP = 6 export const ONBOARDING_HABIT_SUGGESTIONS: ReadonlyArray<{ key: string @@ -112,6 +113,7 @@ export function getOnboardingPreviousStep( export function shouldHideOnboardingFooter(currentStep: number): boolean { return [ + ONBOARDING_TEMPLATE_PACKS_STEP, ONBOARDING_CREATE_HABIT_STEP, ONBOARDING_COMPLETE_HABIT_STEP, ONBOARDING_CREATE_GOAL_STEP, diff --git a/packages/shared/src/utils/template-packs.ts b/packages/shared/src/utils/template-packs.ts new file mode 100644 index 000000000..82adcf3dc --- /dev/null +++ b/packages/shared/src/utils/template-packs.ts @@ -0,0 +1,108 @@ +import type { BulkHabitItem } from '../types/habit' + +export interface TemplatePackHabit { + /** Stable identifier within the pack; also the i18n sub-key for the habit title. */ + key: string + emoji: string + frequencyUnit: 'Day' | 'Week' + frequencyQuantity: number + /** Tag slugs applied on creation, resolved to localized tag names via templatePackTagKey. */ + tags: readonly string[] +} + +export interface TemplatePack { + /** Stable identifier; also the i18n sub-key for the pack name and description. */ + id: string + emoji: string + habits: readonly TemplatePackHabit[] +} + +const TEMPLATE_PACK_I18N_PREFIX = 'onboarding.flow.templatePacks' + +export function templatePackNameKey(packId: string): string { + return `${TEMPLATE_PACK_I18N_PREFIX}.packs.${packId}.name` +} + +export function templatePackDescriptionKey(packId: string): string { + return `${TEMPLATE_PACK_I18N_PREFIX}.packs.${packId}.description` +} + +export function templatePackHabitTitleKey(packId: string, habitKey: string): string { + return `${TEMPLATE_PACK_I18N_PREFIX}.packs.${packId}.habits.${habitKey}` +} + +export function templatePackTagKey(tagSlug: string): string { + return `${TEMPLATE_PACK_I18N_PREFIX}.tags.${tagSlug}` +} + +export const TEMPLATE_PACKS: readonly TemplatePack[] = [ + { + id: 'morningRoutine', + emoji: '🌅', + habits: [ + { key: 'makeBed', emoji: 'đŸ›ïž', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['morning'] }, + { key: 'water', emoji: '💧', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['morning', 'health'] }, + { key: 'stretch', emoji: '🧘', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['morning', 'movement'] }, + { key: 'breakfast', emoji: '🍳', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['morning', 'health'] }, + { key: 'planDay', emoji: '✅', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['morning', 'focus'] }, + ], + }, + { + id: 'fitnessMovement', + emoji: '🏃', + habits: [ + { key: 'walk', emoji: 'đŸš¶', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['movement'] }, + { key: 'strength', emoji: 'đŸ‹ïž', frequencyUnit: 'Week', frequencyQuantity: 1, tags: ['movement', 'fitness'] }, + { key: 'mobility', emoji: 'đŸ€ž', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['movement'] }, + { key: 'stairs', emoji: 'đŸȘœ', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['movement'] }, + { key: 'steps', emoji: '👟', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['movement', 'health'] }, + ], + }, + { + id: 'studyFocus', + emoji: '📚', + habits: [ + { key: 'deepWork', emoji: '📚', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['focus'] }, + { key: 'read', emoji: '📖', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['focus', 'learning'] }, + { key: 'reviewNotes', emoji: '📝', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['focus', 'learning'] }, + { key: 'phoneFree', emoji: 'đŸ“”', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['focus'] }, + { key: 'singleTask', emoji: '🎯', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['focus'] }, + ], + }, + { + id: 'mindfulnessWellbeing', + emoji: '🧘', + habits: [ + { key: 'meditate', emoji: '🧘', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['mindfulness'] }, + { key: 'gratitude', emoji: '🙏', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['mindfulness', 'journal'] }, + { key: 'digitalSunset', emoji: '🌙', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['mindfulness', 'sleep'] }, + { key: 'breathing', emoji: 'đŸŒŹïž', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['mindfulness'] }, + { key: 'reachOut', emoji: '💬', frequencyUnit: 'Day', frequencyQuantity: 1, tags: ['mindfulness', 'social'] }, + ], + }, +] + +export function getTemplatePackById(packId: string): TemplatePack | undefined { + return TEMPLATE_PACKS.find((pack) => pack.id === packId) +} + +/** + * Resolves a pack's enabled habits into bulk-create items. Disabled keys are dropped; + * titles and tags are localized through the provided translate function. + */ +export function buildBulkItemsFromPack( + pack: TemplatePack, + disabledHabitKeys: ReadonlySet, + translate: (key: string) => string, +): BulkHabitItem[] { + return pack.habits + .filter((habit) => !disabledHabitKeys.has(habit.key)) + .map((habit) => ({ + title: translate(templatePackHabitTitleKey(pack.id, habit.key)), + emoji: habit.emoji, + frequencyUnit: habit.frequencyUnit, + frequencyQuantity: habit.frequencyQuantity, + isGeneral: false, + tags: habit.tags.map((slug) => translate(templatePackTagKey(slug))), + })) +} From 9080a6239da3f3922dc8d2eafe7cd214a4a0029b Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sat, 27 Jun 2026 04:23:57 -0300 Subject: [PATCH 6/6] feat(referral): surface referral on Profile + Today + milestone prompts (#191) Profile referral card under stat tiles + dismissible Today entry (removed from About). One-shot milestone prompt on streak 7/30/100 + level-ups via the existing celebration queue (no stacking), persisted referral-prompt-store w/ 14-day cooldown. web+mobile parity. No backend change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../referral/referral-prompt.test.tsx | 182 ++++++++++++++++ .../screens/account-deletion.test.tsx | 7 + .../screens/intentional-offline-ux.test.tsx | 7 + .../__tests__/screens/profile-screen.test.tsx | 38 ++++ .../__tests__/screens/today-screen.test.tsx | 7 + apps/mobile/app/(tabs)/index.tsx | 23 ++ apps/mobile/app/(tabs)/profile.tsx | 12 ++ apps/mobile/app/_layout.tsx | 11 + apps/mobile/app/about.tsx | 8 - .../components/referral/referral-card.tsx | 28 ++- .../components/referral/referral-prompt.tsx | 184 ++++++++++++++++ apps/mobile/hooks/use-habits.ts | 6 + apps/mobile/stores/referral-prompt-store.ts | 26 +++ apps/mobile/test-mocks/expo-clipboard.ts | 7 + apps/mobile/test-mocks/lucide-react-native.ts | 2 + apps/mobile/vitest.config.ts | 4 + apps/web/__tests__/app/profile-page.test.tsx | 27 ++- apps/web/__tests__/app/today-page.test.tsx | 8 + .../referral/referral-card.test.tsx | 14 ++ .../referral/referral-prompt.test.tsx | 140 ++++++++++++ apps/web/app/(app)/about/page.tsx | 5 - apps/web/app/(app)/layout.tsx | 11 + apps/web/app/(app)/page.tsx | 14 ++ apps/web/app/(app)/profile/page.tsx | 7 + .../web/components/referral/referral-card.tsx | 92 +++++--- .../components/referral/referral-prompt.tsx | 163 ++++++++++++++ apps/web/hooks/use-habits.ts | 6 + apps/web/lib/providers.tsx | 2 + apps/web/stores/referral-prompt-store.ts | 36 ++++ .../__tests__/referral-prompt-store.test.ts | 201 ++++++++++++++++++ packages/shared/src/i18n/en.json | 8 + packages/shared/src/i18n/pt-BR.json | 8 + packages/shared/src/stores/index.ts | 15 ++ .../src/stores/referral-prompt-store.ts | 152 +++++++++++++ 34 files changed, 1409 insertions(+), 52 deletions(-) create mode 100644 apps/mobile/__tests__/components/referral/referral-prompt.test.tsx create mode 100644 apps/mobile/components/referral/referral-prompt.tsx create mode 100644 apps/mobile/stores/referral-prompt-store.ts create mode 100644 apps/mobile/test-mocks/expo-clipboard.ts create mode 100644 apps/web/__tests__/components/referral/referral-prompt.test.tsx create mode 100644 apps/web/components/referral/referral-prompt.tsx create mode 100644 apps/web/stores/referral-prompt-store.ts create mode 100644 packages/shared/src/__tests__/referral-prompt-store.test.ts create mode 100644 packages/shared/src/stores/referral-prompt-store.ts diff --git a/apps/mobile/__tests__/components/referral/referral-prompt.test.tsx b/apps/mobile/__tests__/components/referral/referral-prompt.test.tsx new file mode 100644 index 000000000..302454262 --- /dev/null +++ b/apps/mobile/__tests__/components/referral/referral-prompt.test.tsx @@ -0,0 +1,182 @@ +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en' }, + }), +})) + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ getQueryData: () => undefined }), +})) + +vi.mock('@/components/bottom-sheet-modal', () => ({ + BottomSheetModal: ({ + open, + children, + }: { + open: boolean + children: React.ReactNode + onClose?: () => void + }) => (open ? React.createElement('BottomSheetOpen', {}, children) : null), +})) + +vi.mock('@/components/referral/referral-drawer', () => ({ + ReferralDrawer: ({ open }: { open: boolean; onClose?: () => void }) => + open ? React.createElement('ReferralDrawerOpen', {}) : null, +})) + +vi.mock('@/components/ui/pill-button', () => ({ + PillButton: ({ + children, + onPress, + }: { + children: React.ReactNode + onPress?: () => void + }) => React.createElement('PillButtonStub', { onPress }, children), +})) + +import { ReferralPrompt } from '@/components/referral/referral-prompt' +import { useUIStore } from '@/stores/ui-store' +import { useReferralPromptStore } from '@/stores/referral-prompt-store' + +const TestRenderer = require('react-test-renderer') + +type RenderedNode = { + type: unknown + props: { onPress?: () => void; accessibilityLabel?: string } & Record< + string, + unknown + > +} +type RenderedTree = { + root: { findAll: (predicate: (node: RenderedNode) => boolean) => RenderedNode[] } + unmount: () => void +} + +let currentTree: RenderedTree | null = null + +function findByType(tree: RenderedTree, typeName: string) { + return tree.root.findAll((node) => node.type === typeName) +} + +function resetStores() { + useReferralPromptStore.setState({ + promptedMilestoneKeys: [], + lastPromptedAtIso: null, + homeEntryDismissed: false, + armedMilestoneKey: null, + }) + useUIStore.setState({ activeCelebration: null, queuedCelebrations: [] }) +} + +describe('ReferralPrompt (mobile)', () => { + beforeEach(() => { + vi.useFakeTimers() + resetStores() + }) + + afterEach(() => { + if (currentTree) { + TestRenderer.act(() => currentTree!.unmount()) + currentTree = null + } + vi.clearAllTimers() + vi.useRealTimers() + }) + + async function render() { + await TestRenderer.act(async () => { + currentTree = TestRenderer.create() + await Promise.resolve() + }) + return currentTree! + } + + async function renderArmed(milestoneKey: string) { + const tree = await render() + await TestRenderer.act(async () => { + useReferralPromptStore.getState().armReferralPrompt(milestoneKey) + await Promise.resolve() + }) + return tree + } + + it('renders nothing when no milestone is armed', async () => { + const tree = await render() + expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(0) + }) + + it('shows the prompt after the settle delay and marks it prompted', async () => { + const tree = await renderArmed('streak-7') + expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(0) + + await TestRenderer.act(async () => { + await vi.advanceTimersByTimeAsync(500) + }) + + expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(1) + expect(useReferralPromptStore.getState().promptedMilestoneKeys).toContain( + 'streak-7', + ) + }) + + it('stays hidden while a celebration is in flight', async () => { + useUIStore.getState().enqueueCelebration('streak', { streak: 7 }) + const tree = await renderArmed('streak-7') + + await TestRenderer.act(async () => { + await vi.advanceTimersByTimeAsync(1000) + }) + + expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(0) + }) + + it('stays hidden and clears the arm when the milestone was already prompted', async () => { + useReferralPromptStore.setState({ promptedMilestoneKeys: ['streak-7'] }) + const tree = await renderArmed('streak-7') + + await TestRenderer.act(async () => { + await vi.advanceTimersByTimeAsync(1000) + }) + + expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(0) + expect(useReferralPromptStore.getState().armedMilestoneKey).toBeNull() + }) + + it('opens the drawer from the CTA', async () => { + const tree = await renderArmed('level-3') + await TestRenderer.act(async () => { + await vi.advanceTimersByTimeAsync(500) + }) + + const [cta] = findByType(tree, 'PillButtonStub') + await TestRenderer.act(async () => { + cta!.props.onPress?.() + await Promise.resolve() + }) + + expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(0) + expect(findByType(tree, 'ReferralDrawerOpen')).toHaveLength(1) + }) + + it('dismisses without opening the drawer from "maybe later"', async () => { + const tree = await renderArmed('streak-30') + await TestRenderer.act(async () => { + await vi.advanceTimersByTimeAsync(500) + }) + + const [later] = tree.root.findAll( + (node) => node.props.accessibilityLabel === 'referral.prompt.later', + ) + await TestRenderer.act(async () => { + later!.props.onPress?.() + await Promise.resolve() + }) + + expect(findByType(tree, 'BottomSheetOpen')).toHaveLength(0) + expect(findByType(tree, 'ReferralDrawerOpen')).toHaveLength(0) + }) +}) diff --git a/apps/mobile/__tests__/screens/account-deletion.test.tsx b/apps/mobile/__tests__/screens/account-deletion.test.tsx index cd6bcf867..98cbcd4bb 100644 --- a/apps/mobile/__tests__/screens/account-deletion.test.tsx +++ b/apps/mobile/__tests__/screens/account-deletion.test.tsx @@ -5,6 +5,13 @@ import { API } from '@orbit/shared/api' import ProfileScreen from '@/app/(tabs)/profile' +vi.mock('@/components/referral/referral-card', () => ({ + ReferralCard: () => null, +})) +vi.mock('@/components/referral/referral-drawer', () => ({ + ReferralDrawer: () => null, +})) + const TestRenderer = require('react-test-renderer') vi.hoisted(() => { diff --git a/apps/mobile/__tests__/screens/intentional-offline-ux.test.tsx b/apps/mobile/__tests__/screens/intentional-offline-ux.test.tsx index ff7443291..f649233ad 100644 --- a/apps/mobile/__tests__/screens/intentional-offline-ux.test.tsx +++ b/apps/mobile/__tests__/screens/intentional-offline-ux.test.tsx @@ -7,6 +7,13 @@ import RetrospectiveScreen from '@/app/retrospective' import SupportScreen from '@/app/support' import ProfileScreen from '@/app/(tabs)/profile' +vi.mock('@/components/referral/referral-card', () => ({ + ReferralCard: () => null, +})) +vi.mock('@/components/referral/referral-drawer', () => ({ + ReferralDrawer: () => null, +})) + const TestRenderer = require('react-test-renderer') vi.hoisted(() => { diff --git a/apps/mobile/__tests__/screens/profile-screen.test.tsx b/apps/mobile/__tests__/screens/profile-screen.test.tsx index 36deccdec..b182df368 100644 --- a/apps/mobile/__tests__/screens/profile-screen.test.tsx +++ b/apps/mobile/__tests__/screens/profile-screen.test.tsx @@ -4,6 +4,19 @@ import { createMockProfile } from '@orbit/shared/__tests__/factories' import ProfileScreen from '@/app/(tabs)/profile' +vi.mock('@/components/referral/referral-card', () => ({ + ReferralCard: ({ onOpen }: { onOpen: () => void; onDismiss?: () => void }) => + React.createElement('ReferralCardStub', { + accessibilityRole: 'button', + onPress: onOpen, + }), +})) + +vi.mock('@/components/referral/referral-drawer', () => ({ + ReferralDrawer: ({ open }: { open: boolean; onClose?: () => void }) => + open ? React.createElement('ReferralDrawerOpen', {}) : null, +})) + const TestRenderer = require('react-test-renderer') const { mockUseGamificationProfile } = vi.hoisted(() => ({ @@ -244,4 +257,29 @@ describe('ProfileScreen', () => { expect(mockUseGamificationProfile).toHaveBeenCalledWith(false) }) + + it('mounts the referral card on profile and opens the drawer when pressed', async () => { + let tree: ReturnType + await TestRenderer.act(async () => { + tree = TestRenderer.create() + await Promise.resolve() + }) + + const [card] = tree!.root.findAll( + (node: { type: unknown }) => node.type === 'ReferralCardStub', + ) + expect(card).toBeTruthy() + expect( + tree!.root.findAll((node: { type: unknown }) => node.type === 'ReferralDrawerOpen'), + ).toHaveLength(0) + + await TestRenderer.act(async () => { + card.props.onPress() + await Promise.resolve() + }) + + expect( + tree!.root.findAll((node: { type: unknown }) => node.type === 'ReferralDrawerOpen'), + ).toHaveLength(1) + }) }) diff --git a/apps/mobile/__tests__/screens/today-screen.test.tsx b/apps/mobile/__tests__/screens/today-screen.test.tsx index f804234c3..998947eb9 100644 --- a/apps/mobile/__tests__/screens/today-screen.test.tsx +++ b/apps/mobile/__tests__/screens/today-screen.test.tsx @@ -13,6 +13,13 @@ import TodayScreen, { shouldRedirectGoalsTab, } from "@/app/(tabs)/index"; +vi.mock("@/components/referral/referral-card", () => ({ + ReferralCard: () => null, +})); +vi.mock("@/components/referral/referral-drawer", () => ({ + ReferralDrawer: () => null, +})); + const TestRenderer: typeof import("react-test-renderer") = require("react-test-renderer"); type RenderedNode = { diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index 655e1b029..53c7a75ae 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -42,6 +42,7 @@ import { useTags } from "@/hooks/use-tags"; import { useTotalHabitCount } from "@/hooks/use-habit-queries"; import { useCoachMark } from "@/hooks/use-coach-mark"; import { useUIStore } from "@/stores/ui-store"; +import { useReferralPromptStore } from "@/stores/referral-prompt-store"; import { HabitList, type HabitListHandle } from "@/components/habit-list"; import { CreateHabitModal } from "@/components/habits/create-habit-modal"; import { HabitDetailDrawer } from "@/components/habits/habit-detail-drawer"; @@ -54,6 +55,8 @@ import { GradientTop } from "@/components/ui/gradient-top"; import { TrialBanner } from "@/components/ui/trial-banner"; import { TodayHabitsHeader } from "@/components/today/today-habits-header"; import { ReviewReminderCard } from "@/components/review-reminder-card"; +import { ReferralCard } from "@/components/referral/referral-card"; +import { ReferralDrawer } from "@/components/referral/referral-drawer"; import { SetupChecklistCard } from "@/components/today/setup-checklist-card"; import { useHorizontalSwipe } from "@/hooks/use-horizontal-swipe"; import type { MenuAnchorRect } from "@/lib/anchored-menu"; @@ -591,6 +594,9 @@ export default function TodayScreen() { const setShowCreateModal = useUIStore((s) => s.setShowCreateModal); const showCreateGoalModal = useUIStore((s) => s.showCreateGoalModal); const setShowCreateGoalModal = useUIStore((s) => s.setShowCreateGoalModal); + const homeEntryDismissed = useReferralPromptStore((s) => s.homeEntryDismissed); + const dismissHomeEntry = useReferralPromptStore((s) => s.dismissHomeEntry); + const [showReferral, setShowReferral] = useState(false); const [prevFilters, setPrevFilters] = useState(filters); if (filters !== prevFilters) { setPrevFilters(filters); @@ -855,6 +861,15 @@ export default function TodayScreen() { /> ) : null} + {currentActiveView === "today" && + isToday(selectedDate) && + !homeEntryDismissed ? ( + setShowReferral(true)} + onDismiss={dismissHomeEntry} + /> + ) : null} + setShowCreateGoalModal(false)} /> + + setShowReferral(false)} + /> ); } diff --git a/apps/mobile/app/(tabs)/profile.tsx b/apps/mobile/app/(tabs)/profile.tsx index 578fe151d..868b85a9b 100644 --- a/apps/mobile/app/(tabs)/profile.tsx +++ b/apps/mobile/app/(tabs)/profile.tsx @@ -31,6 +31,8 @@ import { StatTile } from '@/components/ui/stat-tile' import { ThemeToggle } from '@/components/ui/theme-toggle' import { StreakBadge } from '@/components/gamification/streak-badge' import { NotificationBell } from '@/components/navigation/notification-bell' +import { ReferralCard } from '@/components/referral/referral-card' +import { ReferralDrawer } from '@/components/referral/referral-drawer' import { useAppTheme } from '@/lib/use-app-theme' import { createTokensV2, tintFromPrimary } from '@/lib/theme' import { buildUpgradeHref } from '@/lib/upgrade-route' @@ -127,6 +129,7 @@ export default function ProfileScreen() { const [showEditName, setShowEditName] = useState(false) const [showTourReplay, setShowTourReplay] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) + const [showReferral, setShowReferral] = useState(false) useEffect(() => { if (subscription === 'success') { @@ -311,6 +314,10 @@ export default function ProfileScreen() { ) : null} + + setShowReferral(true)} /> + + router.push(buildUpgradeHref('/profile'))} @@ -417,6 +424,11 @@ export default function ProfileScreen() { onClose={() => setShowDeleteModal(false)} profile={profile} /> + + setShowReferral(false)} + /> ) } diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 5bf064225..c248aac49 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -37,6 +37,8 @@ import { import { dismissTopOverlay } from '@/lib/overlay-stack' import { buildUpgradeHref } from '@/lib/upgrade-route' import { useUIStore } from '@/stores/ui-store' +import { useReferralPromptStore } from '@/stores/referral-prompt-store' +import { getReferralLevelMilestone } from '@orbit/shared/stores' import { OnboardingFlow } from '@/components/onboarding/onboarding-flow' import { CalendarImportPrompt } from '@/components/onboarding/calendar-import-prompt' import { BottomTabBar, type BottomTabId } from '@/components/navigation/bottom-tab-bar' @@ -45,6 +47,7 @@ import { AchievementToast } from '@/components/gamification/achievement-toast' import { AllDoneCelebration } from '@/components/gamification/all-done-celebration' import { GoalCompletedCelebration } from '@/components/gamification/goal-completed-celebration' import { LevelUpOverlay } from '@/components/gamification/level-up-overlay' +import { ReferralPrompt } from '@/components/referral/referral-prompt' import { StreakCelebration } from '@/components/gamification/streak-celebration' import { StreakFreezeCelebration, @@ -301,6 +304,13 @@ function GlobalOverlays({ const hasProAccess = profile?.hasProAccess ?? false const canViewGamification = profile?.canViewGamification ?? false const gamification = useGamificationProfile(canViewGamification) + const armReferralPrompt = useReferralPromptStore((s) => s.armReferralPrompt) + + useEffect(() => { + if (gamification.leveledUp && gamification.newLevel) { + armReferralPrompt(getReferralLevelMilestone(gamification.newLevel)) + } + }, [gamification.leveledUp, gamification.newLevel, armReferralPrompt]) return ( <> @@ -324,6 +334,7 @@ function GlobalOverlays({ onClear={gamification.clearLevelUp} /> ) : null} + ) : null} diff --git a/apps/mobile/app/about.tsx b/apps/mobile/app/about.tsx index c25c3285d..1381e4757 100644 --- a/apps/mobile/app/about.tsx +++ b/apps/mobile/app/about.tsx @@ -9,8 +9,6 @@ import { Compass, FileText, Mail, Shield } from 'lucide-react-native' import { createTokensV2 } from '@/lib/theme' import { AppLogo } from '@/components/ui/app-logo' import { FeatureGuideDrawer } from '@/components/onboarding/feature-guide-drawer' -import { ReferralCard } from '@/components/referral/referral-card' -import { ReferralDrawer } from '@/components/referral/referral-drawer' import { useGoBackOrFallback } from '@/hooks/use-go-back-or-fallback' import { useAppTheme } from '@/lib/use-app-theme' import { AppBar } from '@/components/ui/app-bar' @@ -32,7 +30,6 @@ export default function AboutScreen() { [currentScheme, currentTheme], ) const [showGuide, setShowGuide] = useState(false) - const [showReferral, setShowReferral] = useState(false) const appVersion = Constants.expoConfig?.version return ( @@ -68,7 +65,6 @@ export default function AboutScreen() { label={t('onboarding.featureGuide.openButton')} onPress={() => setShowGuide(true)} /> - setShowReferral(true)} /> setShowGuide(false)} /> - setShowReferral(false)} - /> ) } diff --git a/apps/mobile/components/referral/referral-card.tsx b/apps/mobile/components/referral/referral-card.tsx index 93d57bd3b..6801c5ecf 100644 --- a/apps/mobile/components/referral/referral-card.tsx +++ b/apps/mobile/components/referral/referral-card.tsx @@ -1,16 +1,18 @@ import { Pressable, StyleSheet, Text, View } from 'react-native' import { useTranslation } from 'react-i18next' -import { ChevronRight, UserPlus } from 'lucide-react-native' +import { ChevronRight, UserPlus, X } from 'lucide-react-native' import { useReferral } from '@/hooks/use-referral' import { createTokensV2, tintFromPrimary } from '@/lib/theme' import { useAppTheme } from '@/lib/use-app-theme' interface ReferralCardProps { onOpen: () => void + /** When provided, the card shows a dismiss control instead of the chevron (the dismissible Today entry). */ + onDismiss?: () => void } -/** Kit referral entry card: primary-tinted icon disc, title, progress line, chevron. */ -export function ReferralCard({ onOpen }: Readonly) { +/** Kit referral entry card: primary-tinted icon disc, title, progress line, and either a chevron or a dismiss control. */ +export function ReferralCard({ onOpen, onDismiss }: Readonly) { const { t } = useTranslation() const { currentScheme, currentTheme } = useAppTheme() const tokens = createTokensV2(currentScheme, currentTheme) @@ -53,7 +55,19 @@ export function ReferralCard({ onOpen }: Readonly) { {desc} - + {onDismiss ? ( + + + + ) : ( + + )} ) @@ -89,6 +103,12 @@ const styles = StyleSheet.create({ minWidth: 0, gap: 3, }, + dismissButton: { + width: 28, + height: 28, + alignItems: 'center', + justifyContent: 'center', + }, title: { fontFamily: 'Rubik_500Medium', fontSize: 16, diff --git a/apps/mobile/components/referral/referral-prompt.tsx b/apps/mobile/components/referral/referral-prompt.tsx new file mode 100644 index 000000000..9c3ff3fa3 --- /dev/null +++ b/apps/mobile/components/referral/referral-prompt.tsx @@ -0,0 +1,184 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { useTranslation } from 'react-i18next' +import { useQueryClient } from '@tanstack/react-query' +import { Gift } from 'lucide-react-native' +import { referralKeys } from '@orbit/shared/query' +import type { ReferralDashboard } from '@orbit/shared/types/referral' +import { + canPromptReferral, + parseReferralMilestoneKey, +} from '@orbit/shared/stores' +import { BottomSheetModal } from '@/components/bottom-sheet-modal' +import { PillButton } from '@/components/ui/pill-button' +import { ReferralDrawer } from '@/components/referral/referral-drawer' +import { createTokensV2, tintFromPrimary } from '@/lib/theme' +import { useAppTheme } from '@/lib/use-app-theme' +import { useUIStore } from '@/stores/ui-store' +import { useReferralPromptStore } from '@/stores/referral-prompt-store' + +const SETTLE_DELAY_MS = 500 +const DEFAULT_DISCOUNT_PERCENT = 10 + +/** One-shot milestone nudge: shows once no celebration is in flight and the re-prompt guard allows it, then hands off to the referral drawer. */ +export function ReferralPrompt() { + const { t } = useTranslation() + const { currentScheme, currentTheme } = useAppTheme() + const tokens = useMemo( + () => createTokensV2(currentScheme, currentTheme), + [currentScheme, currentTheme], + ) + const styles = useMemo(() => createStyles(tokens), [tokens]) + const queryClient = useQueryClient() + const armedMilestoneKey = useReferralPromptStore((s) => s.armedMilestoneKey) + const markReferralPrompted = useReferralPromptStore( + (s) => s.markReferralPrompted, + ) + const clearArmedMilestone = useReferralPromptStore( + (s) => s.clearArmedMilestone, + ) + const celebrationInFlight = useUIStore( + (s) => s.activeCelebration !== null || s.queuedCelebrations.length > 0, + ) + + const [visibleKey, setVisibleKey] = useState(null) + const [showDrawer, setShowDrawer] = useState(false) + const settleTimerRef = useRef>(undefined) + + useEffect(() => { + if (visibleKey || !armedMilestoneKey || celebrationInFlight) return + + if ( + !canPromptReferral( + useReferralPromptStore.getState(), + armedMilestoneKey, + new Date().toISOString(), + ) + ) { + clearArmedMilestone() + return + } + + settleTimerRef.current = setTimeout(() => { + markReferralPrompted(armedMilestoneKey, new Date().toISOString()) + setVisibleKey(armedMilestoneKey) + }, SETTLE_DELAY_MS) + + return () => { + if (settleTimerRef.current) clearTimeout(settleTimerRef.current) + } + }, [ + armedMilestoneKey, + celebrationInFlight, + visibleKey, + markReferralPrompted, + clearArmedMilestone, + ]) + + const milestone = visibleKey ? parseReferralMilestoneKey(visibleKey) : null + const cached = queryClient.getQueryData(referralKeys.all) + const discount = cached?.stats.discountPercent ?? DEFAULT_DISCOUNT_PERCENT + + const title = + milestone?.kind === 'level' + ? t('referral.prompt.levelTitle', { level: milestone.value }) + : t('referral.prompt.streakTitle', { count: milestone?.value ?? 0 }) + + function dismiss() { + setVisibleKey(null) + } + + function openDrawer() { + setVisibleKey(null) + setShowDrawer(true) + } + + return ( + <> + + + {t('referral.prompt.eyebrow')} + + + + {title} + + {t('referral.prompt.body', { discount })} + + + + {t('referral.prompt.cta')} + + + {t('referral.prompt.later')} + + + + + setShowDrawer(false)} /> + + ) +} + +function createStyles(tokens: ReturnType) { + return StyleSheet.create({ + content: { + flex: 1, + alignItems: 'center', + paddingHorizontal: 24, + paddingTop: 24, + gap: 16, + }, + eyebrow: { + fontFamily: 'Rubik_500Medium', + fontSize: 12, + letterSpacing: 0.96, + textTransform: 'uppercase', + color: tokens.fg3, + }, + heroDisc: { + width: 64, + height: 64, + borderRadius: 999, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: tintFromPrimary(tokens, 0.16), + }, + title: { + fontFamily: 'Rubik_500Medium', + fontSize: 22, + textAlign: 'center', + color: tokens.fg1, + }, + body: { + fontFamily: 'Rubik_400Regular', + fontSize: 15, + lineHeight: 22, + textAlign: 'center', + color: tokens.fg2, + }, + actions: { + alignSelf: 'stretch', + gap: 8, + paddingTop: 4, + }, + laterButton: { + alignItems: 'center', + paddingVertical: 12, + }, + laterText: { + fontFamily: 'Rubik_500Medium', + fontSize: 14, + color: tokens.fg3, + }, + }) +} diff --git a/apps/mobile/hooks/use-habits.ts b/apps/mobile/hooks/use-habits.ts index 6bcea527b..39e9ef43d 100644 --- a/apps/mobile/hooks/use-habits.ts +++ b/apps/mobile/hooks/use-habits.ts @@ -65,8 +65,10 @@ import { snapshotHabitLists, updateHabitLists, } from '@/lib/habit-mutation-helpers' +import { getReferralStreakMilestone } from '@orbit/shared/stores' import { useReviewReminderStore } from '@/stores/review-reminder-store' import { useUIStore } from '@/stores/ui-store' +import { useReferralPromptStore } from '@/stores/referral-prompt-store' type CreateHabitMutationInput = CreateHabitRequest & { __offlineTempId?: string } type BulkCreateHabitMutationInput = BulkCreateRequest & { __offlineTempIds?: string[] } @@ -161,6 +163,10 @@ export function useLogHabit() { queryClient.setQueryData(profileKeys.detail(), (old) => old ? { ...old, currentStreak: response.currentStreak } : old, ) + const referralMilestoneKey = getReferralStreakMilestone(response.currentStreak) + if (referralMilestoneKey) { + useReferralPromptStore.getState().armReferralPrompt(referralMilestoneKey) + } } // Apply targeted goal updates from enriched response (instant, no refetch needed) diff --git a/apps/mobile/stores/referral-prompt-store.ts b/apps/mobile/stores/referral-prompt-store.ts new file mode 100644 index 000000000..c92ee8a27 --- /dev/null +++ b/apps/mobile/stores/referral-prompt-store.ts @@ -0,0 +1,26 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { create } from 'zustand' +import { createJSONStorage, persist } from 'zustand/middleware' +import { + createReferralPromptStoreState, + getPersistedReferralPromptState, + migratePersistedReferralPromptState, + type PersistedReferralPromptState, + type ReferralPromptStoreState, +} from '@orbit/shared/stores' + +export const useReferralPromptStore = create()( + persist( + (set) => + createReferralPromptStoreState( + set as Parameters[0], + ), + { + name: 'orbit-referral-prompt-store', + version: 1, + storage: createJSONStorage(() => AsyncStorage), + migrate: migratePersistedReferralPromptState, + partialize: getPersistedReferralPromptState, + }, + ), +) diff --git a/apps/mobile/test-mocks/expo-clipboard.ts b/apps/mobile/test-mocks/expo-clipboard.ts new file mode 100644 index 000000000..5020efb13 --- /dev/null +++ b/apps/mobile/test-mocks/expo-clipboard.ts @@ -0,0 +1,7 @@ +export async function setStringAsync() { + return true +} + +export async function getStringAsync() { + return '' +} diff --git a/apps/mobile/test-mocks/lucide-react-native.ts b/apps/mobile/test-mocks/lucide-react-native.ts index 4cf7a6a4a..fc021ea68 100644 --- a/apps/mobile/test-mocks/lucide-react-native.ts +++ b/apps/mobile/test-mocks/lucide-react-native.ts @@ -27,6 +27,7 @@ export const Copy = createIcon('Copy') export const Eye = createIcon('Eye') export const FastForward = createIcon('FastForward') export const Flame = createIcon('Flame') +export const Gift = createIcon('Gift') export const HelpCircle = createIcon('HelpCircle') export const Home = createIcon('Home') export const Infinity = createIcon('Infinity') @@ -45,5 +46,6 @@ export const Shuffle = createIcon('Shuffle') export const Sparkles = createIcon('Sparkles') export const Trash2 = createIcon('Trash2') export const User = createIcon('User') +export const UserPlus = createIcon('UserPlus') export const WifiOff = createIcon('WifiOff') export const X = createIcon('X') diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts index 3557e1c62..a2cee9c89 100644 --- a/apps/mobile/vitest.config.ts +++ b/apps/mobile/vitest.config.ts @@ -88,6 +88,10 @@ export default defineConfig({ find: 'expo-store-review', replacement: path.resolve(__dirname, './test-mocks/expo-store-review.ts'), }, + { + find: 'expo-clipboard', + replacement: path.resolve(__dirname, './test-mocks/expo-clipboard.ts'), + }, { find: 'expo-file-system', replacement: path.resolve(__dirname, './test-mocks/expo-file-system.ts'), diff --git a/apps/web/__tests__/app/profile-page.test.tsx b/apps/web/__tests__/app/profile-page.test.tsx index d6ce7fe69..8ba82c188 100644 --- a/apps/web/__tests__/app/profile-page.test.tsx +++ b/apps/web/__tests__/app/profile-page.test.tsx @@ -1,6 +1,6 @@ import React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { render } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { createMockProfile } from '@orbit/shared/__tests__/factories' const { mockUseGamificationProfile } = vi.hoisted(() => ({ @@ -91,6 +91,19 @@ vi.mock('@/app/(app)/profile/_components/tour-replay-card', () => ({ TourReplayCard: () => null, })) +vi.mock('@/components/referral/referral-card', () => ({ + ReferralCard: ({ onOpen }: { onOpen: () => void; onDismiss?: () => void }) => ( + + ), +})) + +vi.mock('@/components/referral/referral-drawer', () => ({ + ReferralDrawer: ({ open }: { open: boolean; onOpenChange?: (open: boolean) => void }) => + open ?
: null, +})) + import ProfilePage from '@/app/(app)/profile/page' describe('ProfilePage', () => { @@ -109,4 +122,16 @@ describe('ProfilePage', () => { expect(document.body.textContent).toContain('13') }) + + it('mounts the referral card on profile and opens the drawer when tapped', () => { + render() + + const card = screen.getByTestId('profile-referral-card') + expect(card).toBeInTheDocument() + expect(screen.queryByTestId('profile-referral-drawer')).toBeNull() + + fireEvent.click(card) + + expect(screen.getByTestId('profile-referral-drawer')).toBeInTheDocument() + }) }) diff --git a/apps/web/__tests__/app/today-page.test.tsx b/apps/web/__tests__/app/today-page.test.tsx index 907eb6472..58fb2d361 100644 --- a/apps/web/__tests__/app/today-page.test.tsx +++ b/apps/web/__tests__/app/today-page.test.tsx @@ -141,6 +141,14 @@ vi.mock('@/components/today/setup-checklist-card', () => ({ SetupChecklistCard: () => null, })) +vi.mock('@/components/referral/referral-card', () => ({ + ReferralCard: () => null, +})) + +vi.mock('@/components/referral/referral-drawer', () => ({ + ReferralDrawer: () => null, +})) + vi.mock('@/components/habits/habit-list', () => ({ HabitList: React.forwardRef(function MockHabitList(props: Record, ref) { React.useImperativeHandle(ref, () => habitListHandle) diff --git a/apps/web/__tests__/components/referral/referral-card.test.tsx b/apps/web/__tests__/components/referral/referral-card.test.tsx index e9f024d92..91740f72a 100644 --- a/apps/web/__tests__/components/referral/referral-card.test.tsx +++ b/apps/web/__tests__/components/referral/referral-card.test.tsx @@ -65,4 +65,18 @@ describe('ReferralCard', () => { const svg = container.querySelector('svg') expect(svg).toBeInTheDocument() }) + + it('renders a dismiss control and calls onDismiss without opening', () => { + mockIsLoading = false + mockStats = null + const onOpen = vi.fn() + const onDismiss = vi.fn() + render() + + const dismiss = screen.getByRole('button', { name: 'common.dismiss' }) + fireEvent.click(dismiss) + + expect(onDismiss).toHaveBeenCalledTimes(1) + expect(onOpen).not.toHaveBeenCalled() + }) }) diff --git a/apps/web/__tests__/components/referral/referral-prompt.test.tsx b/apps/web/__tests__/components/referral/referral-prompt.test.tsx new file mode 100644 index 000000000..629701313 --- /dev/null +++ b/apps/web/__tests__/components/referral/referral-prompt.test.tsx @@ -0,0 +1,140 @@ +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})) + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ getQueryData: () => undefined }), +})) + +vi.mock('@/components/ui/app-overlay', () => ({ + AppOverlay: ({ + open, + children, + titleContent, + }: { + open: boolean + children?: React.ReactNode + titleContent?: React.ReactNode + onOpenChange?: (open: boolean) => void + }) => + open ? ( +
+ {titleContent} + {children} +
+ ) : null, +})) + +vi.mock('@/components/referral/referral-drawer', () => ({ + ReferralDrawer: ({ open }: { open: boolean; onOpenChange?: (open: boolean) => void }) => + open ?
: null, +})) + +import { ReferralPrompt } from '@/components/referral/referral-prompt' +import { useUIStore } from '@/stores/ui-store' +import { useReferralPromptStore } from '@/stores/referral-prompt-store' + +function resetStores() { + useReferralPromptStore.setState({ + promptedMilestoneKeys: [], + lastPromptedAtIso: null, + homeEntryDismissed: false, + armedMilestoneKey: null, + }) + useUIStore.setState({ activeCelebration: null, queuedCelebrations: [] }) +} + +async function arm(milestoneKey: string) { + await act(async () => { + useReferralPromptStore.getState().armReferralPrompt(milestoneKey) + await Promise.resolve() + }) +} + +async function settle() { + await act(async () => { + await vi.advanceTimersByTimeAsync(500) + }) +} + +describe('ReferralPrompt', () => { + beforeEach(() => { + vi.useFakeTimers() + resetStores() + }) + + afterEach(() => { + cleanup() + vi.clearAllTimers() + vi.useRealTimers() + }) + + it('renders nothing when no milestone is armed', () => { + render() + expect(screen.queryByTestId('referral-prompt')).toBeNull() + }) + + it('shows the prompt after the settle delay and marks it prompted', async () => { + render() + await arm('streak-7') + expect(screen.queryByTestId('referral-prompt')).toBeNull() + + await settle() + + expect(screen.getByTestId('referral-prompt')).toBeInTheDocument() + expect(useReferralPromptStore.getState().promptedMilestoneKeys).toContain( + 'streak-7', + ) + }) + + it('stays hidden while a celebration is in flight', async () => { + useUIStore.getState().enqueueCelebration('streak', { streak: 7 }) + render() + await arm('streak-7') + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000) + }) + + expect(screen.queryByTestId('referral-prompt')).toBeNull() + }) + + it('stays hidden and clears the arm when the milestone was already prompted', async () => { + useReferralPromptStore.setState({ promptedMilestoneKeys: ['streak-7'] }) + render() + await arm('streak-7') + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000) + }) + + expect(screen.queryByTestId('referral-prompt')).toBeNull() + expect(useReferralPromptStore.getState().armedMilestoneKey).toBeNull() + }) + + it('opens the drawer from the CTA', async () => { + render() + await arm('level-3') + await settle() + + fireEvent.click(screen.getByText('referral.prompt.cta')) + + expect(screen.queryByTestId('referral-prompt')).toBeNull() + expect(screen.getByTestId('referral-drawer')).toBeInTheDocument() + }) + + it('dismisses without opening the drawer from "maybe later"', async () => { + render() + await arm('streak-100') + await settle() + + fireEvent.click(screen.getByText('referral.prompt.later')) + + expect(screen.queryByTestId('referral-prompt')).toBeNull() + expect(screen.queryByTestId('referral-drawer')).toBeNull() + }) +}) diff --git a/apps/web/app/(app)/about/page.tsx b/apps/web/app/(app)/about/page.tsx index 567405dd1..1a8b3f498 100644 --- a/apps/web/app/(app)/about/page.tsx +++ b/apps/web/app/(app)/about/page.tsx @@ -7,8 +7,6 @@ import { Compass, FileText, Mail, Shield } from 'lucide-react' import { AppBar } from '@/components/ui/app-bar' import { AppLogo } from '@/components/ui/app-logo' import { SettingsRow } from '@/components/ui/settings-row' -import { ReferralCard } from '@/components/referral/referral-card' -import { ReferralDrawer } from '@/components/referral/referral-drawer' import { FeatureGuideDrawer } from '@/components/onboarding/feature-guide-drawer' import { useGoBackOrFallback } from '@/hooks/use-go-back-or-fallback' import packageJson from '@/package.json' @@ -18,7 +16,6 @@ export default function AboutPage() { const router = useRouter() const goBackOrFallback = useGoBackOrFallback() const [showGuide, setShowGuide] = useState(false) - const [showReferral, setShowReferral] = useState(false) return (
@@ -61,7 +58,6 @@ export default function AboutPage() { onClick={() => setShowGuide(true)} ariaLabel={t('onboarding.featureGuide.openButton')} /> - setShowReferral(true)} />
-
) diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index fdc3c44c3..273d45895 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -23,12 +23,15 @@ import { WelcomeBackToast } from '@/components/gamification/welcome-back-toast' import { AchievementToast } from '@/components/gamification/achievement-toast' import { LevelUpOverlay } from '@/components/gamification/level-up-overlay' import { StreakFreezeCelebration } from '@/components/gamification/streak-freeze-celebration' +import { ReferralPrompt } from '@/components/referral/referral-prompt' import { useProfile } from '@/hooks/use-profile' import { useTimezoneAutoSync } from '@/hooks/use-timezone-auto-sync' import { useAuthStore } from '@/stores/auth-store' import { useTotalHabitCount } from '@/hooks/use-habits' import { useGamificationProfile } from '@/hooks/use-gamification' import { useUIStore } from '@/stores/ui-store' +import { useReferralPromptStore } from '@/stores/referral-prompt-store' +import { getReferralLevelMilestone } from '@orbit/shared/stores' import { getSupabaseClient } from '@/lib/supabase' import { dismissCalendarImport } from '@/app/actions/calendar' import { TourProvider } from '@/components/tour/tour-provider' @@ -246,6 +249,13 @@ function GlobalOverlays({ }>) { const t = useTranslations() const gamification = useGamificationProfile(canViewGamification) + const armReferralPrompt = useReferralPromptStore((s) => s.armReferralPrompt) + + useEffect(() => { + if (gamification.leveledUp && gamification.newLevel) { + armReferralPrompt(getReferralLevelMilestone(gamification.newLevel)) + } + }, [gamification.leveledUp, gamification.newLevel, armReferralPrompt]) return (
@@ -265,6 +275,7 @@ function GlobalOverlays({ onClear={gamification.clearLevelUp} /> )} + {profile?.hasCompletedOnboarding && } s.setShowCreateModal) + const homeEntryDismissed = useReferralPromptStore((s) => s.homeEntryDismissed) + const dismissHomeEntry = useReferralPromptStore((s) => s.dismissHomeEntry) + const [showReferral, setShowReferral] = useState(false) const [localSearchQuery, setLocalSearchQuery] = useState(searchQueryStore) const [searchOpen, setSearchOpen] = useState(false) const [slideDirection, setSlideDirection] = useState<'left' | 'right'>('right') @@ -384,6 +390,13 @@ export default function TodayPage() { )} + {currentActiveView === 'today' && isToday(selectedDate) && !homeEntryDismissed && ( + setShowReferral(true)} + onDismiss={dismissHomeEntry} + /> + )} + setShowBulkSkipConfirm(false)} /> +
) } diff --git a/apps/web/app/(app)/profile/page.tsx b/apps/web/app/(app)/profile/page.tsx index 83c0d4621..c3f9d91ba 100644 --- a/apps/web/app/(app)/profile/page.tsx +++ b/apps/web/app/(app)/profile/page.tsx @@ -19,6 +19,8 @@ import { useAuthStore } from '@/stores/auth-store' import { deriveNextRewardCarrot } from '@orbit/shared/utils' import { useGamificationProfile } from '@/hooks/use-gamification' import { SectionLabel } from '@/components/ui/section-label' +import { ReferralCard } from '@/components/referral/referral-card' +import { ReferralDrawer } from '@/components/referral/referral-drawer' import { SubscriptionCard } from './_components/subscription-card' import { ProfileIdentityHeader } from './_components/profile-identity-header' import { ProfileStatTiles } from './_components/profile-stat-tiles' @@ -73,6 +75,7 @@ export default function ProfilePage() { const [showDeleteModal, setShowDeleteModal] = useState(false) const [showTourReplay, setShowTourReplay] = useState(false) const [showEditName, setShowEditName] = useState(false) + const [showReferral, setShowReferral] = useState(false) function handleNavClick(item: ProfileNavItem) { if (shouldRedirectProfileNavItem(item, profile)) { @@ -123,6 +126,8 @@ export default function ProfilePage() { }} /> + setShowReferral(true)} /> + + +
) } diff --git a/apps/web/components/referral/referral-card.tsx b/apps/web/components/referral/referral-card.tsx index 4a71d12af..e732401ef 100644 --- a/apps/web/components/referral/referral-card.tsx +++ b/apps/web/components/referral/referral-card.tsx @@ -1,15 +1,17 @@ 'use client' import { useTranslations } from 'next-intl' -import { ChevronRight, UserPlus } from 'lucide-react' +import { ChevronRight, UserPlus, X } from 'lucide-react' import { useReferral } from '@/hooks/use-referral' interface ReferralCardProps { onOpen: () => void + /** When provided, the card shows a dismiss control instead of the chevron (the dismissible Today entry). */ + onDismiss?: () => void } -/** Kit referral entry card: primary-tinted icon disc, title, progress line, chevron. */ -export function ReferralCard({ onOpen }: Readonly) { +/** Kit referral entry card: primary-tinted icon disc, title, progress line, and either a chevron or a dismiss control. */ +export function ReferralCard({ onOpen, onDismiss }: Readonly) { const t = useTranslations() const { stats, isLoading } = useReferral() @@ -23,46 +25,68 @@ export function ReferralCard({ onOpen }: Readonly) { return (
- + {onDismiss && ( + + + + )} +
) } diff --git a/apps/web/components/referral/referral-prompt.tsx b/apps/web/components/referral/referral-prompt.tsx new file mode 100644 index 000000000..e356ff24e --- /dev/null +++ b/apps/web/components/referral/referral-prompt.tsx @@ -0,0 +1,163 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { useTranslations } from 'next-intl' +import { useQueryClient } from '@tanstack/react-query' +import { Gift } from 'lucide-react' +import { referralKeys } from '@orbit/shared/query' +import type { ReferralDashboard } from '@orbit/shared/types/referral' +import { + canPromptReferral, + parseReferralMilestoneKey, +} from '@orbit/shared/stores' +import { AppOverlay } from '@/components/ui/app-overlay' +import { PillButton } from '@/components/ui/pill-button' +import { ReferralDrawer } from '@/components/referral/referral-drawer' +import { useUIStore } from '@/stores/ui-store' +import { useReferralPromptStore } from '@/stores/referral-prompt-store' + +const SETTLE_DELAY_MS = 500 +const DEFAULT_DISCOUNT_PERCENT = 10 + +/** One-shot milestone nudge: shows once no celebration is in flight and the re-prompt guard allows it, then hands off to the referral drawer. */ +export function ReferralPrompt() { + const t = useTranslations() + const queryClient = useQueryClient() + const armedMilestoneKey = useReferralPromptStore((s) => s.armedMilestoneKey) + const markReferralPrompted = useReferralPromptStore( + (s) => s.markReferralPrompted, + ) + const clearArmedMilestone = useReferralPromptStore( + (s) => s.clearArmedMilestone, + ) + const celebrationInFlight = useUIStore( + (s) => s.activeCelebration !== null || s.queuedCelebrations.length > 0, + ) + + const [visibleKey, setVisibleKey] = useState(null) + const [showDrawer, setShowDrawer] = useState(false) + const settleTimerRef = useRef>(undefined) + + useEffect(() => { + if (visibleKey || !armedMilestoneKey || celebrationInFlight) return + + if ( + !canPromptReferral( + useReferralPromptStore.getState(), + armedMilestoneKey, + new Date().toISOString(), + ) + ) { + clearArmedMilestone() + return + } + + settleTimerRef.current = setTimeout(() => { + markReferralPrompted(armedMilestoneKey, new Date().toISOString()) + setVisibleKey(armedMilestoneKey) + }, SETTLE_DELAY_MS) + + return () => { + if (settleTimerRef.current) clearTimeout(settleTimerRef.current) + } + }, [ + armedMilestoneKey, + celebrationInFlight, + visibleKey, + markReferralPrompted, + clearArmedMilestone, + ]) + + const milestone = visibleKey ? parseReferralMilestoneKey(visibleKey) : null + const cached = queryClient.getQueryData(referralKeys.all) + const discount = cached?.stats.discountPercent ?? DEFAULT_DISCOUNT_PERCENT + + const title = + milestone?.kind === 'level' + ? t('referral.prompt.levelTitle', { level: milestone.value }) + : t('referral.prompt.streakTitle', { count: milestone?.value ?? 0 }) + + function dismiss() { + setVisibleKey(null) + } + + function openDrawer() { + setVisibleKey(null) + setShowDrawer(true) + } + + return ( + <> + { + if (!open) dismiss() + }} + titleContent={ + + + {t('referral.prompt.eyebrow')} + + {title} + + } + > +
+ +

+ {t('referral.prompt.body', { discount })} +

+
+ + {t('referral.prompt.cta')} + + +
+
+
+ + + ) +} diff --git a/apps/web/hooks/use-habits.ts b/apps/web/hooks/use-habits.ts index 15a1a11e7..6d9ed46ea 100644 --- a/apps/web/hooks/use-habits.ts +++ b/apps/web/hooks/use-habits.ts @@ -57,7 +57,9 @@ import { bulkLogHabits as bulkLogHabitsAction, bulkSkipHabits as bulkSkipHabitsAction, } from '@/app/actions/habits' +import { getReferralStreakMilestone } from '@orbit/shared/stores' import { useUIStore } from '@/stores/ui-store' +import { useReferralPromptStore } from '@/stores/referral-prompt-store' import { useAppToast } from '@/hooks/use-app-toast' export { @@ -126,6 +128,10 @@ export function useLogHabit() { queryClient.setQueryData(profileKeys.detail(), (old) => old ? { ...old, currentStreak: response.currentStreak } : old, ) + const referralMilestoneKey = getReferralStreakMilestone(response.currentStreak) + if (referralMilestoneKey) { + useReferralPromptStore.getState().armReferralPrompt(referralMilestoneKey) + } } if (response?.linkedGoalUpdates?.length) { diff --git a/apps/web/lib/providers.tsx b/apps/web/lib/providers.tsx index 95d21b416..5030bc044 100644 --- a/apps/web/lib/providers.tsx +++ b/apps/web/lib/providers.tsx @@ -3,6 +3,7 @@ import { QueryClientProvider } from '@tanstack/react-query' import { useEffect } from 'react' import { useUIStore } from '@/stores/ui-store' +import { useReferralPromptStore } from '@/stores/referral-prompt-store' import { getQueryClient } from './query-client' import type { ReactNode } from 'react' @@ -11,6 +12,7 @@ export function Providers({ children }: Readonly<{ children: ReactNode }>) { useEffect(() => { void useUIStore.persist.rehydrate() + void useReferralPromptStore.persist.rehydrate() }, []) return ( diff --git a/apps/web/stores/referral-prompt-store.ts b/apps/web/stores/referral-prompt-store.ts new file mode 100644 index 000000000..76ebe4838 --- /dev/null +++ b/apps/web/stores/referral-prompt-store.ts @@ -0,0 +1,36 @@ +import { create } from 'zustand' +import { createJSONStorage, persist } from 'zustand/middleware' +import { + createReferralPromptStoreState, + getPersistedReferralPromptState, + migratePersistedReferralPromptState, + type PersistedReferralPromptState, + type ReferralPromptStoreState, +} from '@orbit/shared/stores' + +const noopStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, +} + +export const useReferralPromptStore = create()( + persist( + (set) => + createReferralPromptStoreState( + set as Parameters[0], + ), + { + name: 'orbit-referral-prompt-store', + version: 1, + storage: createJSONStorage(() => + globalThis.localStorage === undefined + ? noopStorage + : globalThis.localStorage, + ), + migrate: migratePersistedReferralPromptState, + partialize: getPersistedReferralPromptState, + skipHydration: true, + }, + ), +) diff --git a/packages/shared/src/__tests__/referral-prompt-store.test.ts b/packages/shared/src/__tests__/referral-prompt-store.test.ts new file mode 100644 index 000000000..3ce850074 --- /dev/null +++ b/packages/shared/src/__tests__/referral-prompt-store.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest' +import { + REFERRAL_PROMPT_COOLDOWN_DAYS, + canPromptReferral, + createReferralPromptStoreState, + getPersistedReferralPromptState, + getReferralLevelMilestone, + getReferralStreakMilestone, + migratePersistedReferralPromptState, + parseReferralMilestoneKey, + type ReferralPromptStoreState, +} from '../stores/referral-prompt-store' + +function createStoreHarness() { + let state = {} as ReferralPromptStoreState + + const set = ( + partial: + | Partial + | ((current: ReferralPromptStoreState) => Partial), + ) => { + const next = typeof partial === 'function' ? partial(state) : partial + state = { ...state, ...next } + } + + state = createReferralPromptStoreState(set) + + return { + getState: () => state, + } +} + +const DAY_MS = 24 * 60 * 60 * 1000 + +describe('getReferralStreakMilestone', () => { + it('returns milestone keys for 7, 30, and 100', () => { + expect(getReferralStreakMilestone(7)).toBe('streak-7') + expect(getReferralStreakMilestone(30)).toBe('streak-30') + expect(getReferralStreakMilestone(100)).toBe('streak-100') + }) + + it('returns null for non-milestone streaks', () => { + for (const streak of [0, 6, 8, 14, 29, 99, 101, 365]) { + expect(getReferralStreakMilestone(streak)).toBeNull() + } + }) +}) + +describe('referral milestone keys', () => { + it('builds a level milestone key', () => { + expect(getReferralLevelMilestone(5)).toBe('level-5') + }) + + it('parses streak and level keys back into kind and value', () => { + expect(parseReferralMilestoneKey('streak-30')).toEqual({ + kind: 'streak', + value: 30, + }) + expect(parseReferralMilestoneKey('level-12')).toEqual({ + kind: 'level', + value: 12, + }) + }) + + it('returns null for malformed milestone keys', () => { + expect(parseReferralMilestoneKey('streak')).toBeNull() + expect(parseReferralMilestoneKey('badge-3')).toBeNull() + expect(parseReferralMilestoneKey('streak-abc')).toBeNull() + }) +}) + +describe('canPromptReferral', () => { + const now = '2026-06-27T12:00:00.000Z' + + it('allows a fresh milestone with no prior prompt', () => { + expect( + canPromptReferral( + { promptedMilestoneKeys: [], lastPromptedAtIso: null }, + 'streak-7', + now, + ), + ).toBe(true) + }) + + it('blocks a milestone that was already prompted', () => { + expect( + canPromptReferral( + { promptedMilestoneKeys: ['streak-7'], lastPromptedAtIso: null }, + 'streak-7', + now, + ), + ).toBe(false) + }) + + it('blocks a new milestone while within the cooldown window', () => { + const lastPromptedAtIso = new Date( + new Date(now).getTime() - (REFERRAL_PROMPT_COOLDOWN_DAYS - 1) * DAY_MS, + ).toISOString() + + expect( + canPromptReferral( + { promptedMilestoneKeys: ['streak-7'], lastPromptedAtIso }, + 'level-3', + now, + ), + ).toBe(false) + }) + + it('allows a new milestone once the cooldown has elapsed', () => { + const lastPromptedAtIso = new Date( + new Date(now).getTime() - (REFERRAL_PROMPT_COOLDOWN_DAYS + 1) * DAY_MS, + ).toISOString() + + expect( + canPromptReferral( + { promptedMilestoneKeys: ['streak-7'], lastPromptedAtIso }, + 'level-3', + now, + ), + ).toBe(true) + }) +}) + +describe('referral prompt store factory', () => { + it('arms and clears the transient milestone key', () => { + const store = createStoreHarness() + + store.getState().armReferralPrompt('streak-30') + expect(store.getState().armedMilestoneKey).toBe('streak-30') + + store.getState().clearArmedMilestone() + expect(store.getState().armedMilestoneKey).toBeNull() + }) + + it('records a prompted milestone, sets the timestamp, and clears the armed key', () => { + const store = createStoreHarness() + + store.getState().armReferralPrompt('streak-7') + store.getState().markReferralPrompted('streak-7', '2026-06-27T12:00:00.000Z') + + expect(store.getState().promptedMilestoneKeys).toEqual(['streak-7']) + expect(store.getState().lastPromptedAtIso).toBe('2026-06-27T12:00:00.000Z') + expect(store.getState().armedMilestoneKey).toBeNull() + }) + + it('does not duplicate an already-recorded milestone key', () => { + const store = createStoreHarness() + + store.getState().markReferralPrompted('streak-7', '2026-06-01T00:00:00.000Z') + store.getState().markReferralPrompted('streak-7', '2026-06-27T12:00:00.000Z') + + expect(store.getState().promptedMilestoneKeys).toEqual(['streak-7']) + expect(store.getState().lastPromptedAtIso).toBe('2026-06-27T12:00:00.000Z') + }) + + it('flips the home-entry dismissed flag', () => { + const store = createStoreHarness() + + expect(store.getState().homeEntryDismissed).toBe(false) + store.getState().dismissHomeEntry() + expect(store.getState().homeEntryDismissed).toBe(true) + }) + + it('omits the armed key from the persisted snapshot', () => { + const store = createStoreHarness() + + store.getState().armReferralPrompt('streak-100') + store.getState().dismissHomeEntry() + + const snapshot = getPersistedReferralPromptState(store.getState()) + + expect(snapshot).not.toHaveProperty('armedMilestoneKey') + expect(snapshot).toEqual({ + promptedMilestoneKeys: [], + lastPromptedAtIso: null, + homeEntryDismissed: true, + }) + }) +}) + +describe('migratePersistedReferralPromptState', () => { + it('coerces malformed persisted state to safe defaults', () => { + expect(migratePersistedReferralPromptState(undefined)).toEqual({ + promptedMilestoneKeys: [], + lastPromptedAtIso: null, + homeEntryDismissed: false, + }) + + expect( + migratePersistedReferralPromptState({ + promptedMilestoneKeys: ['streak-7', 42, 'level-2'], + lastPromptedAtIso: 7, + homeEntryDismissed: 'yes', + }), + ).toEqual({ + promptedMilestoneKeys: ['streak-7', 'level-2'], + lastPromptedAtIso: null, + homeEntryDismissed: false, + }) + }) +}) diff --git a/packages/shared/src/i18n/en.json b/packages/shared/src/i18n/en.json index c986e24e8..8c940261c 100644 --- a/packages/shared/src/i18n/en.json +++ b/packages/shared/src/i18n/en.json @@ -954,6 +954,14 @@ "title": "Join me on Orbit", "text": "I've been using Orbit to build better habits. Join me and we both get a {discount}% discount coupon!" }, + "prompt": { + "eyebrow": "You're on a roll", + "streakTitle": "{count}-day streak!", + "levelTitle": "You reached level {level}!", + "body": "Invite a friend and you both get a {discount}% discount coupon for Pro.", + "cta": "Invite a friend", + "later": "Maybe later" + }, "loginBanner": "You've been invited! Sign up to get a 10% discount coupon for Pro" }, "privacy": { diff --git a/packages/shared/src/i18n/pt-BR.json b/packages/shared/src/i18n/pt-BR.json index 51bc71c85..f24c160a4 100644 --- a/packages/shared/src/i18n/pt-BR.json +++ b/packages/shared/src/i18n/pt-BR.json @@ -954,6 +954,14 @@ "title": "Vem pro Orbit comigo", "text": "Estou usando o Orbit para criar hĂĄbitos melhores. Entra tambĂ©m e nĂłs dois ganhamos um cupom de {discount}% de desconto!" }, + "prompt": { + "eyebrow": "VocĂȘ estĂĄ com tudo", + "streakTitle": "SequĂȘncia de {count} dias!", + "levelTitle": "VocĂȘ chegou ao nĂ­vel {level}!", + "body": "Convide um amigo e vocĂȘs dois ganham um cupom de {discount}% de desconto no Pro.", + "cta": "Convidar um amigo", + "later": "Agora nĂŁo" + }, "loginBanner": "VocĂȘ foi convidado! Cadastre-se e ganhe um cupom de 10% de desconto no Pro" }, "privacy": { diff --git a/packages/shared/src/stores/index.ts b/packages/shared/src/stores/index.ts index 9360bf08d..e36edbea8 100644 --- a/packages/shared/src/stores/index.ts +++ b/packages/shared/src/stores/index.ts @@ -8,6 +8,21 @@ export { type TourStoreState, type TourTargetRect, } from './tour-store' +export { + REFERRAL_STREAK_MILESTONES, + REFERRAL_PROMPT_COOLDOWN_DAYS, + getReferralStreakMilestone, + getReferralLevelMilestone, + parseReferralMilestoneKey, + canPromptReferral, + createReferralPromptStoreState, + getPersistedReferralPromptState, + migratePersistedReferralPromptState, + type ReferralPromptGuardState, + type ReferralPromptStoreState, + type PersistedReferralPromptState, + type ReferralMilestone, +} from './referral-prompt-store' export { createTourUIState, createUIStoreState, diff --git a/packages/shared/src/stores/referral-prompt-store.ts b/packages/shared/src/stores/referral-prompt-store.ts new file mode 100644 index 000000000..d91d6b954 --- /dev/null +++ b/packages/shared/src/stores/referral-prompt-store.ts @@ -0,0 +1,152 @@ +type ReferralPromptStoreSet = { + ( + partial: + | Partial + | ((state: ReferralPromptStoreState) => Partial), + replace?: false, + ): void + ( + state: + | ReferralPromptStoreState + | ((state: ReferralPromptStoreState) => ReferralPromptStoreState), + replace: true, + ): void +} + +export const REFERRAL_STREAK_MILESTONES = [7, 30, 100] as const + +export const REFERRAL_PROMPT_COOLDOWN_DAYS = 14 + +const COOLDOWN_MS = REFERRAL_PROMPT_COOLDOWN_DAYS * 24 * 60 * 60 * 1000 + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' +} + +/** Maps a freshly-crossed streak length to its referral-prompt milestone key, or null when the length is not a referral milestone (7/30/100). */ +export function getReferralStreakMilestone(streak: number): string | null { + return (REFERRAL_STREAK_MILESTONES as readonly number[]).includes(streak) + ? `streak-${streak}` + : null +} + +/** Builds the referral-prompt milestone key for a newly reached level. */ +export function getReferralLevelMilestone(level: number): string { + return `level-${level}` +} + +export interface ReferralMilestone { + kind: 'streak' | 'level' + value: number +} + +/** Parses a milestone key (`streak-N` / `level-N`) back into its kind and numeric value, or null when malformed. */ +export function parseReferralMilestoneKey( + milestoneKey: string, +): ReferralMilestone | null { + const separatorIndex = milestoneKey.indexOf('-') + if (separatorIndex < 0) return null + + const kind = milestoneKey.slice(0, separatorIndex) + const value = Number(milestoneKey.slice(separatorIndex + 1)) + if (!Number.isFinite(value)) return null + if (kind === 'streak') return { kind: 'streak', value } + if (kind === 'level') return { kind: 'level', value } + return null +} + +export interface ReferralPromptGuardState { + promptedMilestoneKeys: string[] + lastPromptedAtIso: string | null +} + +/** True when the milestone has never been prompted and the cooldown window since the last prompt has elapsed. */ +export function canPromptReferral( + state: ReferralPromptGuardState, + milestoneKey: string, + nowIso: string, +): boolean { + if (state.promptedMilestoneKeys.includes(milestoneKey)) return false + if (!state.lastPromptedAtIso) return true + + const last = new Date(state.lastPromptedAtIso).getTime() + const now = new Date(nowIso).getTime() + if (Number.isNaN(last) || Number.isNaN(now)) return true + + return now - last >= COOLDOWN_MS +} + +export interface PersistedReferralPromptState { + promptedMilestoneKeys: string[] + lastPromptedAtIso: string | null + homeEntryDismissed: boolean +} + +export interface ReferralPromptStoreState extends PersistedReferralPromptState { + /** Transient milestone key awaiting a prompt; excluded from persistence so a reload simply skips a missed milestone. */ + armedMilestoneKey: string | null + + armReferralPrompt: (milestoneKey: string) => void + clearArmedMilestone: () => void + markReferralPrompted: (milestoneKey: string, nowIso: string) => void + dismissHomeEntry: () => void +} + +export function getPersistedReferralPromptState( + state: ReferralPromptStoreState, +): PersistedReferralPromptState { + return { + promptedMilestoneKeys: [...state.promptedMilestoneKeys], + lastPromptedAtIso: state.lastPromptedAtIso, + homeEntryDismissed: state.homeEntryDismissed, + } +} + +export function migratePersistedReferralPromptState( + persistedState: unknown, +): PersistedReferralPromptState { + const state = isRecord(persistedState) ? persistedState : {} + + return { + promptedMilestoneKeys: Array.isArray(state.promptedMilestoneKeys) + ? state.promptedMilestoneKeys.filter( + (key): key is string => typeof key === 'string', + ) + : [], + lastPromptedAtIso: + typeof state.lastPromptedAtIso === 'string' + ? state.lastPromptedAtIso + : null, + homeEntryDismissed: + typeof state.homeEntryDismissed === 'boolean' + ? state.homeEntryDismissed + : false, + } +} + +export function createReferralPromptStoreState( + set: ReferralPromptStoreSet, +): ReferralPromptStoreState { + return { + promptedMilestoneKeys: [], + lastPromptedAtIso: null, + homeEntryDismissed: false, + armedMilestoneKey: null, + + armReferralPrompt: (milestoneKey) => + set({ armedMilestoneKey: milestoneKey }), + + clearArmedMilestone: () => set({ armedMilestoneKey: null }), + + markReferralPrompted: (milestoneKey, nowIso) => + set((state) => ({ + promptedMilestoneKeys: state.promptedMilestoneKeys.includes(milestoneKey) + ? state.promptedMilestoneKeys + : [...state.promptedMilestoneKeys, milestoneKey], + lastPromptedAtIso: nowIso, + armedMilestoneKey: null, + })), + + dismissHomeEntry: () => set({ homeEntryDismissed: true }), + } +}