diff --git a/packages/shared/__tests__/fixture/settings.ts b/packages/shared/__tests__/fixture/settings.ts index a9f26e60d8b..2a9c82c8e69 100644 --- a/packages/shared/__tests__/fixture/settings.ts +++ b/packages/shared/__tests__/fixture/settings.ts @@ -19,8 +19,14 @@ export const createTestSettings = ( autoDismissNotifications: true, optOutCompanion: true, optOutReadingStreak: true, + optOutStreakFreeze: false, + toggleOptOutStreakFreeze: jest.fn(), optOutLevelSystem: false, optOutQuestSystem: false, + optOutAchievements: false, + isGamificationEnabled: true, + toggleOptOutAchievements: jest.fn(), + toggleAllGamification: jest.fn(), sidebarExpanded: true, companionExpanded: true, sortingEnabled: true, diff --git a/packages/shared/__tests__/helpers/boot.tsx b/packages/shared/__tests__/helpers/boot.tsx index f9b19c169aa..3238dd1faff 100644 --- a/packages/shared/__tests__/helpers/boot.tsx +++ b/packages/shared/__tests__/helpers/boot.tsx @@ -42,8 +42,13 @@ export const settingsContext: SettingsContextData = { openNewTab: true, optOutCompanion: false, optOutReadingStreak: true, + optOutStreakFreeze: false, optOutLevelSystem: false, optOutQuestSystem: false, + optOutAchievements: false, + isGamificationEnabled: true, + toggleOptOutAchievements: jest.fn(), + toggleAllGamification: jest.fn(), setSettings: jest.fn(), setSpaciness: jest.fn(), setTheme: jest.fn(), @@ -60,6 +65,7 @@ export const settingsContext: SettingsContextData = { toggleOpenNewTab: jest.fn(), toggleOptOutCompanion: jest.fn(), toggleOptOutReadingStreak: jest.fn(), + toggleOptOutStreakFreeze: jest.fn(), toggleOptOutLevelSystem: jest.fn(), toggleOptOutQuestSystem: jest.fn(), toggleShowTopSites: jest.fn(), diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index 2934626328c..56849f3a97d 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -152,6 +152,13 @@ const StreakRecoverModal = dynamic( ), ); +const StreakFreezePurchaseModal = dynamic( + () => + import( + /* webpackChunkName: "streakFreezePurchaseModal" */ './streaks/StreakFreezePurchaseModal' + ), +); + const SlackIntegrationModal = dynamic( () => import( @@ -544,6 +551,7 @@ export const modals = { [LazyModal.TopMembers]: TopMembersModal, [LazyModal.BookmarkReminder]: BookmarkReminderModal, [LazyModal.RecoverStreak]: StreakRecoverModal, + [LazyModal.StreakFreezePurchase]: StreakFreezePurchaseModal, [LazyModal.SlackIntegration]: SlackIntegrationModal, [LazyModal.ReportSource]: ReportSourceModal, [LazyModal.UserFollowersModal]: UserFollowersModal, diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index f28b066f111..66e87110334 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -44,6 +44,7 @@ export enum LazyModal { ImageView = 'imageView', NewStreak = 'newStreak', RecoverStreak = 'recoverStreak', + StreakFreezePurchase = 'streakFreezePurchase', ReputationPrivileges = 'reputationPrivileges', MarketingCta = 'marketingCta', Share = 'share', diff --git a/packages/shared/src/components/modals/streaks/NewStreakModal.tsx b/packages/shared/src/components/modals/streaks/NewStreakModal.tsx index 1f79b0784a1..44f8aa841f0 100644 --- a/packages/shared/src/components/modals/streaks/NewStreakModal.tsx +++ b/packages/shared/src/components/modals/streaks/NewStreakModal.tsx @@ -18,6 +18,7 @@ import { useAuthContext } from '../../../contexts/AuthContext'; import { useActions } from '../../../hooks'; import { ActionType } from '../../../graphql/actions'; import StreakReminderSwitch from '../../streak/StreakReminderSwitch'; +import { StreakFreezeUpsell } from '../../streak/StreakFreezeUpsell'; const Paragraph = classed('p', 'text-center text-text-tertiary'); @@ -120,6 +121,9 @@ export default function NewStreakModal({ ? 'Epic win! You are in a league of your own' : `New milestone reached! You are unstoppable.`} + + Protect your streak with streak freezes + { + mockGraphQL({ + request: { query: USER_STREAK_QUERY }, + result: { + data: { + userStreak: { + max: 5, + total: 5, + current: 5, + weekStart: DayOfWeek.Monday, + lastViewAt: new Date().toISOString(), + freezesAvailable, + }, + }, + }, + }); +}; + +const mockProductsQuery = () => { + mockGraphQL({ + request: { query: STREAK_FREEZE_PRODUCTS_QUERY }, + result: { data: { streakFreezeProducts: products } }, + }); +}; + +const mockFreezeDatesQuery = () => { + mockGraphQL({ + request: { query: USER_STREAK_FREEZE_DATES_QUERY, variables: {} }, + result: { data: { userStreakFreezeDates: [] } }, + }); +}; + +const renderComponent = ({ + balance = 1000, + freezesAvailable = 3, +}: { balance?: number; freezesAvailable?: number } = {}) => { + const client = new QueryClient(); + mockStreakQuery(freezesAvailable); + mockProductsQuery(); + mockFreezeDatesQuery(); + + return render( + + + , + ); +}; + +describe('StreakFreezePurchaseModal', () => { + it('should render the packs once loaded', async () => { + renderComponent(); + + await waitForNock(); + + expect( + await screen.findByTestId('streak-freeze-pack-p1'), + ).toHaveTextContent('1 freeze'); + expect(screen.getByTestId('streak-freeze-pack-p3')).toHaveTextContent( + '3 freezes', + ); + expect(screen.getByTestId('streak-freeze-pack-p5')).toHaveTextContent( + '5 freezes', + ); + }); + + it('should disable packs that would push the user over the cap of 5', async () => { + renderComponent({ freezesAvailable: 3 }); + + await waitForNock(); + + // 3 available + 1 = 4 (ok), + 3 = 6 (disabled), + 5 = 8 (disabled) + expect(await screen.findByTestId('streak-freeze-pack-p1')).toBeEnabled(); + expect(screen.getByTestId('streak-freeze-pack-p3')).toBeDisabled(); + expect(screen.getByTestId('streak-freeze-pack-p5')).toBeDisabled(); + }); + + it('should show "Buy Cores" when the balance cannot cover the selected pack', async () => { + renderComponent({ balance: 0, freezesAvailable: 0 }); + + await waitForNock(); + + await waitFor(() => + expect( + screen.getByTestId('streak-freeze-purchase-button'), + ).toHaveTextContent('Buy Cores100'), + ); + }); + + it('should purchase the selected pack', async () => { + renderComponent({ balance: 1000, freezesAvailable: 0 }); + + await waitForNock(); + + let mutationCalled = false; + mockGraphQL({ + request: { + query: PURCHASE_STREAK_FREEZE_MUTATION, + variables: { productId: 'p1' }, + }, + result: () => { + mutationCalled = true; + + return { + data: { + purchaseStreakFreeze: { + freezesAvailable: 1, + balance: { amount: 900 }, + transactionId: 't1', + }, + }, + }; + }, + }); + + const button = await screen.findByTestId('streak-freeze-purchase-button'); + await waitFor(() => + expect(button).toHaveTextContent('Get 1 freeze for 100'), + ); + fireEvent.click(button); + + await waitFor(() => expect(mutationCalled).toBeTruthy()); + }); +}); diff --git a/packages/shared/src/components/modals/streaks/StreakFreezePurchaseModal.tsx b/packages/shared/src/components/modals/streaks/StreakFreezePurchaseModal.tsx new file mode 100644 index 00000000000..f9d2fdea41d --- /dev/null +++ b/packages/shared/src/components/modals/streaks/StreakFreezePurchaseModal.tsx @@ -0,0 +1,235 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { ModalSize } from '../common/types'; +import type { ModalProps } from '../common/Modal'; +import { Modal } from '../common/Modal'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../typography/Typography'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { CoreIcon, ReadingStreakIcon } from '../../icons'; +import { IconSize } from '../../Icon'; +import { formatCoresCurrency } from '../../../lib/utils'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../../lib/log'; +import { useStreakFreeze } from '../../../hooks/streaks/useStreakFreeze'; +import { STREAK_FREEZE_CAP } from '../../../graphql/streakFreeze'; +import type { StreakFreezeProduct } from '../../../graphql/streakFreeze'; + +export interface StreakFreezePurchaseModalProps + extends Pick { + onRequestClose: () => void; +} + +const StreakFreezePack = ({ + product, + isSelected, + isDisabled, + onSelect, +}: { + product: StreakFreezeProduct; + isSelected: boolean; + isDisabled: boolean; + onSelect: () => void; +}): ReactElement => { + const { quantity } = product.flags; + + return ( + + ); +}; + +export const StreakFreezePurchaseModal = ( + props: StreakFreezePurchaseModalProps, +): ReactElement | null => { + const { isOpen, onRequestClose, onAfterClose } = props; + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const { + freezesAvailable, + products, + isLoadingProducts, + isPurchasing, + canBuyProduct, + onPurchase, + } = useStreakFreeze(); + const [selectedProductId, setSelectedProductId] = useState(); + + const selectedProduct = products.find( + (product) => product.id === selectedProductId, + ); + + useEffect(() => { + if (selectedProductId || !products.length) { + return; + } + + const defaultProduct = products.find((product) => canBuyProduct(product)); + + if (defaultProduct) { + setSelectedProductId(defaultProduct.id); + } + // Only run once the products list resolves, to pick an initial pack. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [products]); + + useEffect(() => { + if (isLoadingProducts) { + return; + } + + logEvent({ + event_name: LogEvent.Impression, + target_type: TargetType.StreakFreezePurchase, + target_id: 'streak freeze purchase', + }); + // Log once the modal's data has resolved, mirroring StreakRecoverModal. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoadingProducts]); + + const onClose = () => { + logEvent({ event_name: LogEvent.DismissStreakFreezePurchase }); + onRequestClose(); + }; + + if (!user) { + return null; + } + + const canAffordSelected = + !!selectedProduct && user.balance.amount >= selectedProduct.value; + + return ( + + + +
+ + + Never lose your streak again + + + Freezes auto-apply on days you miss reading, one per missed day. You + can hold up to {STREAK_FREEZE_CAP} at a time. + + + You currently have{' '} + + {freezesAvailable} freeze{freezesAvailable === 1 ? '' : 's'} + + +
+
+ {products.map((product) => ( + setSelectedProductId(product.id)} + /> + ))} +
+
+ + + +
+ ); +}; + +export default StreakFreezePurchaseModal; diff --git a/packages/shared/src/components/modals/streaks/StreakMilestonePopup.spec.tsx b/packages/shared/src/components/modals/streaks/StreakMilestonePopup.spec.tsx index 27fd261ef76..4306127bbca 100644 --- a/packages/shared/src/components/modals/streaks/StreakMilestonePopup.spec.tsx +++ b/packages/shared/src/components/modals/streaks/StreakMilestonePopup.spec.tsx @@ -34,6 +34,7 @@ const defaultStreak: ReadingStreakReturn = { total: 5, weekStart: DayOfWeek.Monday, lastViewAt: new Date(), + freezesAvailable: 0, }, updateStreakConfig: jest.fn(), checkReadingStreak: jest.fn(), @@ -117,6 +118,7 @@ it('should not open when streak is 0', async () => { total: 5, weekStart: DayOfWeek.Monday, lastViewAt: new Date(), + freezesAvailable: 0, }, }, }); diff --git a/packages/shared/src/components/modals/streaks/StreakRecoverModal.spec.tsx b/packages/shared/src/components/modals/streaks/StreakRecoverModal.spec.tsx index e74d882e9fc..b657fe4e9bc 100644 --- a/packages/shared/src/components/modals/streaks/StreakRecoverModal.spec.tsx +++ b/packages/shared/src/components/modals/streaks/StreakRecoverModal.spec.tsx @@ -140,6 +140,7 @@ beforeEach(async () => { total: 12, weekStart: DayOfWeek.Monday, lastViewAt: subDays(new Date(), 2), + freezesAvailable: 0, }, updateStreakConfig: jest.fn(), checkReadingStreak: jest.fn(), diff --git a/packages/shared/src/components/modals/streaks/StreakRecoverModal.tsx b/packages/shared/src/components/modals/streaks/StreakRecoverModal.tsx index 698f9203174..9f1c75a139d 100644 --- a/packages/shared/src/components/modals/streaks/StreakRecoverModal.tsx +++ b/packages/shared/src/components/modals/streaks/StreakRecoverModal.tsx @@ -25,6 +25,7 @@ import type { UserStreakRecoverData } from '../../../graphql/users'; import { CoreIcon } from '../../icons'; import { coresDocsLink } from '../../../lib/constants'; import { anchorDefaultRel } from '../../../lib/strings'; +import { StreakFreezeUpsell } from '../../streak/StreakFreezeUpsell'; export interface StreakRecoverModalProps extends Pick { @@ -202,6 +203,9 @@ export const StreakRecoverModal = ( recover={recover} loading={recover.isRecoverPending} /> + + Get streak freezes for next time + diff --git a/packages/shared/src/components/notifications/utils.ts b/packages/shared/src/components/notifications/utils.ts index 03ec9440348..ecc1a94c9c1 100644 --- a/packages/shared/src/components/notifications/utils.ts +++ b/packages/shared/src/components/notifications/utils.ts @@ -74,6 +74,8 @@ export enum NotificationType { PostBookmarkReminder = 'post_bookmark_reminder', StreakReminder = 'streak_reminder', StreakResetRestore = 'streak_reset_restore', + StreakFreezeUsed = 'streak_freeze_used', + StreakFreezeDepleted = 'streak_freeze_depleted', SourcePostApproved = 'source_post_approved', DevCardUnlocked = 'dev_card_unlocked', PostMention = 'post_mention', @@ -300,6 +302,8 @@ export const MENTION_KEYS = [ export const STREAK_KEYS = [ NotificationType.StreakReminder, NotificationType.StreakResetRestore, + NotificationType.StreakFreezeUsed, + NotificationType.StreakFreezeDepleted, ]; export const SQUAD_ROLE_KEYS = [ NotificationType.PromotedToAdmin, @@ -416,6 +420,8 @@ export const notificationCategoryToTypes: Record< NotificationType.DigestReady, NotificationType.StreakReminder, NotificationType.StreakResetRestore, + NotificationType.StreakFreezeUsed, + NotificationType.StreakFreezeDepleted, NotificationType.Marketing, NotificationType.Announcements, NotificationType.NewUserWelcome, @@ -644,6 +650,16 @@ export const STREAK_NOTIFICATIONS: NotificationItem[] = [ label: 'Restore broken streak', group: false, }, + { + id: NotificationType.StreakFreezeUsed, + label: 'Streak freeze used', + group: false, + }, + { + id: NotificationType.StreakFreezeDepleted, + label: 'Out of streak freezes', + group: false, + }, ]; export const CREATORS_NOTIFICATIONS: NotificationItem[] = [ diff --git a/packages/shared/src/components/streak/StreakFreezeUpsell.tsx b/packages/shared/src/components/streak/StreakFreezeUpsell.tsx new file mode 100644 index 00000000000..27458fc24cf --- /dev/null +++ b/packages/shared/src/components/streak/StreakFreezeUpsell.tsx @@ -0,0 +1,40 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { ClickableText } from '../buttons/ClickableText'; +import { LazyModal } from '../modals/common/types'; +import { useLazyModal } from '../../hooks/useLazyModal'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { featureStreakFreeze } from '../../lib/featureManagement'; +import { useHasAccessToCores } from '../../hooks/useCoresFeature'; + +interface StreakFreezeUpsellProps { + children: ReactNode; + className?: string; +} + +export function StreakFreezeUpsell({ + children, + className, +}: StreakFreezeUpsellProps): ReactElement | null { + const hasAccessToCores = useHasAccessToCores(); + const { value: isStreakFreezeEnabled } = useConditionalFeature({ + feature: featureStreakFreeze, + shouldEvaluate: hasAccessToCores, + }); + const { openModal } = useLazyModal(); + + if (!hasAccessToCores || !isStreakFreezeEnabled) { + return null; + } + + return ( + openModal({ type: LazyModal.StreakFreezePurchase })} + > + {children} + + ); +} diff --git a/packages/shared/src/components/streak/popup/DayStreak.tsx b/packages/shared/src/components/streak/popup/DayStreak.tsx index fbfb4a341fd..6677d1a0c2f 100644 --- a/packages/shared/src/components/streak/popup/DayStreak.tsx +++ b/packages/shared/src/components/streak/popup/DayStreak.tsx @@ -15,8 +15,15 @@ export enum Streak { Pending = 'pending', Upcoming = 'upcoming', Freeze = 'freeze', + UsedFreeze = 'used_freeze', } +const freezeTooltipCopy: Partial> = { + [Streak.Freeze]: + 'We auto-freeze streaks during the weekend, but you can still keep going if you want to', + [Streak.UsedFreeze]: 'A streak freeze was used this day', +}; + interface DayStreakProps { streak: Streak; date: Date; @@ -51,7 +58,8 @@ export function DayStreak({ ); } - const isStreakFreeze = streak === Streak.Freeze; + const isStreakFreeze = + streak === Streak.Freeze || streak === Streak.UsedFreeze; return ( diff --git a/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx b/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx index dc1fe8f4006..cbf09a50b3b 100644 --- a/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx +++ b/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx @@ -6,9 +6,14 @@ import classNames from 'classnames'; import { useRouter } from 'next/router'; import { StreakSection } from './StreakSection'; import { DayStreak, Streak } from './DayStreak'; +import { StreakFreezeRow } from './StreakFreezeRow'; import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; import type { ReadingDay, UserStreak } from '../../../graphql/users'; import { getReadingStreak30Days } from '../../../graphql/users'; +import { userStreakFreezeDatesQueryOptions } from '../../../graphql/streakFreeze'; +import { useHasAccessToCores } from '../../../hooks/useCoresFeature'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { featureStreakFreeze } from '../../../lib/featureManagement'; import { useAuthContext } from '../../../contexts/AuthContext'; import { useActions, useViewSize, ViewSize } from '../../../hooks'; import { ActionType } from '../../../graphql/actions'; @@ -54,14 +59,19 @@ const getStreak = ({ history, startOfWeek = DayOfWeek.Monday, timezone, + freezeDates, }: { value: Date; today: Date; history?: ReadingDay[]; startOfWeek?: number; timezone?: string; + freezeDates?: string[]; }): Streak => { const isFreezeDay = isWeekend(value, startOfWeek, timezone); + const isUsedFreezeDay = freezeDates?.some((freezeDate) => + isSameDayInTimezone(new Date(freezeDate), value, timezone), + ); const isToday = isSameDayInTimezone(value, today, timezone); const isFuture = value > today; const isCompleted = @@ -77,6 +87,12 @@ const getStreak = ({ return Streak.Completed; } + // Consumed freezes are their own auto-applied purchase, distinct from the + // always-on weekend rest day below (different tooltip copy). + if (isUsedFreezeDay) { + return Streak.UsedFreeze; + } + if (isFreezeDay) { return Streak.Freeze; } @@ -118,12 +134,22 @@ export function ReadingStreakPopup({ const { completeAction } = useActions(); const userId = user?.id; const timezone = user?.timezone ?? DEFAULT_TIMEZONE; + const hasAccessToCores = useHasAccessToCores(); + const { value: isStreakFreezeEnabled } = useConditionalFeature({ + feature: featureStreakFreeze, + shouldEvaluate: hasAccessToCores, + }); + const isStreakFreezeUiEnabled = hasAccessToCores && isStreakFreezeEnabled; const { data: history } = useQuery({ queryKey: generateQueryKey(RequestKey.ReadingStreak30Days, user), queryFn: () => getReadingStreak30Days(userId ?? ''), staleTime: StaleTime.Default, enabled: !!userId, }); + const { data: freezeDates } = useQuery({ + ...userStreakFreezeDatesQueryOptions({ user }), + enabled: !!userId && isStreakFreezeUiEnabled, + }); const isTimezoneOk = useStreakTimezoneOk(); const { showPrompt } = usePrompt(); const { logEvent } = useLogContext(); @@ -155,6 +181,7 @@ export function ReadingStreakPopup({ history, startOfWeek: streak.weekStart, timezone, + freezeDates, }); return ( @@ -166,7 +193,7 @@ export function ReadingStreakPopup({ /> ); }); - }, [history, streak.weekStart, timezone]); + }, [history, streak.weekStart, timezone, freezeDates]); const onTogglePush = async () => { logEvent({ @@ -315,6 +342,7 @@ export function ReadingStreakPopup({ + {showAlert && (
{!isSubscribed && ( diff --git a/packages/shared/src/components/streak/popup/StreakFreezeRow.tsx b/packages/shared/src/components/streak/popup/StreakFreezeRow.tsx new file mode 100644 index 00000000000..dc3160a3a5c --- /dev/null +++ b/packages/shared/src/components/streak/popup/StreakFreezeRow.tsx @@ -0,0 +1,65 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { useReadingStreak } from '../../../hooks/streaks/useReadingStreak'; +import { useStreakFreeze } from '../../../hooks/streaks/useStreakFreeze'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { featureStreakFreeze } from '../../../lib/featureManagement'; +import { useHasAccessToCores } from '../../../hooks/useCoresFeature'; +import { useLazyModal } from '../../../hooks/useLazyModal'; +import { LazyModal } from '../../modals/common/types'; +import { useAuthContext } from '../../../contexts/AuthContext'; + +export function StreakFreezeRow(): ReactElement | null { + const { isAuthReady, isLoggedIn } = useAuthContext(); + const { isStreaksEnabled } = useReadingStreak(); + const hasAccessToCores = useHasAccessToCores(); + const shouldEvaluate = isAuthReady && isLoggedIn && isStreaksEnabled; + const { value: isStreakFreezeEnabled } = useConditionalFeature({ + feature: featureStreakFreeze, + shouldEvaluate, + }); + const { openModal } = useLazyModal(); + const isEnabled = shouldEvaluate && isStreakFreezeEnabled && hasAccessToCores; + const { freezesAvailable } = useStreakFreeze({ enabled: isEnabled }); + + if (!isEnabled) { + return null; + } + + const hasFreezes = freezesAvailable > 0; + + return ( + + ); +} diff --git a/packages/shared/src/contexts/BootProvider.spec.tsx b/packages/shared/src/contexts/BootProvider.spec.tsx index f96c85ff8d5..346aaaf6be6 100644 --- a/packages/shared/src/contexts/BootProvider.spec.tsx +++ b/packages/shared/src/contexts/BootProvider.spec.tsx @@ -85,6 +85,7 @@ const defaultSettings: RemoteSettings = { companionExpanded: false, sortingEnabled: false, optOutReadingStreak: true, + optOutStreakFreeze: false, optOutAchievements: false, optOutLevelSystem: false, optOutQuestSystem: false, diff --git a/packages/shared/src/contexts/SettingsContext.tsx b/packages/shared/src/contexts/SettingsContext.tsx index d3084a3d90d..5c23acb0dc4 100644 --- a/packages/shared/src/contexts/SettingsContext.tsx +++ b/packages/shared/src/contexts/SettingsContext.tsx @@ -52,6 +52,7 @@ export interface SettingsContextData extends Omit { toggleSidebarExpanded: () => Promise; toggleSortingEnabled: () => Promise; toggleOptOutReadingStreak: () => Promise; + toggleOptOutStreakFreeze: () => Promise; toggleOptOutLevelSystem: () => Promise; toggleOptOutQuestSystem: () => Promise; toggleOptOutAchievements: () => Promise; @@ -131,6 +132,7 @@ const defaultSettings: RemoteSettings = { companionExpanded: false, sortingEnabled: false, optOutReadingStreak: false, + optOutStreakFreeze: false, optOutLevelSystem: false, optOutQuestSystem: false, optOutAchievements: false, @@ -265,6 +267,11 @@ export const SettingsContextProvider = ({ optOutReadingStreak: !settings.optOutReadingStreak, }); }, + toggleOptOutStreakFreeze: () => + setSettings({ + ...settings, + optOutStreakFreeze: !settings.optOutStreakFreeze, + }), toggleOptOutLevelSystem: () => setSettings({ ...settings, diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.spec.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.spec.tsx index 3e73967297e..10d19808b60 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.spec.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.spec.tsx @@ -150,6 +150,7 @@ const defaultReadingHistory: ProfileReadingData = { current: 5, weekStart: DayOfWeek.Sunday, lastViewAt: new Date('2024-01-15'), + freezesAvailable: 0, }, userMostReadTags: [ { value: 'javascript', count: 20 }, diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.spec.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.spec.tsx index 7d434aaed11..f157228e642 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.spec.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.spec.tsx @@ -38,6 +38,7 @@ const mockStreak: UserStreak = { current: 5, weekStart: 1, // Monday lastViewAt: new Date('2024-01-04'), + freezesAvailable: 0, }; const mockMostReadTags: MostReadTag[] = [ diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index 1f3f7b33552..6951a303daf 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -556,6 +556,7 @@ export const USER_STREAK_FRAGMENT = gql` current lastViewAt weekStart + freezesAvailable } `; diff --git a/packages/shared/src/graphql/njord.ts b/packages/shared/src/graphql/njord.ts index c229be09075..b4a8355a1dd 100644 --- a/packages/shared/src/graphql/njord.ts +++ b/packages/shared/src/graphql/njord.ts @@ -94,6 +94,7 @@ export const sayThanksForAward = async ({ export enum ProductType { Award = 'award', + StreakFreeze = 'streak_freeze', } export type Product = { diff --git a/packages/shared/src/graphql/settings.ts b/packages/shared/src/graphql/settings.ts index feef386e90a..59b253776f9 100644 --- a/packages/shared/src/graphql/settings.ts +++ b/packages/shared/src/graphql/settings.ts @@ -79,6 +79,7 @@ export type RemoteSettings = { companionExpanded: boolean; sortingEnabled: boolean; optOutReadingStreak: boolean; + optOutStreakFreeze: boolean; optOutLevelSystem: boolean; optOutQuestSystem: boolean; optOutAchievements: boolean; diff --git a/packages/shared/src/graphql/streakFreeze.ts b/packages/shared/src/graphql/streakFreeze.ts new file mode 100644 index 00000000000..5bab3529b48 --- /dev/null +++ b/packages/shared/src/graphql/streakFreeze.ts @@ -0,0 +1,102 @@ +import { gql } from 'graphql-request'; +import { gqlClient } from './common'; +import { generateQueryKey, RequestKey, StaleTime } from '../lib/query'; +import type { LoggedUser } from '../lib/user'; + +export type StreakFreezeProduct = { + id: string; + name: string; + value: number; + image: string; + flags: { + quantity: number; + description?: string; + }; +}; + +export const STREAK_FREEZE_PRODUCTS_QUERY = gql` + query StreakFreezeProducts { + streakFreezeProducts { + id + name + value + image + flags { + quantity + description + } + } + } +`; + +export const streakFreezeProductsQueryOptions = () => { + return { + queryKey: generateQueryKey(RequestKey.StreakFreezeProducts), + queryFn: async (): Promise => { + const result = await gqlClient.request<{ + streakFreezeProducts: StreakFreezeProduct[]; + }>(STREAK_FREEZE_PRODUCTS_QUERY); + + return result.streakFreezeProducts; + }, + staleTime: StaleTime.Default, + }; +}; + +export const USER_STREAK_FREEZE_DATES_QUERY = gql` + query UserStreakFreezeDates($limit: Int) { + userStreakFreezeDates(limit: $limit) + } +`; + +export const userStreakFreezeDatesQueryOptions = ({ + user, + limit, +}: { + user?: Pick; + limit?: number; +} = {}) => { + return { + queryKey: generateQueryKey(RequestKey.StreakFreezeDates, user, { limit }), + queryFn: async (): Promise => { + const result = await gqlClient.request<{ + userStreakFreezeDates: string[]; + }>(USER_STREAK_FREEZE_DATES_QUERY, { limit }); + + return result.userStreakFreezeDates; + }, + enabled: !!user?.id, + staleTime: StaleTime.Default, + }; +}; + +export type PurchaseStreakFreezeResult = { + freezesAvailable: number; + balance: LoggedUser['balance']; + transactionId: string; +}; + +export const PURCHASE_STREAK_FREEZE_MUTATION = gql` + mutation PurchaseStreakFreeze($productId: ID!) { + purchaseStreakFreeze(productId: $productId) { + freezesAvailable + balance { + amount + } + transactionId + } + } +`; + +export const purchaseStreakFreeze = async ( + productId: string, +): Promise => { + const result = await gqlClient.request<{ + purchaseStreakFreeze: PurchaseStreakFreezeResult; + }>(PURCHASE_STREAK_FREEZE_MUTATION, { productId }); + + return result.purchaseStreakFreeze; +}; + +// Locked product decision: a user can never hold more than this many freezes. +export const STREAK_FREEZE_CAP = 5; diff --git a/packages/shared/src/graphql/users.ts b/packages/shared/src/graphql/users.ts index 34d47893974..805a8f42794 100644 --- a/packages/shared/src/graphql/users.ts +++ b/packages/shared/src/graphql/users.ts @@ -613,6 +613,7 @@ export interface UserStreak { current: number; weekStart: DayOfWeek; lastViewAt: Date; + freezesAvailable: number; } export interface UserProfileAnalytics { diff --git a/packages/shared/src/hooks/streaks/useStreakFreeze.spec.tsx b/packages/shared/src/hooks/streaks/useStreakFreeze.spec.tsx new file mode 100644 index 00000000000..cc80bbd1548 --- /dev/null +++ b/packages/shared/src/hooks/streaks/useStreakFreeze.spec.tsx @@ -0,0 +1,204 @@ +import React from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/router'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { mockGraphQL } from '../../../__tests__/helpers/graphql'; +import { waitForNock } from '../../../__tests__/helpers/utilities'; +import loggedUser from '../../../__tests__/fixture/loggedUser'; +import { USER_STREAK_QUERY } from '../../graphql/users'; +import { + PURCHASE_STREAK_FREEZE_MUTATION, + STREAK_FREEZE_PRODUCTS_QUERY, + USER_STREAK_FREEZE_DATES_QUERY, +} from '../../graphql/streakFreeze'; +import type { StreakFreezeProduct } from '../../graphql/streakFreeze'; +import { useStreakFreeze } from './useStreakFreeze'; +import { DayOfWeek } from '../../lib/date'; + +const products: StreakFreezeProduct[] = [ + { + id: 'p1', + name: '1 pack', + value: 100, + image: 'img1', + flags: { quantity: 1 }, + }, + { + id: 'p3', + name: '3 pack', + value: 250, + image: 'img3', + flags: { quantity: 3 }, + }, + { + id: 'p5', + name: '5 pack', + value: 400, + image: 'img5', + flags: { quantity: 5 }, + }, +]; + +const mockStreakQuery = (freezesAvailable = 0) => { + mockGraphQL({ + request: { query: USER_STREAK_QUERY }, + result: { + data: { + userStreak: { + max: 5, + total: 5, + current: 5, + weekStart: DayOfWeek.Monday, + lastViewAt: new Date().toISOString(), + freezesAvailable, + }, + }, + }, + }); +}; + +const mockProductsQuery = () => { + mockGraphQL({ + request: { query: STREAK_FREEZE_PRODUCTS_QUERY }, + result: { data: { streakFreezeProducts: products } }, + }); +}; + +const mockFreezeDatesQuery = (dates: string[] = []) => { + mockGraphQL({ + request: { query: USER_STREAK_FREEZE_DATES_QUERY, variables: {} }, + result: { data: { userStreakFreezeDates: dates } }, + }); +}; + +const renderStreakFreezeHook = (client: QueryClient, balance = 1000) => { + const auth = { user: { ...loggedUser, balance: { amount: balance } } }; + + return renderHook(() => useStreakFreeze(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); +}; + +describe('useStreakFreeze', () => { + let client: QueryClient; + + beforeEach(() => { + client = new QueryClient(); + }); + + it('should expose freezesAvailable from the user streak', async () => { + mockStreakQuery(3); + mockProductsQuery(); + mockFreezeDatesQuery(); + + const { result } = renderStreakFreezeHook(client); + + await waitFor(() => expect(result.current.freezesAvailable).toBe(3)); + }); + + it('should fetch streak freeze products', async () => { + mockStreakQuery(); + mockProductsQuery(); + mockFreezeDatesQuery(); + + const { result } = renderStreakFreezeHook(client); + + await waitFor(() => expect(result.current.products).toHaveLength(3)); + expect(result.current.products.map((product) => product.id)).toEqual([ + 'p1', + 'p3', + 'p5', + ]); + }); + + it('should fetch consumed freeze dates', async () => { + mockStreakQuery(); + mockProductsQuery(); + mockFreezeDatesQuery(['2024-01-01T00:00:00.000Z']); + + const { result } = renderStreakFreezeHook(client); + + await waitFor(() => + expect(result.current.freezeDates).toEqual(['2024-01-01T00:00:00.000Z']), + ); + }); + + it('should disable a product that would push the user over the cap of 5', async () => { + mockStreakQuery(3); + mockProductsQuery(); + mockFreezeDatesQuery(); + + const { result } = renderStreakFreezeHook(client); + + await waitFor(() => expect(result.current.freezesAvailable).toBe(3)); + + // 3 + 3 = 6 > 5 + expect(result.current.canBuyProduct(products[1])).toBeFalsy(); + // 3 + 1 = 4 <= 5 + expect(result.current.canBuyProduct(products[0])).toBeTruthy(); + }); + + it('should redirect to the cores page when the balance cannot cover the product', async () => { + mockStreakQuery(); + mockProductsQuery(); + mockFreezeDatesQuery(); + + const push = jest.fn(); + jest.mocked(useRouter).mockReturnValue({ + query: {}, + push, + pathname: '/my-feed', + } as unknown as ReturnType); + + const { result } = renderStreakFreezeHook(client, 0); + + await waitFor(() => expect(result.current.products).toHaveLength(3)); + + await result.current.onPurchase(products[0]); + + expect(push).toHaveBeenCalledWith( + expect.stringContaining('cores?origin=streak+freeze'), + ); + }); + + it('should purchase a product and update freezesAvailable on success', async () => { + mockStreakQuery(1); + mockProductsQuery(); + mockFreezeDatesQuery(); + + const { result } = renderStreakFreezeHook(client, 1000); + + await waitFor(() => expect(result.current.products).toHaveLength(3)); + + let mutationCalled = false; + mockGraphQL({ + request: { + query: PURCHASE_STREAK_FREEZE_MUTATION, + variables: { productId: 'p1' }, + }, + result: () => { + mutationCalled = true; + + return { + data: { + purchaseStreakFreeze: { + freezesAvailable: 2, + balance: { amount: 900 }, + transactionId: 't1', + }, + }, + }; + }, + }); + + await result.current.onPurchase(products[0]); + await waitForNock(); + + expect(mutationCalled).toBeTruthy(); + }); +}); diff --git a/packages/shared/src/hooks/streaks/useStreakFreeze.ts b/packages/shared/src/hooks/streaks/useStreakFreeze.ts new file mode 100644 index 00000000000..c16126484a4 --- /dev/null +++ b/packages/shared/src/hooks/streaks/useStreakFreeze.ts @@ -0,0 +1,134 @@ +import { useCallback } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/router'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useLogContext } from '../../contexts/LogContext'; +import { useToastNotification } from '../useToastNotification'; +import { useTransactionError } from '../useTransactionError'; +import { generateQueryKey, RequestKey } from '../../lib/query'; +import { LogEvent, Origin } from '../../lib/log'; +import { getPathnameWithQuery } from '../../lib/links'; +import { webappUrl } from '../../lib/constants'; +import { isExtension } from '../../lib/func'; +import { useReadingStreak } from './useReadingStreak'; +import type { StreakFreezeProduct } from '../../graphql/streakFreeze'; +import { + purchaseStreakFreeze, + STREAK_FREEZE_CAP, + streakFreezeProductsQueryOptions, + userStreakFreezeDatesQueryOptions, +} from '../../graphql/streakFreeze'; + +export interface UseStreakFreezeReturn { + freezesAvailable: number; + products: StreakFreezeProduct[]; + isLoadingProducts: boolean; + freezeDates: string[]; + isPurchasing: boolean; + canBuyProduct: (product: StreakFreezeProduct) => boolean; + onPurchase: (product: StreakFreezeProduct) => Promise; +} + +export { STREAK_FREEZE_CAP }; + +interface UseStreakFreezeProps { + // Skip the products/freeze-dates network calls when the caller hasn't + // confirmed the feature flag + cores access gates yet (e.g. a popover row + // that renders regardless of the feature being enabled for this user). + enabled?: boolean; +} + +export const useStreakFreeze = ({ + enabled = true, +}: UseStreakFreezeProps = {}): UseStreakFreezeReturn => { + const { user, updateUser } = useAuthContext(); + const { streak } = useReadingStreak(); + const { logEvent } = useLogContext(); + const { displayToast } = useToastNotification(); + const onTransactionError = useTransactionError(); + const queryClient = useQueryClient(); + const router = useRouter(); + + const { data: products, isPending: isLoadingProducts } = useQuery({ + ...streakFreezeProductsQueryOptions(), + enabled, + }); + + const { data: freezeDates } = useQuery({ + ...userStreakFreezeDatesQueryOptions({ user }), + enabled: enabled && !!user?.id, + }); + + const purchaseMutation = useMutation({ + mutationKey: generateQueryKey(RequestKey.StreakFreezePurchase, user), + mutationFn: (productId: string) => purchaseStreakFreeze(productId), + onSuccess: (data) => { + logEvent({ event_name: LogEvent.PurchaseStreakFreeze }); + + queryClient.invalidateQueries({ + queryKey: generateQueryKey(RequestKey.UserStreak, user), + }); + queryClient.invalidateQueries({ + queryKey: generateQueryKey(RequestKey.Transactions, user), + exact: false, + }); + + if (data.balance && user) { + updateUser({ ...user, balance: data.balance }); + } + + displayToast('Nice! Your streak freezes have been added.'); + }, + onError: onTransactionError, + }); + + const freezesAvailable = streak?.freezesAvailable ?? 0; + + const canBuyProduct = useCallback( + (product: StreakFreezeProduct) => + freezesAvailable + product.flags.quantity <= STREAK_FREEZE_CAP, + [freezesAvailable], + ); + + const onPurchase = useCallback( + async (product: StreakFreezeProduct) => { + if (!user) { + return; + } + + logEvent({ + event_name: LogEvent.ClickStreakFreezePurchase, + target_id: product.id, + extra: JSON.stringify({ quantity: product.flags.quantity }), + }); + + if (user.balance.amount < product.value) { + const searchParams = new URLSearchParams(); + searchParams.set('origin', Origin.StreakFreeze); + + if (!isExtension) { + searchParams.set('next', router.pathname); + } + + router?.push(getPathnameWithQuery(`${webappUrl}cores`, searchParams)); + + return; + } + + await purchaseMutation.mutateAsync(product.id); + }, + [logEvent, purchaseMutation, router, user], + ); + + return { + freezesAvailable, + products: products ?? [], + isLoadingProducts, + freezeDates: freezeDates ?? [], + isPurchasing: purchaseMutation.isPending, + canBuyProduct, + onPurchase, + }; +}; + +export default useStreakFreeze; diff --git a/packages/shared/src/hooks/streaks/useStreakRecover.ts b/packages/shared/src/hooks/streaks/useStreakRecover.ts index 867dcb14f31..74313ed4b10 100644 --- a/packages/shared/src/hooks/streaks/useStreakRecover.ts +++ b/packages/shared/src/hooks/streaks/useStreakRecover.ts @@ -27,6 +27,11 @@ import { webappUrl } from '../../lib/constants'; import { UserTransactionStatus } from '../../graphql/njord'; import type { LoggedUser } from '../../lib/user'; import { isExtension } from '../../lib/func'; +import { useLazyModal } from '../useLazyModal'; +import { LazyModal } from '../../components/modals/common/types'; +import { useConditionalFeature } from '../useConditionalFeature'; +import { featureStreakFreeze } from '../../lib/featureManagement'; +import { useHasAccessToCores } from '../useCoresFeature'; interface UseStreakRecoverProps { onAfterClose?: () => void; @@ -63,7 +68,13 @@ export const useStreakRecover = ({ const { logEvent } = useLogContext(); const { updateAlerts } = useAlertsContext(); const { user, updateUser } = useAuthContext(); - const { isStreaksEnabled } = useReadingStreak(); + const { streak, isStreaksEnabled } = useReadingStreak(); + const { openModal } = useLazyModal(); + const hasAccessToCores = useHasAccessToCores(); + const { value: isStreakFreezeEnabled } = useConditionalFeature({ + feature: featureStreakFreeze, + shouldEvaluate: hasAccessToCores && isStreaksEnabled, + }); const client = useQueryClient(); const router = useRouter(); const { @@ -91,7 +102,7 @@ export const useStreakRecover = ({ exact: false, }); - if (data.balance) { + if (data.balance && user) { updateUser({ ...user, balance: data.balance, @@ -109,7 +120,8 @@ export const useStreakRecover = ({ if ( errorExtensions.extensions.status === UserTransactionStatus.InsufficientFunds && - errorExtensions.extensions.balance + errorExtensions.extensions.balance && + user ) { await updateUser({ ...user, @@ -121,7 +133,7 @@ export const useStreakRecover = ({ }); const hideRemoteAlert = useCallback(async () => { - await updateAlerts({ + await updateAlerts?.({ showRecoverStreak: false, }); }, [updateAlerts]); @@ -166,6 +178,10 @@ export const useStreakRecover = ({ ]); const onRecover = useCallback(async () => { + if (!user || !data) { + return; + } + try { if (user.balance.amount < data.streakRecover.cost) { const searchParams = new URLSearchParams(); @@ -182,6 +198,17 @@ export const useStreakRecover = ({ await recoverMutation.mutateAsync(); displayToast('Lucky you! Your streak has been restored'); + + // Restoring costs Cores; a freeze would have prevented the break, so + // prompt users with an empty freeze stash right after the save. + const shouldPromptFreezes = + hasAccessToCores && + isStreakFreezeEnabled && + (streak?.freezesAvailable ?? 0) === 0; + + if (shouldPromptFreezes) { + openModal({ type: LazyModal.StreakFreezePurchase }); + } } catch (e) { displayToast( 'Oops! We are unable to recover your streak. Could you try again later?', @@ -190,12 +217,16 @@ export const useStreakRecover = ({ await onClose?.(); }, [ - data?.streakRecover?.cost, + data, displayToast, + hasAccessToCores, + isStreakFreezeEnabled, onClose, + openModal, recoverMutation, router, - user?.balance?.amount, + streak?.freezesAvailable, + user, ]); useEffect(() => { @@ -219,6 +250,9 @@ export const useStreakRecover = ({ onRecover, recover: { ...data?.streakRecover, + canRecover: data?.streakRecover?.canRecover ?? false, + cost: data?.streakRecover?.cost ?? 0, + oldStreakLength: data?.streakRecover?.oldStreakLength ?? 0, isLoading, isRecoverPending: recoverMutation.isPending, }, diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index e30adb28f5f..3c27c7f795b 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -103,6 +103,9 @@ export { feature }; export const featureCores = new Feature('cores', isDevelopment); +// automated streak freeze: auto-apply purchased freezes on missed reading days +export const featureStreakFreeze = new Feature('streak_freeze', isDevelopment); + // whether the user will see post boost ads // does not necessarily mean they can't boost a post if they have access to cores export const featurePostBoostAds = new Feature('post_boost_ads', isDevelopment); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 4d37a4b92b2..3946fec1735 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -79,6 +79,7 @@ export enum Origin { // End Credits ProfileMenu = 'profile menu', StreakRecover = 'streak recover', + StreakFreeze = 'streak freeze', BriefModal = 'brief modal', BriefPage = 'brief page', SquadBoost = 'squad boost', @@ -245,6 +246,9 @@ export enum LogEvent { StreakRecover = 'restore streak', DismissStreakRecover = 'dimiss streaks milestone', StreakTimezoneMismatch = 'streak timezone mismatch', + ClickStreakFreezePurchase = 'click streak freeze purchase', + PurchaseStreakFreeze = 'purchase streak freeze', + DismissStreakFreezePurchase = 'dismiss streak freeze purchase', // 404 page View404Page = '404 page', // Follow Actions - start @@ -550,6 +554,7 @@ export enum TargetType { ResendVerificationCode = 'resend verification code', StreaksMilestone = 'streaks milestone', StreakRecover = 'streak restore', + StreakFreezePurchase = 'streak freeze purchase', PromotionCard = 'promotion_card', PromotionalBanner = 'promotion_banner', MarketingCtaPopover = 'promotion_popover', diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index 27e40434570..8c54cb24577 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -141,6 +141,9 @@ export enum RequestKey { ReadingStreak30Days = 'reading_streak_30_days', UserStreak = 'user_streak', UserStreakRecover = 'user_streak_recover', + StreakFreezeProducts = 'streak_freeze_products', + StreakFreezeDates = 'streak_freeze_dates', + StreakFreezePurchase = 'streak_freeze_purchase', PersonalizedDigest = 'personalizedDigest', Changelog = 'changelog', Tags = 'tags', diff --git a/packages/shared/src/lib/transaction.tsx b/packages/shared/src/lib/transaction.tsx index 7e5edb0b0ea..36e9e48077a 100644 --- a/packages/shared/src/lib/transaction.tsx +++ b/packages/shared/src/lib/transaction.tsx @@ -1,7 +1,11 @@ import React from 'react'; import type { ReactNode } from 'react'; import type { UserTransaction } from '../graphql/njord'; -import { UserTransactionStatus, UserTransactionType } from '../graphql/njord'; +import { + ProductType, + UserTransactionStatus, + UserTransactionType, +} from '../graphql/njord'; import type { LoggedUser } from './user'; import { Image } from '../components/image/Image'; @@ -83,7 +87,9 @@ export const getTransactionLabel = ({ alt={transaction.product.name} className="size-5" /> - Award{' '} + {transaction.product.type === ProductType.StreakFreeze + ? transaction.product.name + : 'Award'}{' '}
); } diff --git a/packages/storybook/mock/boot.ts b/packages/storybook/mock/boot.ts index b44bf66ab28..be987efabb5 100644 --- a/packages/storybook/mock/boot.ts +++ b/packages/storybook/mock/boot.ts @@ -24,8 +24,10 @@ const defaultSettings: RemoteSettings = { companionExpanded: false, sortingEnabled: false, optOutReadingStreak: true, + optOutStreakFreeze: false, optOutLevelSystem: false, optOutQuestSystem: false, + optOutAchievements: false, optOutCompanion: true, autoDismissNotifications: true, customLinks: [ diff --git a/packages/storybook/stories/features/profile/ReadingOverview.stories.tsx b/packages/storybook/stories/features/profile/ReadingOverview.stories.tsx index bf32fa9135e..a4de92da575 100644 --- a/packages/storybook/stories/features/profile/ReadingOverview.stories.tsx +++ b/packages/storybook/stories/features/profile/ReadingOverview.stories.tsx @@ -90,6 +90,7 @@ const defaultStreak: UserStreak = { current: 8, weekStart: 1, lastViewAt: new Date(), + freezesAvailable: 0, }; const defaultMostReadTags: MostReadTag[] = [ diff --git a/packages/webapp/__tests__/TagPage.tsx b/packages/webapp/__tests__/TagPage.tsx index 1cd77c30559..1b5335a865e 100644 --- a/packages/webapp/__tests__/TagPage.tsx +++ b/packages/webapp/__tests__/TagPage.tsx @@ -172,6 +172,8 @@ const renderComponent = ( companionExpanded: false, sortingEnabled: false, optOutReadingStreak: false, + optOutStreakFreeze: false, + toggleOptOutStreakFreeze: jest.fn().mockResolvedValue(undefined), optOutLevelSystem: false, optOutQuestSystem: false, optOutAchievements: false, diff --git a/packages/webapp/pages/settings/customization/streaks.tsx b/packages/webapp/pages/settings/customization/streaks.tsx index b0a73ee30d1..ba8048364b8 100644 --- a/packages/webapp/pages/settings/customization/streaks.tsx +++ b/packages/webapp/pages/settings/customization/streaks.tsx @@ -15,6 +15,16 @@ import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { TimezoneDropdown } from '@dailydotdev/shared/src/components/widgets/TimezoneDropdown'; import { getUserInitialTimezone } from '@dailydotdev/shared/src/lib/timezones'; import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { + Button, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { useLazyModal } from '@dailydotdev/shared/src/hooks/useLazyModal'; +import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/types'; +import { useStreakFreeze } from '@dailydotdev/shared/src/hooks/streaks/useStreakFreeze'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; +import { featureStreakFreeze } from '@dailydotdev/shared/src/lib/featureManagement'; +import { useHasAccessToCores } from '@dailydotdev/shared/src/hooks/useCoresFeature'; import { AccountPageContainer } from '../../../components/layouts/SettingsLayout/AccountPageContainer'; import { getSettingsLayout } from '../../../components/layouts/SettingsLayout'; import { defaultSeo, noindexSeoProps } from '../../../next-seo'; @@ -23,8 +33,22 @@ import { SettingsSwitch } from '../../../components/layouts/SettingsLayout/commo const AccountManageSubscriptionPage = (): ReactElement => { const { user } = useAuthContext(); - const { optOutReadingStreak, toggleOptOutReadingStreak } = - useSettingsContext(); + const { + optOutReadingStreak, + toggleOptOutReadingStreak, + optOutStreakFreeze, + toggleOptOutStreakFreeze, + } = useSettingsContext(); + const { openModal } = useLazyModal(); + const hasAccessToCores = useHasAccessToCores(); + const { value: isStreakFreezeEnabled } = useConditionalFeature({ + feature: featureStreakFreeze, + shouldEvaluate: hasAccessToCores, + }); + const showStreakFreezeSection = hasAccessToCores && isStreakFreezeEnabled; + const { freezesAvailable } = useStreakFreeze({ + enabled: showStreakFreezeSection, + }); const [userTimeZone, setUserTimeZone] = useState( getUserInitialTimezone({ @@ -51,6 +75,48 @@ const AccountManageSubscriptionPage = (): ReactElement => { + {showStreakFreezeSection && ( +
+
+ + Streak freezes + + + Freezes automatically cover a missed reading day so your streak + survives. Buy packs with Cores below. + +
+ +
+ + {freezesAvailable} freeze{freezesAvailable === 1 ? '' : 's'}{' '} + available + + +
+ + + Automatically use streak freezes to cover missed reading days. + Turning this off means a missed day resets your streak instead + (you can still restore it with Cores). + +
+ )} +
diff --git a/scripts/typecheck-strict-changed.js b/scripts/typecheck-strict-changed.js index e16ce391ed3..22d948f3a1e 100644 --- a/scripts/typecheck-strict-changed.js +++ b/scripts/typecheck-strict-changed.js @@ -28,6 +28,13 @@ const packageConfigs = [ // Files temporarily excluded from strict type checking. // These files have known strict-mode violations that will be addressed separately. const strictSkipList = new Set([ + // The @growthbook packages' package.json "exports" maps have no "types" + // condition, so their declaration files are unresolvable under webapp's + // moduleResolution: "bundler" (TS7016). The helper is compiled by webapp's + // program via the webapp test suites that import it. Pre-existing library + // issue, not a violation in this file — remove once growthbook is upgraded + // to a version with a proper exports map. + 'packages/shared/__tests__/helpers/boot.tsx', 'packages/shared/src/components/auth/AuthOptionsInner.tsx', 'packages/shared/src/components/auth/SocialRegistrationForm.tsx', 'packages/shared/src/features/onboarding/steps/FunnelRegistration.tsx',