Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { useActions } from '../../hooks/useActions';
import { useProfileAchievements } from '../../hooks/profile/useProfileAchievements';
import { useTrackedAchievement } from '../../hooks/profile/useTrackedAchievement';
import { ActionType } from '../../graphql/actions';
import { LogEvent, TargetType } from '../../lib/log';
import { LogEvent, Origin, TargetType } from '../../lib/log';
import type { LazyModalCommonProps, ModalProps } from './common/Modal';
import { Modal } from './common/Modal';
import { ModalClose } from './common/ModalClose';
Expand All @@ -23,6 +23,8 @@ import {
import { Checkbox } from '../fields/Checkbox';
import { getTargetCount } from '../../graphql/user/achievements';
import { sortLockedAchievements } from './achievement/sortAchievements';
import { useShareCelebrations } from '../../hooks/useShareCelebrations';
import { AchievementShareActions } from '../../features/profile/components/achievements/AchievementShareActions';

const SPARKLE_DURATION_MS = 4500;

Expand Down Expand Up @@ -66,6 +68,7 @@ export const AchievementCompletionModal = ({
}
};

const isShareEnabled = useShareCelebrations();
const [phase, setPhase] = useState<'celebrate' | 'pickNext'>('celebrate');
const [isTracking, setIsTracking] = useState(false);
const [showSparkles, setShowSparkles] = useState(true);
Expand Down Expand Up @@ -211,6 +214,17 @@ export const AchievementCompletionModal = ({
<div className="text-text-invert rounded-14 bg-accent-cabbage-default px-3 py-1 font-bold typo-subhead">
+{unlockedAchievement.achievement.points} points
</div>
{isShareEnabled && (
<AchievementShareActions
achievement={unlockedAchievement.achievement}
username={user?.username}
name={user?.name}
isOwner
withLabels
origin={Origin.Achievements}
className="mt-1"
/>
)}
</div>

<Button
Expand Down
34 changes: 32 additions & 2 deletions packages/shared/src/components/modals/AchievementShowcaseModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import type { PublicProfile } from '../../lib/user';
import { useShowcaseAchievements } from '../../hooks/profile/useShowcaseAchievements';
import { useProfileAchievements } from '../../hooks/profile/useProfileAchievements';
import { useToastNotification } from '../../hooks/useToastNotification';
import { useShareCelebrations } from '../../hooks/useShareCelebrations';
import { AchievementShareActions } from '../../features/profile/components/achievements/AchievementShareActions';
import { Origin } from '../../lib/log';

const MAX_SHOWCASE = 5;

Expand All @@ -33,6 +36,7 @@ export const AchievementShowcaseModal = ({
useShowcaseAchievements(user);
const { achievements } = useProfileAchievements(user);
const { displayToast } = useToastNotification();
const isShareEnabled = useShareCelebrations();

const initialSelectedIds = useMemo(
() => showcaseAchievements.map((sa) => sa.achievement.id),
Expand Down Expand Up @@ -123,12 +127,12 @@ export const AchievementShowcaseModal = ({
const isDisabled =
!isSelected && selectedIds.length >= MAX_SHOWCASE;

return (
const row = (
<button
key={userAchievement.achievement.id}
type="button"
className={classNames(
'flex items-center gap-3 rounded-12 border p-3 transition-colors',
isShareEnabled && 'min-w-0 flex-1',
isSelected
? 'border-accent-cabbage-default bg-surface-float'
: 'border-border-subtlest-tertiary bg-surface-float',
Expand Down Expand Up @@ -183,6 +187,32 @@ export const AchievementShowcaseModal = ({
</div>
</button>
);

if (!isShareEnabled) {
return (
<React.Fragment key={userAchievement.achievement.id}>
{row}
</React.Fragment>
);
}

// Rendered next to the toggle, never inside it — a button nested
// in a button is invalid and would swallow the row selection.
return (
<div
key={userAchievement.achievement.id}
className="flex items-center gap-2"
>
{row}
<AchievementShareActions
achievement={userAchievement.achievement}
username={user.username}
name={user.name}
isOwner
origin={Origin.Achievements}
/>
</div>
);
})}
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import NewStreakModal from './NewStreakModal';
import { TestBootProvider } from '../../../../__tests__/helpers/boot';
import { useShareCelebrations } from '../../../hooks/useShareCelebrations';

jest.mock('../../../hooks/useShareCelebrations', () => ({
__esModule: true,
useShareCelebrations: jest.fn(),
}));

jest.mock('../../../hooks', () => ({
...jest.requireActual('../../../hooks'),
useActions: () => ({
completeAction: jest.fn(),
checkHasCompleted: () => false,
isActionsFetched: true,
}),
}));

const useShareCelebrationsMock = useShareCelebrations as jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
useShareCelebrationsMock.mockReturnValue(false);
});

const renderModal = () =>
render(
<TestBootProvider client={new QueryClient()}>
<NewStreakModal
isOpen
currentStreak={10}
maxStreak={20}
onRequestClose={jest.fn()}
/>
</TestBootProvider>,
);

describe('NewStreakModal — share celebration', () => {
it('renders the milestone with no share control when the flag is off', () => {
renderModal();

expect(screen.getByText('10 days streak')).toBeInTheDocument();
expect(screen.queryByLabelText('Share streak')).not.toBeInTheDocument();
expect(screen.queryByText('Share your streak')).not.toBeInTheDocument();
});

it('renders exactly one share control when the flag is on', () => {
useShareCelebrationsMock.mockReturnValue(true);
renderModal();

expect(screen.getAllByLabelText('Share streak')).toHaveLength(1);
expect(screen.getByText('Share your streak')).toBeInTheDocument();
});
});
48 changes: 46 additions & 2 deletions packages/shared/src/components/modals/streaks/NewStreakModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,22 @@ import {
} from '../../../lib/image';
import type { StreakModalProps } from './common';
import { useLogContext } from '../../../contexts/LogContext';
import { LogEvent, TargetType } from '../../../lib/log';
import { LogEvent, Origin, TargetType } from '../../../lib/log';
import { generateQueryKey, RequestKey } from '../../../lib/query';
import { useAuthContext } from '../../../contexts/AuthContext';
import { useActions } from '../../../hooks';
import { ActionType } from '../../../graphql/actions';
import StreakReminderSwitch from '../../streak/StreakReminderSwitch';
import { ShareActions } from '../../share/ShareActions';
import { ButtonVariant } from '../../buttons/Button';
import {
Typography,
TypographyColor,
TypographyType,
} from '../../typography/Typography';
import { useShareCelebrations } from '../../../hooks/useShareCelebrations';
import { webappUrl } from '../../../lib/constants';
import { ReferralCampaignKey } from '../../../lib/referral';

const Paragraph = classed('p', 'text-center text-text-tertiary');

Expand All @@ -37,6 +47,7 @@ export default function NewStreakModal({
const shouldShowSplash = currentStreak >= maxStreak;
const daysPlural = currentStreak === 1 ? 'day' : 'days';
const loggedImpression = useRef(false);
const isShareEnabled = useShareCelebrations();

useEffect(() => {
if (loggedImpression.current) {
Expand Down Expand Up @@ -120,9 +131,42 @@ export default function NewStreakModal({
? 'Epic win! You are in a league of your own'
: `New milestone reached! You are unstoppable.`}
</Paragraph>
{isShareEnabled && (
<div className="mt-6 flex items-center gap-2">
<Typography
type={TypographyType.Footnote}
color={TypographyColor.Tertiary}
>
Share your streak
</Typography>
<ShareActions
link={webappUrl}
text={
shouldShowSplash
? `New personal record: ${currentStreak} ${daysPlural} of reading on daily.dev`
: `${currentStreak} ${daysPlural} of reading on daily.dev and counting`
}
cid={ReferralCampaignKey.Generic}
label="Share streak"
emailTitle={`${currentStreak} ${daysPlural} reading streak`}
buttonVariant={ButtonVariant.Secondary}
onShare={(provider) =>
logEvent({
event_name: LogEvent.ShareLog,
target_type: TargetType.StreaksMilestone,
target_id: currentStreak?.toString(),
extra: JSON.stringify({
origin: Origin.ReadingStreak,
provider,
}),
})
}
/>
</div>
)}
<Checkbox
name="show_streaks"
className="mt-10"
className={isShareEnabled ? 'mt-6' : 'mt-10'}
checked={isStreakModalDisabled}
onToggleCallback={handleOptOut}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { QueryClient } from '@tanstack/react-query';
import { TestBootProvider } from '../../../../../__tests__/helpers/boot';
import type { UserAchievement } from '../../../../graphql/user/achievements';
import { AchievementType } from '../../../../graphql/user/achievements';
import { AchievementCard } from './AchievementCard';
import { useShareCelebrations } from '../../../../hooks/useShareCelebrations';

jest.mock('../../../../hooks/useShareCelebrations', () => ({
__esModule: true,
useShareCelebrations: jest.fn(),
}));

const useShareCelebrationsMock = useShareCelebrations as jest.Mock;

beforeEach(() => {
useShareCelebrationsMock.mockReturnValue(false);
});

const createLockedAchievement = (
overrides: Partial<UserAchievement> = {},
Expand Down Expand Up @@ -34,15 +47,15 @@ const renderCard = (
});

return render(
<QueryClientProvider client={client}>
<TestBootProvider client={client}>
<AchievementCard
userAchievement={createLockedAchievement()}
isOwner
onTrack={jest.fn()}
onUntrack={jest.fn()}
{...props}
/>
</QueryClientProvider>,
</TestBootProvider>,
);
};

Expand Down Expand Up @@ -128,3 +141,50 @@ describe('AchievementCard — stop tracking', () => {
).not.toBeInTheDocument();
});
});

describe('AchievementCard — share celebrations', () => {
const unlocked = createLockedAchievement({
unlockedAt: '2026-05-04T10:00:00.000Z',
progress: 1,
});
const shareUser = { username: 'ada', name: 'Ada Lovelace' };

it('renders no share or download control when the flag is off', () => {
renderCard({ userAchievement: unlocked, shareUser });

expect(
screen.queryByLabelText('Share this achievement'),
).not.toBeInTheDocument();
expect(screen.queryByLabelText('Download badge')).not.toBeInTheDocument();
expect(screen.getByText(/^Unlocked/)).toBeInTheDocument();
});

it('renders exactly one share and one download control when the flag is on', () => {
useShareCelebrationsMock.mockReturnValue(true);
renderCard({ userAchievement: unlocked, shareUser });

expect(screen.getAllByLabelText('Share this achievement')).toHaveLength(1);
expect(screen.getAllByLabelText('Download badge')).toHaveLength(1);
expect(screen.getByText(/^Unlocked/)).toBeInTheDocument();
});

it('renders no share control without a shareUser, even with the flag on', () => {
useShareCelebrationsMock.mockReturnValue(true);
renderCard({ userAchievement: unlocked });

expect(
screen.queryByLabelText('Share this achievement'),
).not.toBeInTheDocument();
expect(screen.queryByLabelText('Download badge')).not.toBeInTheDocument();
});

it('renders no share control for a locked achievement', () => {
useShareCelebrationsMock.mockReturnValue(true);
renderCard({ shareUser });

expect(
screen.queryByLabelText('Share this achievement'),
).not.toBeInTheDocument();
expect(screen.queryByLabelText('Download badge')).not.toBeInTheDocument();
});
});
Loading
Loading