{!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 (
+
openModal({ type: LazyModal.StreakFreezePurchase })}
+ >
+
+ {hasFreezes ? (
+ `${freezesAvailable} streak freeze${
+ freezesAvailable === 1 ? '' : 's'
+ } left`
+ ) : (
+ <>
+ No freezes left
+
+ Protect your streak
+ >
+ )}
+
+
+ {hasFreezes ? 'Buy more' : 'Buy freezes'}
+
+
+ );
+}
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