diff --git a/packages/extension/__mocks__/fileMock.ts b/packages/extension/__mocks__/fileMock.ts new file mode 100644 index 0000000000..564e445144 --- /dev/null +++ b/packages/extension/__mocks__/fileMock.ts @@ -0,0 +1,3 @@ +const mockFile = 'test-file-stub'; + +export default mockFile; diff --git a/packages/extension/jest.config.js b/packages/extension/jest.config.js index af37bff4ca..b998207a73 100644 --- a/packages/extension/jest.config.js +++ b/packages/extension/jest.config.js @@ -15,6 +15,7 @@ module.exports = { moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], moduleNameMapper: { '\\.svg$': '/__mocks__/svgrMock.ts', + '\\.(png|jpe?g|gif|webp|avif)$': '/__mocks__/fileMock.ts', '\\.css$': 'identity-obj-proxy', 'react-markdown': '/__mocks__/reactMarkdownMock.tsx', }, diff --git a/packages/shared/__mocks__/fileMock.ts b/packages/shared/__mocks__/fileMock.ts new file mode 100644 index 0000000000..564e445144 --- /dev/null +++ b/packages/shared/__mocks__/fileMock.ts @@ -0,0 +1,3 @@ +const mockFile = 'test-file-stub'; + +export default mockFile; diff --git a/packages/shared/jest.config.js b/packages/shared/jest.config.js index 32b87696a3..5f549defce 100644 --- a/packages/shared/jest.config.js +++ b/packages/shared/jest.config.js @@ -13,6 +13,7 @@ module.exports = { moduleNameMapper: { '^node-emoji$': 'node-emoji/lib/index.cjs', '\\.svg$': '/__mocks__/svgrMock.ts', + '\\.(png|jpe?g|gif|webp|avif)$': '/__mocks__/fileMock.ts', '\\.css$': 'identity-obj-proxy', 'react-markdown': '/__mocks__/reactMarkdownMock.tsx', 'react-turnstile': 'identity-obj-proxy', diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 39f5178824..82a97953ee 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -67,6 +67,7 @@ import useCustomDefaultFeed from '../hooks/feed/useCustomDefaultFeed'; import { useSearchContextProvider } from '../contexts/search/SearchContext'; import { isDevelopment, isProductionAPI, webappUrl } from '../lib/constants'; import { useReadingReminderHero } from '../hooks/notifications/useReadingReminderHero'; +import { FeedGreetingHero } from './streak/FeedGreetingHero'; const FeedExploreHeader = dynamic( () => @@ -521,6 +522,8 @@ export default function MainFeedLayout({ }, [sortingEnabled, selectedAlgo, loadedSettings, loadedAlgo]); const disableTopPadding = isFinder || shouldUseListFeedLayout; + const shouldShowFeedGreetingHero = + feedName === SharedFeedPage.MyFeed && !isSearchOn; const shouldShowReadingReminderOnHomepage = router.pathname === webappUrl && shouldShowReadingReminder; @@ -568,7 +571,8 @@ export default function MainFeedLayout({ > {isAnyExplore && } {isSearchOn && !isSearchPageLaptop && search} - {shouldShowReadingReminderOnHomepage && ( + {shouldShowFeedGreetingHero && } + {shouldShowReadingReminderOnHomepage && !shouldShowFeedGreetingHero && ( )} {shouldUseCommentFeedLayout ? ( diff --git a/packages/shared/src/components/feeds/FeedNav.tsx b/packages/shared/src/components/feeds/FeedNav.tsx index 6096a96bb4..1422a8704f 100644 --- a/packages/shared/src/components/feeds/FeedNav.tsx +++ b/packages/shared/src/components/feeds/FeedNav.tsx @@ -138,7 +138,7 @@ function FeedNav(): ReactElement { shouldMountInactive className={{ header: classNames( - 'no-scrollbar overflow-x-auto px-2', + 'no-scrollbar overflow-x-auto !bg-background-default px-2 tablet:!bg-background-default', isSortableFeed && sortingEnabled && 'pr-28', ), }} diff --git a/packages/shared/src/components/icons/ReputationLightning/filled.svg b/packages/shared/src/components/icons/ReputationLightning/filled.svg index 1ce0737f26..9e3768e02d 100644 --- a/packages/shared/src/components/icons/ReputationLightning/filled.svg +++ b/packages/shared/src/components/icons/ReputationLightning/filled.svg @@ -1,8 +1,14 @@ Icon/Integration/Filled + + + + + + - + diff --git a/packages/shared/src/components/layout/common.tsx b/packages/shared/src/components/layout/common.tsx index 4303200b27..25c3ed6135 100644 --- a/packages/shared/src/components/layout/common.tsx +++ b/packages/shared/src/components/layout/common.tsx @@ -207,7 +207,7 @@ export const SearchControlHeader = ({ ); return ( -
+
{wrapperChildren} {isStreaksEnabled && ( diff --git a/packages/shared/src/components/modals/streaks/StreakRecoverModal.spec.tsx b/packages/shared/src/components/modals/streaks/StreakRecoverModal.spec.tsx index d5834af1c1..71bb1edf28 100644 --- a/packages/shared/src/components/modals/streaks/StreakRecoverModal.spec.tsx +++ b/packages/shared/src/components/modals/streaks/StreakRecoverModal.spec.tsx @@ -257,8 +257,9 @@ it('Should have no cost for first time recovery', async () => { expect(popupHeader).toBeInTheDocument(); // expect cost to be 0 - const cost = screen.getByText('Restore my streakFree'); - expect(cost).toBeInTheDocument(); + const button = screen.getByTestId('streak-recover-button'); + expect(button).toHaveTextContent(/Restore my streak/i); + expect(button).toHaveTextContent(/Free/i); }); it('Should have cost of 100 Cores for 2nd+ time recovery', async () => { @@ -285,8 +286,9 @@ it('Should have cost of 100 Cores for 2nd+ time recovery', async () => { expect(popupHeader).toBeInTheDocument(); // expect cost to be 100 - const cost = screen.getByText('Restore my streak100'); - expect(cost).toBeInTheDocument(); + const button = screen.getByTestId('streak-recover-button'); + expect(button).toHaveTextContent(/Restore my streak/i); + expect(button).toHaveTextContent(/100/i); }); it('Should show buy Cores message if user does not have enough Cores', async () => { diff --git a/packages/shared/src/components/modals/streaks/StreakRecoverModal.tsx b/packages/shared/src/components/modals/streaks/StreakRecoverModal.tsx index fd86d83df9..3f325000fa 100644 --- a/packages/shared/src/components/modals/streaks/StreakRecoverModal.tsx +++ b/packages/shared/src/components/modals/streaks/StreakRecoverModal.tsx @@ -25,20 +25,31 @@ import type { UserStreakRecoverData } from '../../../graphql/users'; import { CoreIcon } from '../../icons'; import { coresDocsLink } from '../../../lib/constants'; import { anchorDefaultRel } from '../../../lib/strings'; +import streakRecoverCoverImage from './streak-recover-cover.png'; export interface StreakRecoverModalProps extends Pick { onRequestClose: () => void; user: LoggedUser; + forceOpen?: boolean; } +const streakRecoverCoverSrc = + typeof streakRecoverCoverImage === 'string' + ? streakRecoverCoverImage + : (streakRecoverCoverImage as { src?: string })?.src; + const StreakRecoverCover = () => ( -
+
Broken reading streak
); @@ -50,9 +61,8 @@ const StreakRecoverHeading = ({ days }: { days: number }) => ( type={TypographyType.Title1} data-testid="streak-recover-modal-heading" > - Oh no! Your - {days} day streak has - been broken! + Oh no! {days} day streak {' '} + has been broken! ); @@ -70,7 +80,7 @@ const StreakRecoveryCopy = ({ rel={anchorDefaultRel} href={coresDocsLink} title="What are Cores?" - className="text-text-link hover:underline" + className="underline" > Cores @@ -85,9 +95,7 @@ const StreakRecoveryCopy = ({ ); const canRecoverText = ( <> - Maintain your streak! -
- Use {recover.cost} {coresLink} to keep going. + Maintain your streak! Use {recover.cost} {coresLink} to keep going. ); const noRecoverText = ( @@ -116,14 +124,18 @@ const StreakRecoverButton = ({ return ( ); }; @@ -134,7 +146,7 @@ export const StreakRecoverOptout = ({ }: { id: string; } & Pick): ReactElement => ( -
+
{ - const { isOpen, onRequestClose, onAfterClose, user } = props; - const { isStreaksEnabled } = useReadingStreak(); + const { isOpen, onRequestClose, onAfterClose, user, forceOpen } = props; + const { isStreaksEnabled, streak } = useReadingStreak(); const id = useId(); const { recover, hideForever, onClose, onRecover } = useStreakRecover({ @@ -170,7 +182,27 @@ export const StreakRecoverModal = ( onRequestClose, }); - if (!user || !isStreaksEnabled || !recover.canRecover || recover.isLoading) { + const isPreviewMode = + !!forceOpen && (recover.isLoading || !recover.canRecover); + const previewRecover: UserStreakRecoverData & { + isLoading: boolean; + isRecoverPending: boolean; + } = { + canRecover: true, + cost: 100, + regularCost: 100, + oldStreakLength: Math.max(streak?.current ?? 0, 1), + isLoading: false, + isRecoverPending: false, + }; + const activeRecover = isPreviewMode ? previewRecover : recover; + const canRecoverAction = !isPreviewMode; + + if ( + !user || + !isStreaksEnabled || + (!isPreviewMode && (!recover.canRecover || recover.isLoading)) + ) { return null; } @@ -180,25 +212,278 @@ export const StreakRecoverModal = ( isDrawerOnMobile={isOpen} onRequestClose={onClose} size={ModalSize.XSmall} + className="tablet:!w-[26.25rem]" > - -
+ +
+ + + + + + + + + + + + + + + + +
+
- - + +
+ ); }; diff --git a/packages/shared/src/components/modals/streaks/streak-recover-cover.png b/packages/shared/src/components/modals/streaks/streak-recover-cover.png new file mode 100644 index 0000000000..e37cba1dcf Binary files /dev/null and b/packages/shared/src/components/modals/streaks/streak-recover-cover.png differ diff --git a/packages/shared/src/components/streak/AnimatedNumber.tsx b/packages/shared/src/components/streak/AnimatedNumber.tsx new file mode 100644 index 0000000000..20df0117bb --- /dev/null +++ b/packages/shared/src/components/streak/AnimatedNumber.tsx @@ -0,0 +1,93 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; + +interface AnimatedNumberProps { + value: number; + className?: string; +} + +interface DigitColumnProps { + digit: string; + animate: boolean; +} + +function DigitColumn({ digit, animate }: DigitColumnProps): ReactElement { + const [displayDigit, setDisplayDigit] = useState(digit); + const [prevDigit, setPrevDigit] = useState(digit); + const [isAnimating, setIsAnimating] = useState(false); + + useEffect(() => { + if (digit !== displayDigit && animate) { + setPrevDigit(displayDigit); + setIsAnimating(true); + + const timer = setTimeout(() => { + setDisplayDigit(digit); + setIsAnimating(false); + }, 400); + + return () => clearTimeout(timer); + } + + if (digit !== displayDigit) { + setDisplayDigit(digit); + } + + return undefined; + }, [digit, displayDigit, animate]); + + return ( + + + {isAnimating ? prevDigit : displayDigit} + + {isAnimating && ( + + {digit} + + )} + + ); +} + +export function AnimatedNumber({ + value, + className, +}: AnimatedNumberProps): ReactElement { + const prevValueRef = useRef(value); + const [shouldAnimate, setShouldAnimate] = useState(false); + + useEffect(() => { + if (value !== prevValueRef.current) { + setShouldAnimate(true); + prevValueRef.current = value; + + const timer = setTimeout(() => setShouldAnimate(false), 500); + return () => clearTimeout(timer); + } + + return undefined; + }, [value]); + + const digits = String(value).split(''); + + return ( + + {digits.map((digit, index) => ( + + ))} + + ); +} diff --git a/packages/shared/src/components/streak/FeedGreetingHero.tsx b/packages/shared/src/components/streak/FeedGreetingHero.tsx new file mode 100644 index 0000000000..42a5e9b262 --- /dev/null +++ b/packages/shared/src/components/streak/FeedGreetingHero.tsx @@ -0,0 +1,815 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import classNames from 'classnames'; +import { subDays } from 'date-fns'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useReadingStreak } from '../../hooks/streaks/useReadingStreak'; +import { isSameDayInTimezone } from '../../lib/timezones'; +import { generateQueryKey, RequestKey, StaleTime } from '../../lib/query'; +import type { ReadingDay } from '../../graphql/users'; +import { getReadingStreak30Days } from '../../graphql/users'; +import { isWeekend as isWeekendDay } from '../../lib/date'; +import { useStreakDebug } from '../../hooks/streaks/useStreakDebug'; +import { ThemeMode, useSettingsContext } from '../../contexts/SettingsContext'; + +const previewDays = 7; + +type GreetingMoment = 'morning' | 'afternoon' | 'evening'; + +const getGreetingMoment = (date: Date): GreetingMoment => { + const hour = date.getHours(); + + if (hour < 12) { + return 'morning'; + } + + if (hour < 18) { + return 'afternoon'; + } + + return 'evening'; +}; + +const greetingByMoment: Record = { + morning: 'Good morning', + afternoon: 'Good afternoon', + evening: 'Good evening', +}; + +const generateDummyReadDays = ( + streakCount: number, + anchorDate: Date, + weekStart: number | undefined, + timezone: string | undefined, +): Set => { + const result = new Set(); + let remaining = streakCount; + let cursor = anchorDate; + + while (remaining > 0) { + if (!isWeekendDay(cursor, weekStart, timezone)) { + result.add(cursor.toDateString()); + remaining -= 1; + } + cursor = subDays(cursor, 1); + } + + return result; +}; + +export function FeedGreetingHero(): ReactElement | null { + const { user } = useAuthContext(); + const { themeMode } = useSettingsContext(); + const { streak, isLoading, isStreaksEnabled } = useReadingStreak(); + const { + debugStreakOverride, + isDebugMode, + isFeedHeroVisible, + feedHeroVariantOverride, + } = useStreakDebug(); + const { data: history } = useQuery({ + queryKey: generateQueryKey(RequestKey.ReadingStreak30Days, user), + queryFn: () => getReadingStreak30Days(user?.id), + staleTime: StaleTime.Default, + enabled: !!user?.id && isStreaksEnabled, + }); + const [isVisible, setIsVisible] = useState(false); + const [isSystemDark, setIsSystemDark] = useState(() => { + if (typeof window === 'undefined') { + return true; + } + + return window.matchMedia('(prefers-color-scheme: dark)').matches; + }); + + useEffect(() => { + if (themeMode !== ThemeMode.Auto || typeof window === 'undefined') { + return undefined; + } + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const updateSystemTheme = (event?: MediaQueryListEvent): void => { + setIsSystemDark(event?.matches ?? mediaQuery.matches); + }; + + updateSystemTheme(); + mediaQuery.addEventListener('change', updateSystemTheme); + + return () => { + mediaQuery.removeEventListener('change', updateSystemTheme); + }; + }, [themeMode]); + + useEffect(() => { + const animationFrame = requestAnimationFrame(() => setIsVisible(true)); + return () => cancelAnimationFrame(animationFrame); + }, []); + + const greetingMoment = useMemo(() => { + if (isDebugMode && feedHeroVariantOverride === 'night') { + return 'evening'; + } + + if (isDebugMode && feedHeroVariantOverride === 'morning') { + return 'morning'; + } + + return getGreetingMoment(new Date()); + }, [feedHeroVariantOverride, isDebugMode]); + const greeting = greetingByMoment[greetingMoment]; + const isEvening = greetingMoment === 'evening'; + const isMorning = greetingMoment === 'morning'; + + const effectiveStreak = debugStreakOverride ?? streak?.current ?? 0; + const hasReadToday = + !!streak?.lastViewAt && + isSameDayInTimezone( + new Date(streak.lastViewAt), + new Date(), + user?.timezone, + ); + const completedBeforeToday = hasReadToday + ? Math.max(effectiveStreak - 1, 0) + : effectiveStreak; + const previewDaysDates = useMemo(() => { + const half = Math.floor(previewDays / 2); + return Array.from({ length: previewDays }, (_, index) => { + const offset = index - half; // -3, -2, -1, 0, 1, 2, 3 + if (offset < 0) { + return subDays(new Date(), Math.abs(offset)); + } + + if (offset > 0) { + return new Date(new Date().setDate(new Date().getDate() + offset)); + } + + return new Date(); + }); + }, []); + const readDaysSet = useMemo(() => { + if (!history) { + return new Set(); + } + + return new Set( + history + .filter((day) => day.reads > 0) + .map((day) => new Date(day.date).toDateString()), + ); + }, [history]); + const debugReadDaysSet = useMemo(() => { + if (debugStreakOverride === null) { + return null; + } + + return generateDummyReadDays( + debugStreakOverride, + new Date(), + streak?.weekStart, + user?.timezone, + ); + }, [debugStreakOverride, streak?.weekStart, user?.timezone]); + const morningDustParticles = useMemo( + () => + Array.from({ length: 18 }, (_, index) => ({ + left: 10 + ((index * 17) % 75), + top: 8 + ((index * 23) % 80), + size: 2 + ((index * 7) % 3), + duration: 4 + ((index * 5) % 5), + delay: (index * 0.22) % 4, + })), + [], + ); + const dayLabel = effectiveStreak === 1 ? 'day' : 'days'; + + if ( + !user || + isLoading || + (!isDebugMode && isEvening && themeMode === ThemeMode.Light) || + (!isDebugMode && + isEvening && + themeMode === ThemeMode.Auto && + !isSystemDark) || + !isStreaksEnabled || + !streak || + (isDebugMode && !isFeedHeroVisible) || + (!isDebugMode && hasReadToday && effectiveStreak > 0) + ) { + return null; + } + + return ( +
+
+
+ + + + {!isEvening && ( +
+ {/* Sunrise anime sky wash (blue -> pink -> gold) */} +
+ + {/* Additional bottom purple/blue cloud glow */} +
+ + {/* Strong fallback beam so rays are always visible */} +
+ + {/* Strong parallel window rays from top-left */} +
+
+
+
+
+
+
+ + {/* Floating light particles in the rays */} + {morningDustParticles.map((particle) => ( +
+ ))} + + {/* Warm source glow */} +
+
+ )} + {isEvening && ( + <> + + {/* Deep space background gradients */} +
+
+ + {/* Milky way core band */} +
+
+ + {/* Nebula dust clouds */} +
+
+
+ + {/* Falling stars */} +
+
+
+
+
+
+ + )} +
+ {isEvening && ( +
+ + + + + + + + + + + + + + +
+ )} + {[ + // Dense Milky Way band stars + { + left: '15%', + top: '65%', + size: 1, + delay: '0ms', + opacity: 'opacity-80', + }, + { + left: '22%', + top: '55%', + size: 1.5, + delay: '400ms', + opacity: 'opacity-90', + }, + { + left: '28%', + top: '45%', + size: 2, + delay: '800ms', + opacity: 'opacity-100', + }, + { + left: '35%', + top: '35%', + size: 1, + delay: '200ms', + opacity: 'opacity-70', + }, + { + left: '42%', + top: '25%', + size: 1.5, + delay: '600ms', + opacity: 'opacity-90', + }, + { + left: '48%', + top: '15%', + size: 2.5, + delay: '100ms', + opacity: 'opacity-100', + }, + { + left: '55%', + top: '22%', + size: 1, + delay: '900ms', + opacity: 'opacity-60', + }, + { + left: '62%', + top: '32%', + size: 2, + delay: '300ms', + opacity: 'opacity-95', + }, + { + left: '68%', + top: '42%', + size: 1.5, + delay: '700ms', + opacity: 'opacity-85', + }, + { + left: '75%', + top: '52%', + size: 1, + delay: '500ms', + opacity: 'opacity-75', + }, + + // Scattered background stars + { + left: '5%', + top: '15%', + size: 1.5, + delay: '150ms', + opacity: 'opacity-60', + }, + { + left: '12%', + top: '8%', + size: 2, + delay: '850ms', + opacity: 'opacity-80', + }, + { + left: '8%', + top: '85%', + size: 1, + delay: '450ms', + opacity: 'opacity-50', + }, + { + left: '18%', + top: '90%', + size: 2.5, + delay: '50ms', + opacity: 'opacity-90', + }, + { + left: '30%', + top: '10%', + size: 1, + delay: '750ms', + opacity: 'opacity-40', + }, + { + left: '45%', + top: '80%', + size: 2, + delay: '250ms', + opacity: 'opacity-85', + }, + { + left: '58%', + top: '85%', + size: 1.5, + delay: '950ms', + opacity: 'opacity-70', + }, + { + left: '70%', + top: '12%', + size: 2.5, + delay: '350ms', + opacity: 'opacity-95', + }, + { + left: '82%', + top: '20%', + size: 1, + delay: '650ms', + opacity: 'opacity-55', + }, + { + left: '88%', + top: '75%', + size: 2, + delay: '100ms', + opacity: 'opacity-80', + }, + { + left: '95%', + top: '40%', + size: 1.5, + delay: '550ms', + opacity: 'opacity-65', + }, + { + left: '92%', + top: '85%', + size: 1, + delay: '850ms', + opacity: 'opacity-45', + }, + + // Tiny dust stars + { + left: '25%', + top: '50%', + size: 0.5, + delay: '100ms', + opacity: 'opacity-40', + }, + { + left: '32%', + top: '40%', + size: 0.5, + delay: '300ms', + opacity: 'opacity-50', + }, + { + left: '45%', + top: '30%', + size: 0.5, + delay: '500ms', + opacity: 'opacity-45', + }, + { + left: '52%', + top: '20%', + size: 0.5, + delay: '700ms', + opacity: 'opacity-55', + }, + { + left: '65%', + top: '35%', + size: 0.5, + delay: '900ms', + opacity: 'opacity-40', + }, + ].map((star) => ( + 1.5 + ? '0 0 8px 1px rgba(255,255,255,0.8)' + : 'none', + }} + /> + ))} +
+ {isEvening && ( +
+
+ {Array.from({ length: 15 }).map((_, index) => { + const opacity = Math.max(0.1, 1 - Math.abs(index - 7) / 7); + return ( + + ); + })} +
+
+ )} + + {/* Ground fog / atmosphere */} +
+
+ +
+

+ {isMorning && ( + <> + {/* Butterfly 1 (Top Right) */} + + + + + + {/* Butterfly 2 (Bottom Left) */} + + + + + + {/* Butterfly 3 (Top Left) */} + + + + + + + )} + {greeting} +

+

+ {effectiveStreak}-{dayLabel} streak. Keep it going today. +

+
+ +
+
+ {Array.from({ length: previewDays }).map((_, index) => { + const day = previewDaysDates[index]; + const isToday = index === Math.floor(previewDays / 2); + const isWeekend = isWeekendDay( + day, + streak.weekStart, + user.timezone, + ); + const hasDebugOverride = !!debugReadDaysSet; + const historyHasData = readDaysSet.size > 0; + + let isCompleted = false; + if (isToday) { + isCompleted = false; // Hero is meant to prompt reading today, so today is always shown as incomplete + } else if (index < Math.floor(previewDays / 2)) { + // Past days + if (hasDebugOverride) { + isCompleted = + !isWeekend && debugReadDaysSet.has(day.toDateString()); + } else if (historyHasData) { + isCompleted = !isWeekend && readDaysSet.has(day.toDateString()); + } else { + isCompleted = + Math.floor(previewDays / 2) - index <= completedBeforeToday; + } + } else { + // Future days + isCompleted = false; + } + + return ( + + {isCompleted && !isWeekend && ( + + )} + + ); + })} +
+
+
+ ); +} diff --git a/packages/shared/src/components/streak/MilestoneShareActions.tsx b/packages/shared/src/components/streak/MilestoneShareActions.tsx new file mode 100644 index 0000000000..4acbbc1233 --- /dev/null +++ b/packages/shared/src/components/streak/MilestoneShareActions.tsx @@ -0,0 +1,98 @@ +import type { ReactElement } from 'react'; +import React, { useMemo, useState } from 'react'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../buttons/Button'; +import { CopyIcon, LinkedInIcon, TwitterIcon } from '../icons'; +import { getLinkedInShareLink, getTwitterShareLink } from '../../lib/share'; +import { webappUrl } from '../../lib/constants'; +import { Typography, TypographyType } from '../typography/Typography'; + +interface MilestoneShareActionsProps { + message: string; +} + +export function MilestoneShareActions({ + message, +}: MilestoneShareActionsProps): ReactElement { + const [isCopied, setIsCopied] = useState(false); + const shareLink = useMemo(() => { + if (typeof window === 'undefined') { + return webappUrl; + } + + return window.location.href; + }, []); + + const onShareX = (): void => { + window.open( + getTwitterShareLink(shareLink, message), + '_blank', + 'noopener,noreferrer', + ); + }; + + const onShareLinkedIn = (): void => { + window.open( + getLinkedInShareLink(shareLink), + '_blank', + 'noopener,noreferrer', + ); + }; + + const onCopyLink = async (): Promise => { + await navigator.clipboard.writeText(shareLink); + setIsCopied(true); + }; + + return ( +
+
+ + + +
+ {isCopied && ( + + Link copied + + )} +
+ ); +} diff --git a/packages/shared/src/components/streak/ReadingStreakButton.tsx b/packages/shared/src/components/streak/ReadingStreakButton.tsx index a957620833..730e3c8b97 100644 --- a/packages/shared/src/components/streak/ReadingStreakButton.tsx +++ b/packages/shared/src/components/streak/ReadingStreakButton.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import classnames from 'classnames'; import { ReadingStreakPopup } from './popup/ReadingStreakPopup'; import type { ButtonIconPosition } from '../buttons/Button'; @@ -12,13 +12,28 @@ import { isTesting } from '../../lib/constants'; import { useLogContext } from '../../contexts/LogContext'; import { LogEvent } from '../../lib/log'; import { RootPortal } from '../tooltips/Portal'; -import { Drawer } from '../drawers'; +import { Drawer, DrawerPosition } from '../drawers'; import ConditionalWrapper from '../ConditionalWrapper'; import type { TooltipPosition } from '../tooltips/BaseTooltipContainer'; import { useAuthContext } from '../../contexts/AuthContext'; import { isSameDayInTimezone } from '../../lib/timezones'; import { IconWrapper } from '../Icon'; import { useStreakTimezoneOk } from '../../hooks/streaks/useStreakTimezoneOk'; +import { AnimatedNumber } from './AnimatedNumber'; +import { useStreakIncrement } from '../../hooks/streaks/useStreakIncrement'; +import { + useStreakUrgency, + UrgencyLevel, +} from '../../hooks/streaks/useStreakUrgency'; +import { useStreakDebug } from '../../hooks/streaks/useStreakDebug'; +import { getCurrentTier, getMilestoneAtDay } from '../../lib/streakMilestones'; +import { StreakIncrementPopover } from './StreakIncrementPopover'; +import { StreakBrokenPopover } from './StreakBrokenPopover'; +import { StreakReminderPopover } from './StreakReminderPopover'; +import { StreakMilestoneCelebration } from './StreakMilestoneCelebration'; +import { useLazyModal } from '../../hooks/useLazyModal'; +import { LazyModal } from '../modals/common/types'; +import { Switch } from '../fields/Switch'; interface ReadingStreakButtonProps { streak: UserStreak; @@ -34,6 +49,10 @@ interface CustomStreaksTooltipProps { shouldShowStreaks?: boolean; setShouldShowStreaks?: (value: boolean) => void; placement: TooltipPosition; + showMilestoneTimeline?: boolean; + streakOverride?: number; + isDebugMode?: boolean; + milestoneClaimResetNonce?: number; } function CustomStreaksTooltip({ @@ -42,6 +61,10 @@ function CustomStreaksTooltip({ shouldShowStreaks, setShouldShowStreaks, placement, + showMilestoneTimeline, + streakOverride, + isDebugMode, + milestoneClaimResetNonce, }: CustomStreaksTooltipProps): ReactElement { return ( } - onClickOutside={() => setShouldShowStreaks(false)} + content={ + + } + onClickOutside={ + isDebugMode ? undefined : () => setShouldShowStreaks(false) + } > {children} ); } +const urgencyTooltipMessages: Partial> = { + [UrgencyLevel.Medium]: '1 post to keep your streak alive', + [UrgencyLevel.High]: 'Less than 1 hour left!', +}; + export function ReadingStreakButton({ streak, isLoading, @@ -71,15 +109,60 @@ export function ReadingStreakButton({ iconPosition, className, }: ReadingStreakButtonProps): ReactElement { + const { openModal } = useLazyModal(); const { logEvent } = useLogContext(); const { user } = useAuthContext(); const isLaptop = useViewSize(ViewSize.Laptop); const isMobile = useViewSize(ViewSize.MobileL); + const isTablet = useViewSize(ViewSize.Tablet); + const debug = useStreakDebug(); const [shouldShowStreaks, setShouldShowStreaks] = useState(false); + const [showStreakAsDrawer, setShowStreakAsDrawer] = useState(false); + const [debugPos, setDebugPos] = useState({ x: 16, y: 16 }); + const [milestoneClaimResetNonce, setMilestoneClaimResetNonce] = useState(0); + const dragRef = useRef<{ + startX: number; + startY: number; + origX: number; + origY: number; + } | null>(null); + const [showMilestoneCelebration, setShowMilestoneCelebration] = + useState(false); + const effectiveStreak = debug.isDebugMode + ? debug.debugStreakOverride ?? 0 + : streak?.current; + const isDebugStreakInactive = debug.isDebugMode && effectiveStreak === 0; const hasReadToday = - streak?.lastViewAt && + !isDebugStreakInactive && + !!streak?.lastViewAt && isSameDayInTimezone(new Date(streak.lastViewAt), new Date(), user.timezone); const isTimezoneOk = useStreakTimezoneOk(); + const { animationState, previousStreak } = useStreakIncrement( + streak?.current, + ); + const [showBrokenPopover, setShowBrokenPopover] = useState(false); + const [showReminderPopover, setShowReminderPopover] = useState(false); + const isStreaksEnabled = !!streak && !isDebugStreakInactive; + const urgency = useStreakUrgency( + !!hasReadToday, + isStreaksEnabled, + effectiveStreak ?? undefined, + ); + + const effectiveUrgency = debug.debugUrgency ?? urgency; + const effectiveAnimation = debug.debugAnimationOverride ?? animationState; + const currentTier = getCurrentTier(effectiveStreak ?? 0); + const hitMilestone = getMilestoneAtDay(effectiveStreak ?? 0); + const decrementDebugStreak = useCallback(() => { + debug.setDebugStreak(Math.max((effectiveStreak ?? 0) - 1, 0)); + }, [debug, effectiveStreak]); + const incrementDebugStreak = useCallback(() => { + debug.setDebugStreak((effectiveStreak ?? 0) + 1); + }, [debug, effectiveStreak]); + const handleResetDebug = useCallback(() => { + debug.resetDebug(); + setMilestoneClaimResetNonce((value) => value + 1); + }, [debug]); const handleToggle = useCallback(() => { setShouldShowStreaks((state) => !state); @@ -90,9 +173,32 @@ export function ReadingStreakButton({ }); } }, [shouldShowStreaks, logEvent]); + const handleCloseDrawer = useCallback(() => { + setShouldShowStreaks(false); + }, []); const Tooltip = shouldShowStreaks ? CustomStreaksTooltip : SimpleTooltip; + useEffect(() => { + if (isTesting || isLoading || !streak || !user?.id || hasReadToday) { + return; + } + + const sessionKey = `streak-reminder-shown:${user.id}`; + + try { + if (sessionStorage.getItem(sessionKey)) { + return; + } + + sessionStorage.setItem(sessionKey, '1'); + } catch { + // Fallback for environments where sessionStorage is unavailable. + } + + requestAnimationFrame(() => setShowReminderPopover(true)); + }, [hasReadToday, isLoading, streak, user?.id]); + if (isLoading) { return
; } @@ -101,61 +207,318 @@ export function ReadingStreakButton({ return null; } + const urgencyMessage = debug.features.urgencyNudges + ? urgencyTooltipMessages[effectiveUrgency] + : undefined; + const showIncrementAnimation = + debug.features.animatedCounter && effectiveAnimation === 'incrementing'; + const isDebugIncrement = debug.debugAnimationOverride === 'incrementing'; + const showStreakBroken = showBrokenPopover || effectiveAnimation === 'broken'; + const showUrgencyAnimation = debug.features.urgencyNudges; + const isTabletOnly = isTablet && !isLaptop; + const shouldOpenInDrawer = + isMobile || isTabletOnly || (debug.isDebugMode && showStreakAsDrawer); + const debugActionButtonClassName = + 'inline-flex items-center justify-center gap-2 rounded-8 bg-surface-float px-3 py-1 typo-footnote hover:bg-surface-hover'; + const debugIconClassName = + 'inline-flex h-4 w-4 items-center justify-center text-[14px] leading-none'; + return ( <> ( {children} )} > - + {showIncrementAnimation && (isDebugIncrement || !hitMilestone) && ( + )} - size={!compact && !isMobile ? ButtonSize.Medium : ButtonSize.Small} - > - {streak?.current} - {!compact && ' reading days'} - + {showStreakBroken && ( + + )} + {showReminderPopover && ( + + )} +
- {isMobile && ( + {shouldOpenInDrawer && ( - + )} + + {((showIncrementAnimation && hitMilestone && !isDebugIncrement) || + showMilestoneCelebration) && ( + setShowMilestoneCelebration(false)} + /> + )} + + {debug.isDebugMode && ( +
+ + + + + + + setShowStreakAsDrawer((value) => !value)} + className="rounded-8 bg-surface-float px-3 py-2 hover:bg-surface-hover" + > + Open as right drawer + + debug.setFeedHeroVisible(!debug.isFeedHeroVisible)} + className="rounded-8 bg-surface-float px-3 py-2 hover:bg-surface-hover" + > + Show feed hero night + +
+ {(['auto', 'night', 'morning'] as const).map((variant) => ( + + ))} +
+
+ + + {effectiveStreak ?? 0} + + +
+
+ {[0, 3, 4, 7, 14, 30, 90, 365, 730, 1095, 1460].map((d) => ( + + ))} +
+ +
+ )} ); } diff --git a/packages/shared/src/components/streak/StreakBrokenPopover.tsx b/packages/shared/src/components/streak/StreakBrokenPopover.tsx new file mode 100644 index 0000000000..bb6b986e0d --- /dev/null +++ b/packages/shared/src/components/streak/StreakBrokenPopover.tsx @@ -0,0 +1,63 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { ReadingStreakIcon } from '../icons'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; + +type PopoverPhase = 'enter' | 'visible' | 'fading' | 'exit'; + +interface StreakBrokenPopoverProps { + previousStreak: number; +} + +export function StreakBrokenPopover({ + previousStreak, +}: StreakBrokenPopoverProps): ReactElement | null { + const [phase, setPhase] = useState('enter'); + + useEffect(() => { + const timers = [ + setTimeout(() => setPhase('visible'), 200), + setTimeout(() => setPhase('fading'), 3200), + setTimeout(() => setPhase('exit'), 3800), + ]; + + return () => timers.forEach(clearTimeout); + }, []); + + if (phase === 'exit') { + return null; + } + + return ( +
+
+ + + Streak lost + +
+ + + Your {previousStreak}-day streak has been reset. Start reading again to + build it back! + +
+ ); +} diff --git a/packages/shared/src/components/streak/StreakIncrementPopover.tsx b/packages/shared/src/components/streak/StreakIncrementPopover.tsx new file mode 100644 index 0000000000..f7bc2a593c --- /dev/null +++ b/packages/shared/src/components/streak/StreakIncrementPopover.tsx @@ -0,0 +1,103 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { ReadingStreakIcon } from '../icons'; +import { Typography, TypographyType } from '../typography/Typography'; + +type PopoverPhase = 'enter' | 'filling' | 'complete' | 'fading' | 'exit'; +type IconPhase = 'circle' | 'outline' | 'filled'; + +interface StreakIncrementPopoverProps { + fromStreak: number; + toStreak: number; +} + +export function StreakIncrementPopover({ + toStreak, +}: StreakIncrementPopoverProps): ReactElement | null { + const [phase, setPhase] = useState('enter'); + const [iconPhase, setIconPhase] = useState('circle'); + + useEffect(() => { + const timers = [ + setTimeout(() => setPhase('filling'), 0), + setTimeout(() => setPhase('complete'), 2800), + setTimeout(() => setPhase('fading'), 4200), + setTimeout(() => setPhase('exit'), 5000), + + // Icon animation sequence + setTimeout(() => setIconPhase('outline'), 400), + setTimeout(() => setIconPhase('filled'), 850), + ]; + + return () => { + timers.forEach(clearTimeout); + }; + }, []); + + if (phase === 'exit') { + return null; + } + + return ( +
+
+
+ {iconPhase === 'filled' && ( + + )} + + {/* Hollow Circle */} +
+ + {/* Outlined Flame */} + + + {/* Filled Flame */} + +
+ + Day {toStreak}! + +
+
+ ); +} diff --git a/packages/shared/src/components/streak/StreakMilestoneCelebration.tsx b/packages/shared/src/components/streak/StreakMilestoneCelebration.tsx new file mode 100644 index 0000000000..fafaf49e35 --- /dev/null +++ b/packages/shared/src/components/streak/StreakMilestoneCelebration.tsx @@ -0,0 +1,228 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import type { StreakMilestone } from '../../lib/streakMilestones'; +import { RewardType } from '../../lib/streakMilestones'; +import { CoreIcon } from '../icons'; +import { RootPortal } from '../tooltips/Portal'; +import { MILESTONE_ICON_URLS } from './popup/icons/milestoneIcons'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { IconSize } from '../Icon'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { MilestoneShareActions } from './MilestoneShareActions'; + +type CelebrationPhase = 'enter' | 'reveal' | 'rewards'; + +interface StreakMilestoneCelebrationProps { + milestone: StreakMilestone; + streakDay: number; + onComplete: () => void; +} + +const rewardIcon: Record = { + [RewardType.Cores]: '', + [RewardType.Cosmetic]: '\u2728', + [RewardType.Perk]: '\u26A1', +}; + +interface Particle { + id: number; + x: number; + y: number; + size: number; + delay: number; + duration: number; + color: string; +} + +const PARTICLE_COLORS = [ + 'rgba(255, 149, 0, 0.8)', + 'rgba(255, 116, 84, 0.8)', + 'rgba(255, 200, 60, 0.8)', + 'rgba(255, 255, 255, 0.6)', + 'rgba(255, 170, 100, 0.7)', +]; + +const generateParticles = (count: number): Particle[] => + Array.from({ length: count }, (_, i) => ({ + id: i, + x: Math.random() * 100, + y: Math.random() * 100, + size: 4 + Math.random() * 8, + delay: Math.random() * 0.6, + duration: 1 + Math.random() * 1.5, + color: PARTICLE_COLORS[i % PARTICLE_COLORS.length], + })); + +export function StreakMilestoneCelebration({ + milestone, + streakDay, + onComplete, +}: StreakMilestoneCelebrationProps): ReactElement | null { + const [phase, setPhase] = useState('enter'); + const particles = useMemo(() => generateParticles(20), []); + + useEffect(() => { + const timers = [ + setTimeout(() => setPhase('reveal'), 100), + setTimeout(() => setPhase('rewards'), 800), + ]; + + return () => timers.forEach(clearTimeout); + }, []); + + const isVisible = phase !== 'enter'; + const showRewards = phase === 'rewards'; + + return ( + +
+
+ + {isVisible && + particles.map((p) => ( +
+ ))} + +
+
+ {milestone.label} +
+ +
+ + {milestone.label} + + + Day {streakDay} Milestone! + +
+ + {showRewards && milestone.rewards.length > 0 && ( +
+ + Rewards Unlocked + + {milestone.rewards.map((reward) => ( +
+ {reward.type === RewardType.Cores ? ( + + ) : ( + {rewardIcon[reward.type]} + )} + + {reward.description} + +
+ ))} +
+ )} + {showRewards && ( +
+ + +
+ )} +
+
+ + + + ); +} diff --git a/packages/shared/src/components/streak/StreakReminderPopover.tsx b/packages/shared/src/components/streak/StreakReminderPopover.tsx new file mode 100644 index 0000000000..af7279ca82 --- /dev/null +++ b/packages/shared/src/components/streak/StreakReminderPopover.tsx @@ -0,0 +1,97 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { getCurrentTier, getNextMilestone } from '../../lib/streakMilestones'; +import { MILESTONE_ICON_URLS } from './popup/icons/milestoneIcons'; + +type PopoverPhase = 'enter' | 'visible' | 'fading' | 'exit'; + +interface StreakReminderPopoverProps { + currentStreak: number; +} + +export function StreakReminderPopover({ + currentStreak, +}: StreakReminderPopoverProps): ReactElement | null { + const [phase, setPhase] = useState('enter'); + + useEffect(() => { + const timers = [ + setTimeout(() => setPhase('visible'), 200), + setTimeout(() => setPhase('fading'), 3200), + setTimeout(() => setPhase('exit'), 3800), + ]; + + return () => timers.forEach(clearTimeout); + }, []); + + if (phase === 'exit') { + return null; + } + + const currentTier = getCurrentTier(currentStreak); + const nextMilestone = getNextMilestone(currentStreak); + const activeMilestone = nextMilestone ?? currentTier; + const hasNextMilestone = Boolean(nextMilestone); + const daysAway = nextMilestone + ? Math.max(nextMilestone.day - currentStreak, 0) + : 0; + + return ( +
+
+
+ +
+
+ + Day {currentStreak}! + +
+ + + Read today to keep your {currentStreak}-day streak alive. + + + {activeMilestone && ( +
+
+ {activeMilestone.label} + + {hasNextMilestone + ? `${activeMilestone.label} ยท ${ + daysAway === 1 ? '1 day away' : `${daysAway} days away` + }` + : activeMilestone.label} + +
+
+ )} +
+ ); +} diff --git a/packages/shared/src/components/streak/automation/session-animation.ai-prompt.md b/packages/shared/src/components/streak/automation/session-animation.ai-prompt.md new file mode 100644 index 0000000000..a53f99cfc7 --- /dev/null +++ b/packages/shared/src/components/streak/automation/session-animation.ai-prompt.md @@ -0,0 +1,33 @@ +# AI Video Generator Prompt (Runway/Pika/Sora-style) + +Copy and adapt this prompt in your preferred video generation/editing tool. + +## Prompt + +Create a polished 30-second product demo video of a modern developer web app interface in dark theme. +The UI is a streak popup with a calendar and a milestones timeline. +Tone: premium, energetic, motivating. +Style: clean SaaS product marketing, high readability, subtle motion graphics, no flashy transitions. + +Storyboard: +1) Open streak popup from top navigation and reveal current streak + calendar. +2) Pan to a "milestones & rewards" timeline with active and upcoming milestones. +3) Click "Claim" on a sponsored day-4 reward and show a centered modal with glowing logo, coupon code, copy button, and redeem CTA. +4) Close modal and claim a normal core reward. Show a coin-like reward icon with "+amount" that flies from center of screen toward a wallet icon in the top nav. +5) End on timeline showing claimed states and a final card: "Stay consistent. Unlock more. daily.dev streak milestones". + +Direction and constraints: +- 16:9, 1920x1080, 60fps. +- Keep cursor visible with subtle click highlight. +- Smooth camera easing, very light zoom only during key clicks. +- Preserve legibility of UI text and buttons. +- Do not use hard cuts during modal animation; use short crossfades. +- Keep timing natural and realistic as if recorded from a real app session. + +## Optional negative prompt + +- avoid cartoonish UI +- avoid neon over-saturation +- avoid unreadable tiny text +- avoid chaotic camera movement +- avoid fake code editor overlays diff --git a/packages/shared/src/components/streak/automation/session-animation.storyboard.md b/packages/shared/src/components/streak/automation/session-animation.storyboard.md new file mode 100644 index 0000000000..100ece3204 --- /dev/null +++ b/packages/shared/src/components/streak/automation/session-animation.storyboard.md @@ -0,0 +1,73 @@ +# Streak Milestone Feature Animation (30s) + +This sequence showcases the updated streak popup, milestones timeline, and reward claim animations. + +## Capture setup + +- Resolution: 1920x1080 +- Frame rate: 60fps +- Theme: dark (recommended for contrast on glow effects) +- Cursor: enabled with click highlight +- Zoom: subtle auto zoom only on interaction moments + +## Demo preconditions + +- Use an account with: + - streak `>= 4` to show sponsored day-4 coupon claim + - at least one additional unlocked reward milestone to show regular claim state +- Ensure streak popup can be opened from the top navigation streak button. +- Keep wallet button visible in the top nav to support cores fly-to-wallet effect. + +## Shot list + +### Shot 1: Open and orient (0:00 - 0:04) + +- Start on feed home in idle state. +- Move cursor to streak trigger in header. +- Click to open `ReadingStreakPopup`. +- Hold for 0.8s on title area + current streak and calendar. +- Overlay text: `New streak milestones & rewards`. + +### Shot 2: Reveal timeline polish (0:04 - 0:10) + +- Slow pan down to milestones section. +- Let timeline auto-scroll settle to active milestone. +- Brief pause while "days away" badge is visible on next milestone. +- Overlay text: `Progress is now timeline-first`. + +### Shot 3: Claim sponsored reward (0:10 - 0:17) + +- Move cursor to the day-4 sponsored row. +- Click `Claim` (or `Show` if already claimed in current session). +- Record full-screen coupon modal reveal: + - glow logo reveal + - coupon code appearance + - CTA + close button +- Click `Copy` and pause 0.5s on `Copied` state. +- Overlay text: `Milestone rewards now claimable in-flow`. + +### Shot 4: Cores reward animation (0:17 - 0:23) + +- Close coupon modal. +- Click `Claim` on a non-sponsored unlocked milestone. +- Capture cores reward animation: + - center pop + amount + - fly-to-wallet motion +- Keep wallet area in frame until animation completes. +- Overlay text: `Rewards animate straight to wallet`. + +### Shot 5: End state and CTA (0:23 - 0:30) + +- Return focus to timeline with claimed states visible. +- Add gentle zoom out to include popup context (calendar + timeline). +- Final text card: + - line 1: `Stay consistent. Unlock more.` + - line 2: `daily.dev streak milestones` +- Fade out. + +## Editing notes + +- Keep cuts tight; avoid hard jumps during modal transitions. +- Prefer 150-220ms cross-dissolves between shots. +- Do not speed up claim animations; show them at 1x for clarity. +- Keep total runtime between 26s and 32s. diff --git a/packages/shared/src/components/streak/automation/session-animation.voiceover.md b/packages/shared/src/components/streak/automation/session-animation.voiceover.md new file mode 100644 index 0000000000..3217362b89 --- /dev/null +++ b/packages/shared/src/components/streak/automation/session-animation.voiceover.md @@ -0,0 +1,44 @@ +# Voiceover and Captions (30s) + +Use this as either spoken VO or on-screen caption timing. + +## Voiceover script + +00:00-00:04 +"This is the updated streak experience on daily.dev." + +00:04-00:10 +"Your progress is now centered around a cleaner milestones and rewards timeline." + +00:10-00:17 +"Unlocked rewards can be claimed directly in the flow, including sponsored milestone perks." + +00:17-00:23 +"Core rewards animate into your wallet instantly, so feedback is immediate." + +00:23-00:30 +"Keep your streak active, unlock more milestones, and track everything in one place." + +## Optional subtitle file (SRT) + +```srt +1 +00:00:00,000 --> 00:00:04,000 +This is the updated streak experience on daily.dev. + +2 +00:00:04,000 --> 00:00:10,000 +Your progress is now centered around a cleaner milestones and rewards timeline. + +3 +00:00:10,000 --> 00:00:17,000 +Unlocked rewards can be claimed directly in the flow, including sponsored milestone perks. + +4 +00:00:17,000 --> 00:00:23,000 +Core rewards animate into your wallet instantly, so feedback is immediate. + +5 +00:00:23,000 --> 00:00:30,000 +Keep your streak active, unlock more milestones, and track everything in one place. +``` diff --git a/packages/shared/src/components/streak/popup/ClaimRewardAnimation.tsx b/packages/shared/src/components/streak/popup/ClaimRewardAnimation.tsx new file mode 100644 index 0000000000..ef91f27a72 --- /dev/null +++ b/packages/shared/src/components/streak/popup/ClaimRewardAnimation.tsx @@ -0,0 +1,293 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { CoreIcon } from '../../icons'; +import { RootPortal } from '../../tooltips/Portal'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { useIsLightTheme } from '../../../hooks/utils'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import CursorAiDarkLogo from './icons/cursor-ai-dark.svg'; +import CursorAiLightLogo from './icons/cursor-ai-light.svg'; + +type AnimationPhase = 'idle' | 'appear' | 'fly'; + +export type ClaimReward = + | { + type: 'cores'; + amount: string; + milestoneDay: number; + milestoneLabel: string; + } + | { + type: 'coupon'; + code: string; + title: string; + milestoneDay: number; + milestoneLabel: string; + }; + +interface ClaimRewardAnimationProps { + reward: ClaimReward; + onComplete: () => void; +} + +const WALLET_BUTTON_SELECTOR = 'a[href="/wallet"]'; +const PARTICLE_COLORS = [ + 'rgba(255, 149, 0, 0.8)', + 'rgba(255, 116, 84, 0.8)', + 'rgba(255, 200, 60, 0.8)', + 'rgba(255, 255, 255, 0.6)', + 'rgba(255, 170, 100, 0.7)', +]; + +interface Particle { + id: number; + x: number; + y: number; + size: number; + delay: number; + duration: number; + color: string; +} + +const generateParticles = (count: number): Particle[] => + Array.from({ length: count }, (_, i) => ({ + id: i, + x: Math.random() * 100, + y: Math.random() * 100, + size: 4 + Math.random() * 8, + delay: Math.random() * 0.6, + duration: 1 + Math.random() * 1.5, + color: PARTICLE_COLORS[i % PARTICLE_COLORS.length], + })); + +export function ClaimRewardAnimation({ + reward, + onComplete, +}: ClaimRewardAnimationProps): ReactElement | null { + const isLightTheme = useIsLightTheme(); + const [phase, setPhase] = useState('idle'); + const [copied, setCopied] = useState(false); + const [showCouponDetails, setShowCouponDetails] = useState(false); + const [flyTarget, setFlyTarget] = useState<{ + x: number; + y: number; + } | null>(null); + const particles = useMemo(() => generateParticles(20), []); + + useEffect(() => { + requestAnimationFrame(() => setPhase('appear')); + + if (reward.type === 'coupon') { + const revealTimer = setTimeout(() => setShowCouponDetails(true), 700); + return () => clearTimeout(revealTimer); + } + + const walletBtn = document.querySelector(WALLET_BUTTON_SELECTOR); + + if (walletBtn) { + const rect = walletBtn.getBoundingClientRect(); + setFlyTarget({ + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }); + } + + const appearTimer = setTimeout(() => setPhase('fly'), 1200); + const completeTimer = setTimeout(() => { + onComplete(); + }, 2200); + + return () => { + clearTimeout(appearTimer); + clearTimeout(completeTimer); + }; + }, [onComplete, reward.type]); + + const handleCopyCode = useCallback(async () => { + if (reward.type !== 'coupon') { + return; + } + + await navigator.clipboard.writeText(reward.code); + setCopied(true); + }, [reward]); + + const centerX = typeof window !== 'undefined' ? window.innerWidth / 2 : 500; + const centerY = typeof window !== 'undefined' ? window.innerHeight / 2 : 400; + + const isFly = phase === 'fly' && flyTarget && reward.type === 'cores'; + const targetX = isFly ? flyTarget.x : centerX; + const targetY = isFly ? flyTarget.y : centerY; + const targetScale = isFly ? 0.15 : 1; + const targetOpacity = isFly ? 0 : 1; + const showBackdrop = phase !== 'idle'; + const CursorLogo = isLightTheme ? CursorAiLightLogo : CursorAiDarkLogo; + + return ( + +
+
+ {reward.type === 'coupon' && + showBackdrop && + particles.map((p) => ( +
+ ))} + {reward.type === 'coupon' ? ( +
+
+ +
+ +
+ + Cursor AI + + + Day 4 Milestone! + +
+ + {reward.code} + + +
+ + +
+
+ ) : ( + <> +
+
+ +
+ + +{reward.amount} + +
+ + )} +
+ + + ); +} diff --git a/packages/shared/src/components/streak/popup/MilestoneTimeline.tsx b/packages/shared/src/components/streak/popup/MilestoneTimeline.tsx new file mode 100644 index 0000000000..b1e3f1e1a3 --- /dev/null +++ b/packages/shared/src/components/streak/popup/MilestoneTimeline.tsx @@ -0,0 +1,511 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import type { StreakMilestone } from '../../../lib/streakMilestones'; +import { + STREAK_MILESTONES, + getNextMilestone, + RewardType, +} from '../../../lib/streakMilestones'; +import { useIsLightTheme } from '../../../hooks/utils'; +import { + CoreIcon, + LockIcon, + ReputationLightningIcon, + SparkleIcon, +} from '../../icons'; +import { IconSize } from '../../Icon'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { ClaimRewardAnimation } from './ClaimRewardAnimation'; +import type { ClaimReward } from './ClaimRewardAnimation'; +import { MILESTONE_ICON_URLS } from './icons/milestoneIcons'; +import CursorAiIconDark from './icons/cursor-ai-dark.svg'; +import CursorAiIconLight from './icons/cursor-ai-light.svg'; +import SponsoredGiftImage from './icons/sponsored-gift.png'; + +interface MilestoneItemProps { + milestone: StreakMilestone; + isUnlocked: boolean; + isNext: boolean; + isLast: boolean; + daysAway?: number; + isClaimed: boolean; + onClaim?: () => void; +} + +interface MilestoneSparkle { + top: number; + left: number; + size: number; + delayMs: number; +} + +const seededRandom = (seed: number): number => { + const x = Math.sin(seed * 9301 + 49297) * 49297; + return x - Math.floor(x); +}; + +const getMilestoneSparkles = ( + milestoneDay: number, + count = 3, +): MilestoneSparkle[] => + Array.from({ length: count }, (_, index) => ({ + top: 20 + seededRandom(milestoneDay * 11 + index * 7 + 1) * 60, + left: 20 + seededRandom(milestoneDay * 13 + index * 5 + 3) * 60, + size: 8 + seededRandom(milestoneDay * 17 + index * 3 + 2) * 5, + delayMs: seededRandom(milestoneDay * 19 + index * 9 + 4) * 900, + })); + +const SPONSORED_MILESTONE_DAY = 4; +const SPONSORED_COUPON_CODE = 'CURSOR-4D-STREAK'; +const sponsoredGiftSrc = + typeof SponsoredGiftImage === 'string' + ? SponsoredGiftImage + : (SponsoredGiftImage as { src?: string })?.src; + +const getMilestoneHelperText = (milestone: StreakMilestone): string | null => { + if (milestone.day === 1) { + return 'Start your streak journey.'; + } + + return null; +}; + +const getMilestoneCoresAmount = (milestone: StreakMilestone): string | null => { + const coresReward = milestone.rewards.find( + (reward) => reward.type === RewardType.Cores, + ); + if (!coresReward) { + return null; + } + + const amountMatch = coresReward.description.match(/\d[\d,]*/); + return amountMatch?.[0] ?? null; +}; + +const getMilestoneHeadline = ({ + milestone, + helperText, + isSponsoredMilestone, +}: { + milestone: StreakMilestone; + helperText: string | null; + isSponsoredMilestone: boolean; +}): ReactElement | string => { + if (isSponsoredMilestone) { + return '20% discount coupon'; + } + + if (milestone.rewards.length === 0) { + return helperText ?? milestone.label; + } + + if (milestone.rewards.length === 1) { + const reward = milestone.rewards[0]; + + if (reward.type === RewardType.Perk && /boost/i.test(reward.description)) { + return ( + + + {reward.description} + + ); + } + + if (reward.type !== RewardType.Cores) { + return reward.description; + } + + return ( + + + {reward.description} + + ); + } + + const primaryCoresReward = milestone.rewards.find( + (reward) => reward.type === RewardType.Cores, + ); + const primaryReward = + primaryCoresReward?.description ?? milestone.rewards[0].description; + if (!primaryCoresReward) { + const extraRewardsCount = milestone.rewards.length - 1; + return `${primaryReward} + ${extraRewardsCount} ${ + extraRewardsCount === 1 ? 'reward' : 'rewards' + }`; + } + + return ( + + + {primaryReward} + + ); +}; + +function MilestoneItem({ + milestone, + isUnlocked, + isNext, + isLast, + daysAway, + isClaimed, + onClaim, +}: MilestoneItemProps): ReactElement { + const helperText = + milestone.rewards.length === 0 ? getMilestoneHelperText(milestone) : null; + const hasCompactRewardLayout = milestone.rewards.length <= 1; + const sparklePositions = getMilestoneSparkles(milestone.day); + const isSponsoredMilestone = milestone.day === SPONSORED_MILESTONE_DAY; + const isLightTheme = useIsLightTheme(); + const hasActiveLineStyle = isNext || isSponsoredMilestone; + const showClaimButton = milestone.rewards.length > 0 && isUnlocked; + const canClaim = isUnlocked && !isClaimed; + const canShowSponsoredReward = isSponsoredMilestone && isClaimed; + const CursorAiIcon = isLightTheme ? CursorAiIconLight : CursorAiIconDark; + const claimButtonVariant = + canShowSponsoredReward || isClaimed + ? ButtonVariant.Tertiary + : ButtonVariant.Primary; + let claimButtonLabel = 'Claim'; + if (canShowSponsoredReward) { + claimButtonLabel = 'Show'; + } else if (isClaimed) { + claimButtonLabel = 'Claimed'; + } + let milestoneDescriptionColor = TypographyColor.Quaternary; + if (isNext) { + milestoneDescriptionColor = TypographyColor.Primary; + } else if (isUnlocked) { + milestoneDescriptionColor = TypographyColor.Tertiary; + } + const milestoneHeadline = getMilestoneHeadline({ + milestone, + helperText, + isSponsoredMilestone, + }); + const shouldShowLockedIcon = !isUnlocked && !isNext; + const milestoneIconStyle = + isNext && !isSponsoredMilestone ? { filter: 'grayscale(100%)' } : undefined; + let milestoneIcon: ReactElement; + if (isSponsoredMilestone) { + milestoneIcon = ( + {`${milestone.label} + ); + } else if (shouldShowLockedIcon) { + milestoneIcon = ( + + ); + } else { + milestoneIcon = ( + {milestone.label} + ); + } + + return ( +
+ {isSponsoredMilestone && ( + <> +
+ + )} + {!isLast && ( +
+ )} + +
+ {isNext && ( +
+ )} + {isUnlocked && !isNext && ( + <> + {sparklePositions.map((sparkle) => ( + + ))} + + )} + {milestoneIcon} +
+ +
+
+ + {`${milestone.day}d`} + + + {milestoneHeadline} + + {showClaimButton && ( + + )} +
+
+ + {isSponsoredMilestone + ? `A gift from ${milestone.label}` + : milestone.label} + + {isNext && daysAway !== undefined && ( + + ยท {daysAway === 1 ? '1 day away' : `${daysAway} days away`} + + )} + {isSponsoredMilestone && ( + + )} +
+
+
+ ); +} + +interface MilestoneTimelineProps { + currentStreak: number; + isVisible?: boolean; + claimResetNonce?: number; +} + +export function MilestoneTimeline({ + currentStreak, + isVisible = true, + claimResetNonce, +}: MilestoneTimelineProps): ReactElement { + const nextMilestone = getNextMilestone(currentStreak); + const activeDay = + nextMilestone?.day ?? + STREAK_MILESTONES.filter((m) => m.day <= currentStreak).at(-1)?.day ?? + STREAK_MILESTONES[0]?.day; + const activeMilestoneRef = useRef(null); + const [claimedDays, setClaimedDays] = useState>(new Set()); + const [claimAnimation, setClaimAnimation] = useState( + null, + ); + + const handleClaim = useCallback((milestone: StreakMilestone) => { + if (milestone.day === SPONSORED_MILESTONE_DAY) { + setClaimAnimation({ + type: 'coupon', + code: SPONSORED_COUPON_CODE, + title: 'Cursor AI discount coupon', + milestoneDay: milestone.day, + milestoneLabel: milestone.label, + }); + setClaimedDays((prev) => new Set([...prev, milestone.day])); + return; + } + + const coresAmount = getMilestoneCoresAmount(milestone); + if (coresAmount) { + setClaimAnimation({ + type: 'cores', + amount: coresAmount, + milestoneDay: milestone.day, + milestoneLabel: milestone.label, + }); + } + + setClaimedDays((prev) => new Set([...prev, milestone.day])); + }, []); + + const handleAnimationComplete = useCallback(() => { + setClaimAnimation(null); + }, []); + + useEffect(() => { + setClaimedDays(new Set()); + setClaimAnimation(null); + }, [claimResetNonce]); + + useEffect(() => { + if (!isVisible) { + return undefined; + } + + const timer = setTimeout(() => { + const active = activeMilestoneRef.current; + + if (!active) { + return; + } + + active.scrollIntoView({ block: 'center', behavior: 'smooth' }); + }, 200); + + return () => clearTimeout(timer); + }, [currentStreak, nextMilestone?.day, isVisible]); + + return ( +
+ + milestones & rewards + + +
+ {STREAK_MILESTONES.map((milestone, index) => { + const isUnlocked = currentStreak >= milestone.day; + const isNext = + nextMilestone !== null && milestone.day === nextMilestone.day; + const isLast = index === STREAK_MILESTONES.length - 1; + const daysAway = isNext ? milestone.day - currentStreak : undefined; + const shouldScrollToMilestone = milestone.day === activeDay; + const wasJustClaimed = claimedDays.has(milestone.day); + const isClaimed = wasJustClaimed; + + return ( +
+ handleClaim(milestone)} + /> +
+ ); + })} +
+ {claimAnimation && ( + + )} +
+ ); +} diff --git a/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx b/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx index 1b53501fad..37c845a492 100644 --- a/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx +++ b/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx @@ -1,24 +1,22 @@ import type { ReactElement } from 'react'; -import React, { useEffect, useMemo } from 'react'; -import { addDays, subDays } from 'date-fns'; +import React, { useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; -import classNames from 'classnames'; + import { useRouter } from 'next/router'; import { StreakSection } from './StreakSection'; -import { DayStreak, Streak } from './DayStreak'; +import { MilestoneTimeline } from './MilestoneTimeline'; +import { StreakMonthCalendar } from './StreakMonthCalendar'; import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; import type { ReadingDay, UserStreak } from '../../../graphql/users'; import { getReadingStreak30Days } from '../../../graphql/users'; import { useAuthContext } from '../../../contexts/AuthContext'; -import { useActions, useViewSize, ViewSize } from '../../../hooks'; +import { useActions } from '../../../hooks'; import { ActionType } from '../../../graphql/actions'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import { SettingsIcon, VIcon, WarningIcon } from '../../icons'; -import { isWeekend, DayOfWeek } from '../../../lib/date'; import { DEFAULT_TIMEZONE, getTimezoneOffsetLabel, - isSameDayInTimezone, } from '../../../lib/timezones'; import { timezoneSettingsUrl, webappUrl } from '../../../lib/constants'; import { useStreakTimezoneOk } from '../../../hooks/streaks/useStreakTimezoneOk'; @@ -43,77 +41,34 @@ import { TypographyColor, TypographyType, } from '../../typography/Typography'; -import { cloudinaryNotificationsBrowser } from '../../../lib/image'; +import CloseButton from '../../CloseButton'; +import { NotificationSvg } from '../../../svg/NotificationSvg'; import { usePushNotificationMutation } from '../../../hooks/notifications'; import { IconSize } from '../../Icon'; import { Tooltip } from '../../tooltip/Tooltip'; -const getStreak = ({ - value, - today, - history, - startOfWeek = DayOfWeek.Monday, - timezone, -}: { - value: Date; - today: Date; - history?: ReadingDay[]; - startOfWeek?: number; - timezone?: string; -}): Streak => { - const isFreezeDay = isWeekend(value, startOfWeek, timezone); - const isToday = isSameDayInTimezone(value, today, timezone); - const isFuture = value > today; - const isCompleted = - !isFuture && - history?.some(({ date: historyDate, reads }) => { - const dateToCompare = new Date(historyDate); - const sameDate = isSameDayInTimezone(dateToCompare, value, timezone); - - return sameDate && reads > 0; - }); - - if (isCompleted) { - return Streak.Completed; - } - - if (isFreezeDay) { - return Streak.Freeze; - } - - if (isToday) { - return Streak.Pending; - } - - return Streak.Upcoming; -}; - -const getStreakDays = (today: Date) => { - return [ - subDays(today, 4), - subDays(today, 3), - subDays(today, 2), - subDays(today, 1), - today, - addDays(today, 1), - addDays(today, 2), - addDays(today, 3), - addDays(today, 4), - ]; // these dates will then be compared to the user's post views -}; - interface ReadingStreakPopupProps { streak: UserStreak; fullWidth?: boolean; + showMilestoneTimeline?: boolean; + streakOverride?: number; + isVisible?: boolean; + milestoneClaimResetNonce?: number; + onClose?: () => void; } export function ReadingStreakPopup({ streak, fullWidth, + showMilestoneTimeline = true, + streakOverride, + isVisible = true, + milestoneClaimResetNonce, + onClose, }: ReadingStreakPopupProps): ReactElement { const router = useRouter(); const { flags, updateFlag } = useSettingsContext(); - const isMobile = useViewSize(ViewSize.MobileL); + const { user } = useAuthContext(); const { completeAction } = useActions(); const { data: history } = useQuery({ @@ -140,32 +95,6 @@ export function ReadingStreakPopup({ isInitialized && (!isSubscribed || acceptedJustNow); - const streaks = useMemo(() => { - const today = new Date(); - const streakDays = getStreakDays(today); - - return streakDays.map((value) => { - const isToday = isSameDayInTimezone(value, today, user?.timezone); - - const streakDef = getStreak({ - value, - today, - history, - startOfWeek: streak.weekStart, - timezone: user?.timezone, - }); - - return ( - - ); - }); - }, [history, streak.weekStart, user?.timezone]); - const onTogglePush = async () => { logEvent({ event_name: LogEvent.DisableNotification, @@ -188,133 +117,148 @@ export function ReadingStreakPopup({ return null; } + const displayStreak = streakOverride ?? streak.current; + return ( -
-
-
- - -
-
- {streaks} -
-
-
+
+ {/* Tier progress removed โ€” milestones timeline is the primary progression UI */} + +
+ +
+ {onClose && ( + )} - > -
- Total reading days: {streak.total} -
- - {isTimezoneOk ? ( - <> - We are showing your reading streak in your selected - timezone. -
- Click to adjust your timezone if needed or traveling. - - ) : ( - <>Click for more info + +
+
+
+ + {isTimezoneOk ? ( + <> + We are showing your reading streak in your selected + timezone. +
+ Click to adjust your timezone if needed or traveling. + + ) : ( + <>Click for more info + )} +
+ } + > +
+ {!isTimezoneOk && ( + )} +
+ { + const deviceTimezone = + Intl.DateTimeFormat().resolvedOptions().timeZone; + const eventExtra = { + device_timezone: deviceTimezone, + user_timezone: user?.timezone, + timezone_ok: isTimezoneOk, + timezone_ignore: flags?.timezoneMismatchIgnore, + }; + + logEvent({ + event_name: LogEvent.Click, + target_type: TargetId.StreakTimezoneLabel, + extra: JSON.stringify(eventExtra), + }); + + if (isTimezoneOk) { + return; + } + + event.preventDefault(); + + const promptResult = await showPrompt({ + title: 'Streak timezone mismatch', + description: `We detected your current timezone setting ${getTimezoneOffsetLabel( + user?.timezone, + )} does not match your current device timezone ${getTimezoneOffsetLabel( + deviceTimezone, + )}. You can update your timezone in settings.`, + okButton: { + title: 'Go to settings', + }, + cancelButton: { + title: 'Ignore', + }, + shouldCloseOnOverlayClick: false, + }); + + logEvent({ + event_name: LogEvent.Click, + target_type: TargetId.StreakTimezoneMismatchPrompt, + extra: JSON.stringify({ + ...eventExtra, + action: promptResult + ? StreakTimezonePromptAction.Settings + : StreakTimezonePromptAction.Ignore, + }), + }); + + if (!promptResult) { + updateFlag('timezoneMismatchIgnore', deviceTimezone); + + return; + } + + router.push(timezoneSettingsUrl); + }} + href={timezoneSettingsUrl} + > + {isTimezoneOk + ? user?.timezone || DEFAULT_TIMEZONE + : 'Timezone mismatch'} + +
- } - > -
- {!isTimezoneOk && ( - - )} -
- { - const deviceTimezone = - Intl.DateTimeFormat().resolvedOptions().timeZone; - const eventExtra = { - device_timezone: deviceTimezone, - user_timezone: user?.timezone, - timezone_ok: isTimezoneOk, - timezone_ignore: flags?.timezoneMismatchIgnore, - }; - - logEvent({ - event_name: LogEvent.Click, - target_type: TargetId.StreakTimezoneLabel, - extra: JSON.stringify(eventExtra), - }); - - if (isTimezoneOk) { - return; - } - - event.preventDefault(); - - const promptResult = await showPrompt({ - title: 'Streak timezone mismatch', - description: `We detected your current timezone setting ${getTimezoneOffsetLabel( - user?.timezone, - )} does not match your current device timezone ${getTimezoneOffsetLabel( - deviceTimezone, - )}. You can update your timezone in settings.`, - okButton: { - title: 'Go to settings', - }, - cancelButton: { - title: 'Ignore', - }, - shouldCloseOnOverlayClick: false, - }); - - logEvent({ - event_name: LogEvent.Click, - target_type: TargetId.StreakTimezoneMismatchPrompt, - extra: JSON.stringify({ - ...eventExtra, - action: promptResult - ? StreakTimezonePromptAction.Settings - : StreakTimezonePromptAction.Ignore, - }), - }); - - if (!promptResult) { - updateFlag('timezoneMismatchIgnore', deviceTimezone); - - return; - } - - router.push(timezoneSettingsUrl); - }} - href={timezoneSettingsUrl} - > - {isTimezoneOk - ? user?.timezone || DEFAULT_TIMEZONE - : 'Timezone mismatch'} - -
-
- -
- - - + + } + />
+ {showMilestoneTimeline && ( + + )} + {showAlert && (
{!isSubscribed && ( @@ -325,14 +269,11 @@ export function ReadingStreakPopup({ type={TypographyType.Callout} className="flex-1" > - Get notified to keep your streak + Don't lose your progress! Get notified
- A sample browser notification +
diff --git a/packages/shared/src/components/streak/popup/StreakMonthCalendar.tsx b/packages/shared/src/components/streak/popup/StreakMonthCalendar.tsx new file mode 100644 index 0000000000..322f5a7601 --- /dev/null +++ b/packages/shared/src/components/streak/popup/StreakMonthCalendar.tsx @@ -0,0 +1,325 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { addDays, format, isSameDay, subDays } from 'date-fns'; +import { ReadingStreakIcon } from '../../icons'; +import type { ReadingDay } from '../../../graphql/users'; +import { isWeekend as isWeekendDay } from '../../../lib/date'; + +interface StreakMonthCalendarProps { + history?: ReadingDay[]; + weekStart?: number; + timezone?: string; + currentStreak?: number; + streakOverride?: number; + trailing?: React.ReactNode; +} + +function formatDayLabel(day: Date, today: Date, isWeekend: boolean): string { + const isToday = isSameDay(day, today); + const datePart = format(day, 'MMMM d, yyyy'); + const label = isToday + ? `Today, ${datePart}` + : format(day, 'EEEE, MMMM d, yyyy'); + + if (isWeekend) { + return `${label} (weekend)`; + } + + return label; +} + +function generateDummyReadDays( + streakCount: number, + anchorDate: Date, + weekStart: number | undefined, + timezone: string | undefined, +): Set { + const result = new Set(); + let remaining = streakCount; + let cursor = anchorDate; + + while (remaining > 0) { + if (!isWeekendDay(cursor, weekStart, timezone)) { + result.add(cursor.toDateString()); + remaining -= 1; + } + cursor = subDays(cursor, 1); + } + + return result; +} + +export function StreakMonthCalendar({ + history, + weekStart, + timezone, + currentStreak, + streakOverride, + trailing, +}: StreakMonthCalendarProps): ReactElement { + const baseToday = useMemo(() => new Date(), []); + const [hoveredDay, setHoveredDay] = useState(null); + const effectiveStreak = streakOverride ?? currentStreak; + const hasPositiveStreak = (effectiveStreak ?? 0) > 0; + const activeStreak = Math.max(streakOverride ?? currentStreak ?? 1, 1); + const initialStreakRef = useRef(activeStreak); + const streakDelta = activeStreak - initialStreakRef.current; + const today = useMemo( + () => addDays(baseToday, streakDelta), + [baseToday, streakDelta], + ); + const todayAbsoluteIndex = activeStreak - 1; + const startRowIndex = Math.max(0, Math.floor(todayAbsoluteIndex / 10) - 1); + const startAbsoluteIndex = startRowIndex * 10; + + const days = useMemo( + () => + Array.from({ length: 30 }, (_, index) => { + const absoluteIndex = startAbsoluteIndex + index; + return subDays(today, todayAbsoluteIndex - absoluteIndex); + }), + [startAbsoluteIndex, today, todayAbsoluteIndex], + ); + + const readDaysSet = useMemo(() => { + if (streakOverride !== undefined) { + return generateDummyReadDays(streakOverride, today, weekStart, timezone); + } + + if (currentStreak !== undefined) { + return generateDummyReadDays(currentStreak, today, weekStart, timezone); + } + + if (!history) { + return new Set(); + } + + return new Set( + history + .filter((d) => d.reads > 0) + .map((d) => new Date(d.date).toDateString()), + ); + }, [currentStreak, history, streakOverride, today, weekStart, timezone]); + + const todayLabel = formatDayLabel( + today, + today, + isWeekendDay(today, weekStart, timezone), + ); + const displayLabel = hoveredDay + ? formatDayLabel( + hoveredDay, + today, + isWeekendDay(hoveredDay, weekStart, timezone), + ) + : todayLabel; + const isHoveredWeekend = + !!hoveredDay && isWeekendDay(hoveredDay, weekStart, timezone); + + const [visible, setVisible] = useState(true); + const pendingLabel = useRef(displayLabel); + const [renderedLabel, setRenderedLabel] = useState(displayLabel); + const [windowDays, setWindowDays] = useState(days); + const [elevatorTrackDays, setElevatorTrackDays] = useState( + null, + ); + const [isElevatorAnimating, setIsElevatorAnimating] = useState(false); + const [trackOffsetPx, setTrackOffsetPx] = useState(0); + const [windowHeightPx, setWindowHeightPx] = useState(null); + const previousStartRowIndex = useRef(startRowIndex); + const gridContainerRef = useRef(null); + + useEffect(() => { + if (displayLabel === renderedLabel) { + setVisible(true); + return undefined; + } + + if (!hoveredDay) { + setRenderedLabel(displayLabel); + setVisible(true); + return undefined; + } + + pendingLabel.current = displayLabel; + setVisible(false); + + const timer = setTimeout(() => { + setRenderedLabel(pendingLabel.current); + setVisible(true); + }, 100); + + return () => clearTimeout(timer); + }, [displayLabel, renderedLabel, hoveredDay]); + + useEffect(() => { + if (!gridContainerRef.current || elevatorTrackDays) { + return undefined; + } + + setWindowHeightPx(gridContainerRef.current.getBoundingClientRect().height); + return undefined; + }, [elevatorTrackDays, windowDays]); + + useEffect(() => { + if (previousStartRowIndex.current === startRowIndex) { + setWindowDays(days); + previousStartRowIndex.current = startRowIndex; + return undefined; + } + + const rowShift = startRowIndex - previousStartRowIndex.current; + const isSingleRowShift = Math.abs(rowShift) === 1; + const gridElement = gridContainerRef.current; + + if (!isSingleRowShift || !gridElement) { + setWindowDays(days); + previousStartRowIndex.current = startRowIndex; + return undefined; + } + + const { height: currentWindowHeight } = gridElement.getBoundingClientRect(); + const styles = window.getComputedStyle(gridElement); + const rowGap = parseFloat(styles.rowGap || styles.gap || '0') || 0; + const rowStep = (currentWindowHeight + rowGap) / 3; + + if (!Number.isFinite(rowStep) || rowStep <= 0) { + setWindowDays(days); + previousStartRowIndex.current = startRowIndex; + return undefined; + } + + const forwardIncomingRow = days.slice(20, 30); + const backwardIncomingRow = days.slice(0, 10); + const nextTrackDays = + rowShift > 0 + ? [...windowDays, ...forwardIncomingRow] + : [...backwardIncomingRow, ...windowDays]; + const initialOffset = rowShift > 0 ? 0 : -rowStep; + const targetOffset = rowShift > 0 ? -rowStep : 0; + + setWindowHeightPx(currentWindowHeight); + setElevatorTrackDays(nextTrackDays); + setTrackOffsetPx(initialOffset); + setIsElevatorAnimating(false); + + const animationFrame = requestAnimationFrame(() => { + setIsElevatorAnimating(true); + setTrackOffsetPx(targetOffset); + }); + const animationTimer = setTimeout(() => { + setWindowDays(days); + setElevatorTrackDays(null); + setTrackOffsetPx(0); + setIsElevatorAnimating(false); + previousStartRowIndex.current = startRowIndex; + }, 240); + + return () => { + cancelAnimationFrame(animationFrame); + clearTimeout(animationTimer); + }; + }, [days, startRowIndex, windowDays]); + + const renderDayCircle = ( + day: Date, + index: number, + keyPrefix: string, + isInteractive: boolean, + ): ReactElement => { + const isToday = isSameDay(day, today); + const isFreeze = isWeekendDay(day, weekStart, timezone); + const isRead = + !isFreeze && + readDaysSet.has(day.toDateString()) && + (!isToday || hasPositiveStreak); + + return ( +
setHoveredDay(day) : undefined} + onMouseLeave={isInteractive ? () => setHoveredDay(null) : undefined} + onClick={isInteractive ? () => setHoveredDay(day) : undefined} + onKeyDown={ + isInteractive + ? (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setHoveredDay(day); + } + } + : undefined + } + role="button" + tabIndex={0} + > + {isToday && !isFreeze && ( + + )} + {isRead && ( + + )} +
+ ); + }; + + return ( +
+
+ + {renderedLabel} + + {!isHoveredWeekend && trailing} +
+
+ {elevatorTrackDays ? ( +
+
+ {elevatorTrackDays.map((day, index) => + renderDayCircle(day, index, 'elevator', false), + )} +
+
+ ) : ( +
+ {windowDays.map((day, index) => + renderDayCircle(day, index, 'window', true), + )} +
+ )} +
+
+ ); +} diff --git a/packages/shared/src/components/streak/popup/StreakSection.tsx b/packages/shared/src/components/streak/popup/StreakSection.tsx index bf45c7cb1e..bc9fb662d6 100644 --- a/packages/shared/src/components/streak/popup/StreakSection.tsx +++ b/packages/shared/src/components/streak/popup/StreakSection.tsx @@ -4,16 +4,28 @@ import React from 'react'; interface StreakSectionProps { streak: number; label: string; + isPrimary?: boolean; } export function StreakSection({ streak, label, + isPrimary, }: StreakSectionProps): ReactElement { + const displayLabel = isPrimary ? `Current streak ยท ${label}` : label; + return ( - {streak} -

{label}

+ + {streak} + +

{displayLabel}

); } diff --git a/packages/shared/src/components/streak/popup/icons/blaze.png b/packages/shared/src/components/streak/popup/icons/blaze.png new file mode 100644 index 0000000000..ceb54c8165 Binary files /dev/null and b/packages/shared/src/components/streak/popup/icons/blaze.png differ diff --git a/packages/shared/src/components/streak/popup/icons/cursor-ai-dark.png b/packages/shared/src/components/streak/popup/icons/cursor-ai-dark.png new file mode 100644 index 0000000000..c27de9ba4c Binary files /dev/null and b/packages/shared/src/components/streak/popup/icons/cursor-ai-dark.png differ diff --git a/packages/shared/src/components/streak/popup/icons/cursor-ai-dark.svg b/packages/shared/src/components/streak/popup/icons/cursor-ai-dark.svg new file mode 100644 index 0000000000..10d50ca847 --- /dev/null +++ b/packages/shared/src/components/streak/popup/icons/cursor-ai-dark.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/shared/src/components/streak/popup/icons/cursor-ai-light.png b/packages/shared/src/components/streak/popup/icons/cursor-ai-light.png new file mode 100644 index 0000000000..9ec9012639 Binary files /dev/null and b/packages/shared/src/components/streak/popup/icons/cursor-ai-light.png differ diff --git a/packages/shared/src/components/streak/popup/icons/cursor-ai-light.svg b/packages/shared/src/components/streak/popup/icons/cursor-ai-light.svg new file mode 100644 index 0000000000..635d3ccdc6 --- /dev/null +++ b/packages/shared/src/components/streak/popup/icons/cursor-ai-light.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/shared/src/components/streak/popup/icons/ember.png b/packages/shared/src/components/streak/popup/icons/ember.png new file mode 100644 index 0000000000..8aaa254f61 Binary files /dev/null and b/packages/shared/src/components/streak/popup/icons/ember.png differ diff --git a/packages/shared/src/components/streak/popup/icons/eternal-flame.png b/packages/shared/src/components/streak/popup/icons/eternal-flame.png new file mode 100644 index 0000000000..f15348e2dc Binary files /dev/null and b/packages/shared/src/components/streak/popup/icons/eternal-flame.png differ diff --git a/packages/shared/src/components/streak/popup/icons/firestorm.png b/packages/shared/src/components/streak/popup/icons/firestorm.png new file mode 100644 index 0000000000..384acbc459 Binary files /dev/null and b/packages/shared/src/components/streak/popup/icons/firestorm.png differ diff --git a/packages/shared/src/components/streak/popup/icons/flame.png b/packages/shared/src/components/streak/popup/icons/flame.png new file mode 100644 index 0000000000..e203e65faf Binary files /dev/null and b/packages/shared/src/components/streak/popup/icons/flame.png differ diff --git a/packages/shared/src/components/streak/popup/icons/godflame.png b/packages/shared/src/components/streak/popup/icons/godflame.png new file mode 100644 index 0000000000..296e93d614 Binary files /dev/null and b/packages/shared/src/components/streak/popup/icons/godflame.png differ diff --git a/packages/shared/src/components/streak/popup/icons/inferno.png b/packages/shared/src/components/streak/popup/icons/inferno.png new file mode 100644 index 0000000000..baf3c16ea4 Binary files /dev/null and b/packages/shared/src/components/streak/popup/icons/inferno.png differ diff --git a/packages/shared/src/components/streak/popup/icons/kindle.png b/packages/shared/src/components/streak/popup/icons/kindle.png new file mode 100644 index 0000000000..7a2ec9f8da Binary files /dev/null and b/packages/shared/src/components/streak/popup/icons/kindle.png differ diff --git a/packages/shared/src/components/streak/popup/icons/legendary.png b/packages/shared/src/components/streak/popup/icons/legendary.png new file mode 100644 index 0000000000..12c8100e67 Binary files /dev/null and b/packages/shared/src/components/streak/popup/icons/legendary.png differ diff --git a/packages/shared/src/components/streak/popup/icons/milestoneIcons.ts b/packages/shared/src/components/streak/popup/icons/milestoneIcons.ts new file mode 100644 index 0000000000..7b2b86550f --- /dev/null +++ b/packages/shared/src/components/streak/popup/icons/milestoneIcons.ts @@ -0,0 +1,35 @@ +import { StreakTier } from '../../../../lib/streakMilestones'; +import BlazeImage from './blaze.png'; +import EmberImage from './ember.png'; +import EternalFlameImage from './eternal-flame.png'; +import FirestormImage from './firestorm.png'; +import FlameImage from './flame.png'; +import GodflameImage from './godflame.png'; +import InfernoImage from './inferno.png'; +import KindleImage from './kindle.png'; +import LegendaryImage from './legendary.png'; +import PhoenixImage from './phoenix.png'; +import ScorcherImage from './scorcher.png'; +import SparkImage from './spark.png'; +import SupernovaImage from './supernova.png'; +import TitanImage from './titan.png'; + +const getAssetSrc = (asset: string | { src?: string }): string => + typeof asset === 'string' ? asset : asset.src || ''; + +export const MILESTONE_ICON_URLS: Record = { + [StreakTier.Ember]: getAssetSrc(EmberImage), + [StreakTier.Spark]: getAssetSrc(SparkImage), + [StreakTier.Kindle]: getAssetSrc(KindleImage), + [StreakTier.Flame]: getAssetSrc(FlameImage), + [StreakTier.Blaze]: getAssetSrc(BlazeImage), + [StreakTier.Firestorm]: getAssetSrc(FirestormImage), + [StreakTier.Inferno]: getAssetSrc(InfernoImage), + [StreakTier.Scorcher]: getAssetSrc(ScorcherImage), + [StreakTier.EternalFlame]: getAssetSrc(EternalFlameImage), + [StreakTier.Supernova]: getAssetSrc(SupernovaImage), + [StreakTier.Legendary]: getAssetSrc(LegendaryImage), + [StreakTier.Phoenix]: getAssetSrc(PhoenixImage), + [StreakTier.Titan]: getAssetSrc(TitanImage), + [StreakTier.Godflame]: getAssetSrc(GodflameImage), +}; diff --git a/packages/shared/src/components/streak/popup/icons/phoenix.png b/packages/shared/src/components/streak/popup/icons/phoenix.png new file mode 100644 index 0000000000..5fdc64d5c0 Binary files /dev/null and b/packages/shared/src/components/streak/popup/icons/phoenix.png differ diff --git a/packages/shared/src/components/streak/popup/icons/scorcher.png b/packages/shared/src/components/streak/popup/icons/scorcher.png new file mode 100644 index 0000000000..9ccea91081 Binary files /dev/null and b/packages/shared/src/components/streak/popup/icons/scorcher.png differ diff --git a/packages/shared/src/components/streak/popup/icons/spark.png b/packages/shared/src/components/streak/popup/icons/spark.png new file mode 100644 index 0000000000..db6aae9c30 Binary files /dev/null and b/packages/shared/src/components/streak/popup/icons/spark.png differ diff --git a/packages/shared/src/components/streak/popup/icons/sponsored-gift.png b/packages/shared/src/components/streak/popup/icons/sponsored-gift.png new file mode 100644 index 0000000000..7e0b758b51 Binary files /dev/null and b/packages/shared/src/components/streak/popup/icons/sponsored-gift.png differ diff --git a/packages/shared/src/components/streak/popup/icons/supernova.png b/packages/shared/src/components/streak/popup/icons/supernova.png new file mode 100644 index 0000000000..6e74115fb4 Binary files /dev/null and b/packages/shared/src/components/streak/popup/icons/supernova.png differ diff --git a/packages/shared/src/components/streak/popup/icons/titan.png b/packages/shared/src/components/streak/popup/icons/titan.png new file mode 100644 index 0000000000..3c3f9b8b7d Binary files /dev/null and b/packages/shared/src/components/streak/popup/icons/titan.png differ diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx index 3c32e63e38..9e2d676508 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx @@ -16,6 +16,7 @@ import { TypographyType, } from '../../../../components/typography/Typography'; import { + ReadingMilestonesSection, ReadingStreaksSection, ReadingTagsSection, HeatmapLegend, @@ -123,6 +124,7 @@ export function ReadingOverview({ /> )} + {!!streak && } ); } diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverviewComponents.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverviewComponents.tsx index e40c12f22e..1b2b45dddf 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverviewComponents.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverviewComponents.tsx @@ -4,6 +4,11 @@ import type { UserStreak, MostReadTag } from '../../../../graphql/users'; import { largeNumberFormat, getTagPageLink } from '../../../../lib'; import Link from '../../../../components/utilities/Link'; import { Tooltip } from '../../../../components/tooltip/Tooltip'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; import { Typography, TypographyColor, @@ -16,6 +21,13 @@ import { ActivityContainer } from '../../../../components/profile/ActivitySectio import { ClickableText } from '../../../../components/buttons/ClickableText'; import { ElementPlaceholder } from '../../../../components/ElementPlaceholder'; import { migrateUserToStreaks } from '../../../../lib/constants'; +import { STREAK_MILESTONES } from '../../../../lib/streakMilestones'; +import { MILESTONE_ICON_URLS } from '../../../../components/streak/popup/icons/milestoneIcons'; +import { useStreakDebug } from '../../../../hooks/streaks/useStreakDebug'; +import { useIsLightTheme } from '../../../../hooks/utils'; +import { ShareIcon } from '../../../../components/icons'; +import CursorAiIconDark from '../../../../components/streak/popup/icons/cursor-ai-dark.svg'; +import CursorAiIconLight from '../../../../components/streak/popup/icons/cursor-ai-light.svg'; // ReadingTagProgress component interface ReadingTagProgressProps { @@ -66,18 +78,137 @@ interface ReadingStreaksSectionProps { export const ReadingStreaksSection = ({ streak, -}: ReadingStreaksSectionProps): ReactElement => ( -
- - -
-); +}: ReadingStreaksSectionProps): ReactElement => { + return ( +
+ + +
+ ); +}; + +interface ReadingMilestonesSectionProps { + streak: UserStreak; +} + +export const ReadingMilestonesSection = ({ + streak, +}: ReadingMilestonesSectionProps): ReactElement => { + const { debugStreakOverride } = useStreakDebug(); + const isLightTheme = useIsLightTheme(); + const effectiveStreak = debugStreakOverride ?? streak?.current ?? 0; + const CursorAiIcon = isLightTheme ? CursorAiIconLight : CursorAiIconDark; + const achievedMilestones = STREAK_MILESTONES.filter( + (milestone) => effectiveStreak >= milestone.day, + ); + + return ( + <> + {achievedMilestones.length > 0 && ( + + Reading milestones + + )} +
+ {achievedMilestones.map((milestone) => { + const isUnlocked = effectiveStreak >= milestone.day; + const isCursorMilestone = milestone.day === 4; + return ( + + {isCursorMilestone ? ( + + ) : ( + {milestone.label} + )} +
+ + {milestone.label} + + + {milestone.day}-day streak + + {isUnlocked ? ( +
+ + Achieved + +
+ ) : ( + + {`${milestone.day - effectiveStreak} days away`} + + )} +
+
+ } + > + {isCursorMilestone ? ( + + + + ) : ( + {milestone.label} + )} + + ); + })} +
+ + ); +}; // ReadingTagsSection component interface ReadingTagsSectionProps { diff --git a/packages/shared/src/hooks/streaks/useStreakDebug.ts b/packages/shared/src/hooks/streaks/useStreakDebug.ts new file mode 100644 index 0000000000..bb6471c397 --- /dev/null +++ b/packages/shared/src/hooks/streaks/useStreakDebug.ts @@ -0,0 +1,203 @@ +import { useCallback, useRef, useState, useSyncExternalStore } from 'react'; +import { UrgencyLevel } from './useStreakUrgency'; +import type { StreakAnimationState } from './useStreakIncrement'; + +export interface StreakFeatureToggles { + animatedCounter: boolean; + milestoneTimeline: boolean; + urgencyNudges: boolean; +} + +export type FeedHeroVariantOverride = 'auto' | 'night' | 'morning'; + +interface UseStreakDebugReturn { + isDebugMode: boolean; + features: StreakFeatureToggles; + toggleFeature: (feature: keyof StreakFeatureToggles) => void; + isFeedHeroVisible: boolean; + setFeedHeroVisible: (value: boolean) => void; + feedHeroVariantOverride: FeedHeroVariantOverride; + setFeedHeroVariantOverride: (value: FeedHeroVariantOverride) => void; + debugUrgency: UrgencyLevel | null; + debugAnimationOverride: StreakAnimationState | null; + debugStreakOverride: number | null; + triggerIncrementAnimation: () => void; + cycleUrgency: () => void; + setDebugStreak: (value: number) => void; + resetDebug: () => void; +} + +const URGENCY_CYCLE: UrgencyLevel[] = [ + UrgencyLevel.None, + UrgencyLevel.Low, + UrgencyLevel.Medium, + UrgencyLevel.High, +]; + +let globalDebugStreak: number | null = null; +const debugStreakListeners = new Set<() => void>(); +let globalFeedHeroVisible = false; +const feedHeroListeners = new Set<() => void>(); +let globalFeedHeroVariantOverride: FeedHeroVariantOverride = 'auto'; +const feedHeroVariantListeners = new Set<() => void>(); + +const subscribeDebugStreak = (listener: () => void): (() => void) => { + debugStreakListeners.add(listener); + return () => debugStreakListeners.delete(listener); +}; + +const getDebugStreakSnapshot = (): number | null => globalDebugStreak; +const getDebugStreakServerSnapshot = (): number | null => null; +const subscribeFeedHeroVisibility = (listener: () => void): (() => void) => { + feedHeroListeners.add(listener); + + return () => feedHeroListeners.delete(listener); +}; +const getFeedHeroVisibilitySnapshot = (): boolean => globalFeedHeroVisible; +const getFeedHeroVisibilityServerSnapshot = (): boolean => false; +const subscribeFeedHeroVariant = (listener: () => void): (() => void) => { + feedHeroVariantListeners.add(listener); + + return () => feedHeroVariantListeners.delete(listener); +}; +const getFeedHeroVariantSnapshot = (): FeedHeroVariantOverride => + globalFeedHeroVariantOverride; +const getFeedHeroVariantServerSnapshot = (): FeedHeroVariantOverride => 'auto'; + +const setGlobalDebugStreak = (value: number | null): void => { + globalDebugStreak = value; + debugStreakListeners.forEach((listener) => listener()); +}; +const setGlobalFeedHeroVisible = (value: boolean): void => { + globalFeedHeroVisible = value; + feedHeroListeners.forEach((listener) => listener()); +}; +const setGlobalFeedHeroVariantOverride = ( + value: FeedHeroVariantOverride, +): void => { + globalFeedHeroVariantOverride = value; + feedHeroVariantListeners.forEach((listener) => listener()); +}; + +export const useStreakDebug = (): UseStreakDebugReturn => { + const [isDebugMode] = useState(() => { + if (typeof window === 'undefined') { + return false; + } + + return new URLSearchParams(window.location.search).has('debugStreak'); + }); + + const [features, setFeatures] = useState({ + animatedCounter: true, + milestoneTimeline: true, + urgencyNudges: true, + }); + + const [debugUrgency, setDebugUrgency] = useState(null); + const [debugAnimationOverride, setDebugAnimationOverride] = + useState(null); + + const debugStreakOverride = useSyncExternalStore( + subscribeDebugStreak, + getDebugStreakSnapshot, + getDebugStreakServerSnapshot, + ); + const isFeedHeroVisible = useSyncExternalStore( + subscribeFeedHeroVisibility, + getFeedHeroVisibilitySnapshot, + getFeedHeroVisibilityServerSnapshot, + ); + const feedHeroVariantOverride = useSyncExternalStore( + subscribeFeedHeroVariant, + getFeedHeroVariantSnapshot, + getFeedHeroVariantServerSnapshot, + ); + + const animationTimerRef = useRef>(); + + const triggerIncrementAnimation = useCallback(() => { + if (animationTimerRef.current) { + clearTimeout(animationTimerRef.current); + } + setDebugAnimationOverride('incrementing'); + animationTimerRef.current = setTimeout( + () => setDebugAnimationOverride('done'), + 3200, + ); + }, []); + + const toggleFeature = useCallback( + (feature: keyof StreakFeatureToggles) => { + setFeatures((prev) => { + const newValue = !prev[feature]; + + if (feature === 'animatedCounter' && newValue) { + triggerIncrementAnimation(); + } + + if (feature === 'urgencyNudges' && newValue) { + setDebugUrgency(UrgencyLevel.Medium); + } + + if (feature === 'urgencyNudges' && !newValue) { + setDebugUrgency(null); + } + + return { ...prev, [feature]: newValue }; + }); + }, + [triggerIncrementAnimation], + ); + + const cycleUrgency = useCallback(() => { + setDebugUrgency((prev) => { + const currentIndex = prev ? URGENCY_CYCLE.indexOf(prev) : -1; + const nextIndex = (currentIndex + 1) % URGENCY_CYCLE.length; + return URGENCY_CYCLE[nextIndex]; + }); + }, []); + + const setDebugStreak = useCallback((value: number) => { + setGlobalDebugStreak(value); + }, []); + const setFeedHeroVisible = useCallback((value: boolean) => { + setGlobalFeedHeroVisible(value); + }, []); + const setFeedHeroVariantOverride = useCallback( + (value: FeedHeroVariantOverride) => { + setGlobalFeedHeroVariantOverride(value); + }, + [], + ); + + const resetDebug = useCallback(() => { + setDebugUrgency(null); + setDebugAnimationOverride(null); + setGlobalDebugStreak(null); + setGlobalFeedHeroVisible(false); + setGlobalFeedHeroVariantOverride('auto'); + setFeatures({ + animatedCounter: true, + milestoneTimeline: true, + urgencyNudges: true, + }); + }, []); + + return { + isDebugMode, + features, + toggleFeature, + isFeedHeroVisible, + setFeedHeroVisible, + feedHeroVariantOverride, + setFeedHeroVariantOverride, + debugUrgency, + debugAnimationOverride, + debugStreakOverride, + triggerIncrementAnimation, + cycleUrgency, + setDebugStreak, + resetDebug, + }; +}; diff --git a/packages/shared/src/hooks/streaks/useStreakIncrement.ts b/packages/shared/src/hooks/streaks/useStreakIncrement.ts new file mode 100644 index 0000000000..28743cb35e --- /dev/null +++ b/packages/shared/src/hooks/streaks/useStreakIncrement.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef, useState } from 'react'; + +export type StreakAnimationState = 'idle' | 'incrementing' | 'broken' | 'done'; + +interface UseStreakIncrementReturn { + animationState: StreakAnimationState; + previousStreak: number | undefined; + resetAnimation: () => void; +} + +const ANIMATION_DURATION_MS = 3200; + +export const useStreakIncrement = ( + currentStreak: number | undefined, +): UseStreakIncrementReturn => { + const prevStreakRef = useRef(undefined); + const [animationState, setAnimationState] = + useState('idle'); + const timerRef = useRef>(); + + useEffect(() => { + if (currentStreak === undefined) { + prevStreakRef.current = currentStreak; + return; + } + + if (prevStreakRef.current !== undefined) { + let nextState: StreakAnimationState | null = null; + + if (currentStreak > prevStreakRef.current) { + nextState = 'incrementing'; + } else if (currentStreak < prevStreakRef.current) { + nextState = 'broken'; + } + + if (nextState) { + setAnimationState(nextState); + + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(() => { + setAnimationState('done'); + }, ANIMATION_DURATION_MS); + } + } + + prevStreakRef.current = currentStreak; + }, [currentStreak]); + + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, []); + + const resetAnimation = () => setAnimationState('idle'); + + return { + animationState, + previousStreak: prevStreakRef.current, + resetAnimation, + }; +}; diff --git a/packages/shared/src/hooks/streaks/useStreakUrgency.ts b/packages/shared/src/hooks/streaks/useStreakUrgency.ts new file mode 100644 index 0000000000..d896d053d9 --- /dev/null +++ b/packages/shared/src/hooks/streaks/useStreakUrgency.ts @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { getHourTimezone } from '../../lib/timezones'; + +export enum UrgencyLevel { + None = 'none', + Low = 'low', + Medium = 'medium', + High = 'high', +} + +const URGENCY_CHECK_INTERVAL_MS = 60_000; + +export const useStreakUrgency = ( + hasReadToday: boolean, + isStreaksEnabled: boolean, + currentStreak: number | undefined, +): UrgencyLevel => { + const { user } = useAuthContext(); + const [urgency, setUrgency] = useState(UrgencyLevel.None); + + useEffect(() => { + if (!isStreaksEnabled || hasReadToday || !currentStreak) { + setUrgency(UrgencyLevel.None); + return undefined; + } + + const checkUrgency = () => { + const timezone = user?.timezone; + + if (!timezone) { + setUrgency(UrgencyLevel.None); + return; + } + + const hour = getHourTimezone(timezone); + + if (hour >= 23) { + setUrgency(UrgencyLevel.High); + } else if (hour >= 22) { + setUrgency(UrgencyLevel.Medium); + } else if (hour >= 21) { + setUrgency(UrgencyLevel.Low); + } else { + setUrgency(UrgencyLevel.None); + } + }; + + checkUrgency(); + const interval = setInterval(checkUrgency, URGENCY_CHECK_INTERVAL_MS); + + return () => clearInterval(interval); + }, [hasReadToday, isStreaksEnabled, currentStreak, user?.timezone]); + + return urgency; +}; diff --git a/packages/shared/src/lib/streakMilestones.ts b/packages/shared/src/lib/streakMilestones.ts new file mode 100644 index 0000000000..812363e5f4 --- /dev/null +++ b/packages/shared/src/lib/streakMilestones.ts @@ -0,0 +1,168 @@ +export enum StreakTier { + Ember = 'ember', + Spark = 'spark', + Kindle = 'kindle', + Flame = 'flame', + Blaze = 'blaze', + Firestorm = 'firestorm', + Inferno = 'inferno', + Scorcher = 'scorcher', + EternalFlame = 'eternal_flame', + Supernova = 'supernova', + Legendary = 'legendary', + Phoenix = 'phoenix', + Titan = 'titan', + Godflame = 'godflame', +} + +export enum RewardType { + Cores = 'cores', + Cosmetic = 'cosmetic', + Perk = 'perk', +} + +export interface StreakReward { + type: RewardType; + description: string; +} + +export interface StreakMilestone { + day: number; + tier: StreakTier; + label: string; + rewards: StreakReward[]; +} + +export const STREAK_MILESTONES: StreakMilestone[] = [ + { + day: 1, + tier: StreakTier.Ember, + label: 'Ember', + rewards: [], + }, + { + day: 3, + tier: StreakTier.Spark, + label: 'Spark', + rewards: [{ type: RewardType.Cores, description: '10 Cores' }], + }, + { + day: 4, + tier: StreakTier.Spark, + label: 'Cursor AI', + rewards: [{ type: RewardType.Perk, description: '20% discount coupon' }], + }, + { + day: 5, + tier: StreakTier.Kindle, + label: 'Kindle', + rewards: [{ type: RewardType.Cores, description: '10 Cores' }], + }, + { + day: 7, + tier: StreakTier.Flame, + label: 'Flame', + rewards: [{ type: RewardType.Cosmetic, description: 'Premium flair' }], + }, + { + day: 14, + tier: StreakTier.Blaze, + label: 'Blaze', + rewards: [{ type: RewardType.Perk, description: '48H Boost post' }], + }, + { + day: 21, + tier: StreakTier.Firestorm, + label: 'Firestorm', + rewards: [{ type: RewardType.Cores, description: '50 Cores' }], + }, + { + day: 30, + tier: StreakTier.Inferno, + label: 'Inferno', + rewards: [ + { type: RewardType.Cores, description: '100 Cores' }, + { type: RewardType.Cosmetic, description: 'Exclusive profile frame' }, + ], + }, + { + day: 60, + tier: StreakTier.Scorcher, + label: 'Scorcher', + rewards: [ + { + type: RewardType.Perk, + description: 'X1 free streak restore', + }, + ], + }, + { + day: 90, + tier: StreakTier.EternalFlame, + label: 'Eternal Flame', + rewards: [{ type: RewardType.Cores, description: '250 Cores' }], + }, + { + day: 180, + tier: StreakTier.Supernova, + label: 'Supernova', + rewards: [{ type: RewardType.Perk, description: 'Verified Reader title' }], + }, + { + day: 365, + tier: StreakTier.Legendary, + label: 'Legendary', + rewards: [{ type: RewardType.Cores, description: '600 Cores' }], + }, + { + day: 730, + tier: StreakTier.Phoenix, + label: 'Phoenix', + rewards: [{ type: RewardType.Cores, description: '900 Cores' }], + }, + { + day: 1095, + tier: StreakTier.Titan, + label: 'Titan', + rewards: [{ type: RewardType.Perk, description: 'Top comment (Shotgun!)' }], + }, + { + day: 1460, + tier: StreakTier.Godflame, + label: 'Godflame', + rewards: [ + { type: RewardType.Cosmetic, description: 'Goldflame crown badge' }, + ], + }, +]; + +export const getCurrentTier = (currentStreak: number): StreakMilestone => { + const reached = STREAK_MILESTONES.filter((m) => currentStreak >= m.day); + return reached[reached.length - 1] ?? STREAK_MILESTONES[0]; +}; + +export const getNextMilestone = ( + currentStreak: number, +): StreakMilestone | null => { + return STREAK_MILESTONES.find((m) => m.day > currentStreak) ?? null; +}; + +export const getMilestoneAtDay = (day: number): StreakMilestone | null => { + return STREAK_MILESTONES.find((m) => m.day === day) ?? null; +}; + +export const getTierProgress = (currentStreak: number): number => { + const current = getCurrentTier(currentStreak); + const next = getNextMilestone(currentStreak); + + if (!next) { + return 100; + } + + const rangeStart = current.day; + const rangeEnd = next.day; + const progress = + ((currentStreak - rangeStart) / (rangeEnd - rangeStart)) * 100; + + return Math.min(Math.max(progress, 0), 100); +}; diff --git a/packages/shared/src/svg/NotificationSvg.tsx b/packages/shared/src/svg/NotificationSvg.tsx new file mode 100644 index 0000000000..eaacc13c30 --- /dev/null +++ b/packages/shared/src/svg/NotificationSvg.tsx @@ -0,0 +1,97 @@ +import type { ReactElement } from 'react'; +import React from 'react'; + +export function NotificationSvg(): ReactElement { + return ( + + + + + + + + + + + + + + {/* Dark Background */} + + {/* App Icon (glowing bacon) */} + + {/* Title line (glowing white) */} + + {/* Subtitle lines (light grey) */} + + + {/* Tiny timestamp / meta */} + + + + ); +} diff --git a/packages/shared/tailwind.config.ts b/packages/shared/tailwind.config.ts index 89a82ff1fc..394909dc56 100644 --- a/packages/shared/tailwind.config.ts +++ b/packages/shared/tailwind.config.ts @@ -252,12 +252,117 @@ export default { backgroundColor: 'transparent', }, }, + 'streak-bounce': { + '0%': { transform: 'scale(1)' }, + '25%': { transform: 'scale(1.18)' }, + '50%': { transform: 'scale(1.08)' }, + '75%': { transform: 'scale(1.12)' }, + '100%': { transform: 'scale(1)' }, + }, + 'streak-expand': { + '0%': { transform: 'scale(1)', boxShadow: 'none' }, + '20%': { + transform: 'scale(1.25)', + boxShadow: + '0 0 20px 4px color-mix(in srgb, currentColor, transparent 60%)', + }, + '60%': { + transform: 'scale(1.25)', + boxShadow: + '0 0 20px 4px color-mix(in srgb, currentColor, transparent 60%)', + }, + '100%': { + transform: 'scale(1)', + boxShadow: + '0 0 0 0 color-mix(in srgb, currentColor, transparent 100%)', + }, + }, + 'streak-shine': { + '0%': { left: '-100%' }, + '50%': { left: '100%' }, + '100%': { left: '100%' }, + }, + 'streak-plus-one': { + '0%': { opacity: '0', transform: 'translateY(0) scale(0.5)' }, + '20%': { opacity: '1', transform: 'translateY(-8px) scale(1)' }, + '70%': { opacity: '1', transform: 'translateY(-12px) scale(1)' }, + '100%': { opacity: '0', transform: 'translateY(-24px) scale(0.8)' }, + }, + 'streak-shimmer': { + '0%': { backgroundPosition: '-200% 0' }, + '100%': { backgroundPosition: '200% 0' }, + }, + 'streak-digit-in': { + '0%': { transform: 'translateY(100%)', opacity: '0' }, + '100%': { transform: 'translateY(0)', opacity: '1' }, + }, + 'streak-pulse': { + '0%, 100%': { + boxShadow: + '0 0 0 0 color-mix(in srgb, currentColor, transparent 100%)', + }, + '50%': { + boxShadow: + '0 0 8px 2px color-mix(in srgb, currentColor, transparent 65%)', + }, + }, + 'milestone-glow': { + '0%, 100%': { + filter: + 'drop-shadow(0 0 0px color-mix(in srgb, currentColor, transparent 100%))', + }, + '50%': { + filter: + 'drop-shadow(0 0 6px color-mix(in srgb, currentColor, transparent 50%))', + }, + }, + 'streak-shake': { + '0%, 100%': { transform: 'translateX(0)' }, + '20%': { transform: 'translateX(-2px)' }, + '40%': { transform: 'translateX(2px)' }, + '60%': { transform: 'translateX(-1px)' }, + '80%': { transform: 'translateX(1px)' }, + }, + 'streak-day-pop': { + '0%, 100%': { transform: 'scale(1)', opacity: '1' }, + '50%': { transform: 'scale(1.35)', opacity: '0.7' }, + }, + 'fade-in-up': { + '0%': { opacity: '0', transform: 'translateY(6px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + 'sponsored-gradient-slide': { + '0%': { backgroundPosition: '0% 50%' }, + '100%': { backgroundPosition: '200% 50%' }, + }, + 'sponsored-lines-slide': { + '0%': { backgroundPosition: '0% 0%' }, + '100%': { backgroundPosition: '220% 0%' }, + }, }, animation: { 'scale-down-pulse': 'scale-down-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', 'fade-slide-up': 'fade-slide-up 0.5s ease-out 1s both', 'highlight-fade': 'highlight-fade 2.5s ease-out forwards', + 'streak-bounce': + 'streak-bounce 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards', + 'streak-expand': + 'streak-expand 1.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards', + 'streak-shine': 'streak-shine 1.6s ease-in-out forwards', + 'streak-plus-one': + 'streak-plus-one 1.4s cubic-bezier(0.4, 0, 0.2, 1) forwards', + 'streak-shimmer': 'streak-shimmer 0.8s ease-in-out forwards', + 'streak-digit-in': + 'streak-digit-in 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards', + 'streak-pulse': 'streak-pulse 3s ease-in-out infinite', + 'milestone-glow': 'milestone-glow 3s ease-in-out infinite', + 'streak-shake': 'streak-shake 0.5s ease-in-out', + 'streak-day-pop': 'streak-day-pop 2s ease-in-out infinite', + 'fade-in-up': 'fade-in-up 0.3s ease-out', + 'sponsored-gradient-slide': + 'sponsored-gradient-slide 6s linear infinite', + 'sponsored-lines-slide': 'sponsored-lines-slide 5s linear infinite', }, }, lineClamp: { diff --git a/packages/webapp/__mocks__/fileMock.ts b/packages/webapp/__mocks__/fileMock.ts new file mode 100644 index 0000000000..564e445144 --- /dev/null +++ b/packages/webapp/__mocks__/fileMock.ts @@ -0,0 +1,3 @@ +const mockFile = 'test-file-stub'; + +export default mockFile; diff --git a/packages/webapp/jest.config.js b/packages/webapp/jest.config.js index 27a271983a..9fc389b212 100644 --- a/packages/webapp/jest.config.js +++ b/packages/webapp/jest.config.js @@ -26,6 +26,7 @@ module.exports = { moduleNameMapper: { '^node-emoji$': 'node-emoji/lib/index.cjs', '\\.svg$': '/__mocks__/svgrMock.ts', + '\\.(png|jpe?g|gif|webp|avif)$': '/__mocks__/fileMock.ts', '\\.css$': 'identity-obj-proxy', 'react-markdown': '/__mocks__/reactMarkdownMock.tsx', },