fix(rewards): add retry on snapshot load failure + tab-switch logging (#768)

Co-authored-by: Jwalin Shah <jshah1331@gmail.com>
This commit is contained in:
Jwalin Shah
2026-04-22 15:21:07 -07:00
committed by GitHub
co-authored by Jwalin Shah
parent c78c957821
commit 5113b0fd8b
3 changed files with 137 additions and 42 deletions
@@ -68,12 +68,14 @@ function roleGlyph(index: number) {
interface RewardsCommunityTabProps {
error: string | null;
isLoading: boolean;
onRetry?: () => void;
snapshot: RewardsSnapshot | null;
}
export default function RewardsCommunityTab({
error,
isLoading,
onRetry,
snapshot,
}: RewardsCommunityTabProps) {
const navigate = useNavigate();
@@ -136,9 +138,22 @@ export default function RewardsCommunityTab({
{error ? (
<div
role="alert"
className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Rewards sync is unavailable right now. The page is showing connection guidance without
claiming new unlocks. Details: {error}
data-testid="rewards-error"
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<span>
Rewards sync is unavailable right now. The page is showing connection guidance without
claiming new unlocks. Details: {error}
</span>
{onRetry ? (
<button
type="button"
data-testid="rewards-retry"
onClick={onRetry}
disabled={isLoading}
className="rounded-full border border-amber-300 bg-white px-3 py-1 text-xs font-semibold text-amber-800 shadow-sm transition-colors hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-60">
{isLoading ? 'Retrying…' : 'Try again'}
</button>
) : null}
</div>
) : null}
+58 -39
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import PillTabBar from '../components/PillTabBar';
import RewardsCommunityTab from '../components/rewards/RewardsCommunityTab';
@@ -9,51 +9,65 @@ import type { RewardsSnapshot } from '../types/rewards';
type RewardsTab = 'referrals' | 'redeem' | 'rewards';
function errorMessage(err: unknown): string {
if (err && typeof err === 'object' && 'error' in err && typeof err.error === 'string') {
return err.error;
}
if (err instanceof Error) {
return err.message;
}
return 'Unable to load rewards';
}
const Rewards = () => {
const [selectedTab, setSelectedTab] = useState<RewardsTab>('rewards');
const [snapshot, setSnapshot] = useState<RewardsSnapshot | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const loadRewards = async () => {
try {
const result = await rewardsApi.getMyRewards();
if (!cancelled) {
setSnapshot(result);
console.debug('[rewards] snapshot applied', {
unlockedCount: result.summary.unlockedCount,
totalCount: result.summary.totalCount,
});
}
} catch (err) {
const message =
err && typeof err === 'object' && 'error' in err && typeof err.error === 'string'
? err.error
: err instanceof Error
? err.message
: 'Unable to load rewards';
console.debug('[rewards] snapshot load failed', message);
if (!cancelled) {
setSnapshot(null);
setError(message);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
const loadRewards = useCallback(async (signal?: { cancelled: boolean }) => {
console.debug('[rewards] fetching snapshot');
setIsLoading(true);
setError(null);
try {
const result = await rewardsApi.getMyRewards();
if (signal?.cancelled) return;
setSnapshot(result);
console.debug('[rewards] snapshot applied', {
unlockedCount: result.summary.unlockedCount,
totalCount: result.summary.totalCount,
});
} catch (err) {
const message = errorMessage(err);
console.debug('[rewards] snapshot load failed', message);
if (signal?.cancelled) return;
setSnapshot(null);
setError(message);
} finally {
if (!signal?.cancelled) {
setIsLoading(false);
}
};
void loadRewards();
return () => {
cancelled = true;
};
}
}, []);
useEffect(() => {
const signal = { cancelled: false };
void loadRewards(signal);
return () => {
signal.cancelled = true;
};
}, [loadRewards]);
const handleTabChange = useCallback((next: RewardsTab) => {
console.debug('[rewards] tab changed', { next });
setSelectedTab(next);
}, []);
const handleRetry = useCallback(() => {
console.debug('[rewards] retry requested');
void loadRewards();
}, [loadRewards]);
return (
<div className="min-h-full px-4 pt-6 pb-10">
<div className="mx-auto max-w-2xl space-y-4">
@@ -64,7 +78,7 @@ const Rewards = () => {
{ label: 'Redeem', value: 'redeem' },
]}
selected={selectedTab}
onChange={setSelectedTab}
onChange={handleTabChange}
activeClassName="border-primary-600 bg-primary-600 text-white"
/>
@@ -73,7 +87,12 @@ const Rewards = () => {
) : selectedTab === 'redeem' ? (
<RewardsRedeemTab />
) : (
<RewardsCommunityTab error={error} isLoading={isLoading} snapshot={snapshot} />
<RewardsCommunityTab
error={error}
isLoading={isLoading}
onRetry={handleRetry}
snapshot={snapshot}
/>
)}
</div>
</div>
+61
View File
@@ -97,6 +97,67 @@ describe('Rewards page', () => {
expect(screen.queryByText('Unlocked')).not.toBeInTheDocument();
});
it('retries the snapshot fetch when the user clicks Try again', async () => {
rewardsApi.getMyRewards
.mockRejectedValueOnce({ error: 'Backend offline' })
.mockResolvedValueOnce({
discord: {
linked: true,
discordId: 'discord-123',
inviteUrl: 'https://discord.gg/openhuman',
membershipStatus: 'member',
},
summary: {
unlockedCount: 1,
totalCount: 2,
assignedDiscordRoleCount: 1,
plan: 'PRO',
hasActiveSubscription: true,
},
metrics: {
currentStreakDays: 7,
longestStreakDays: 7,
cumulativeTokens: 12000000,
featuresUsedCount: 2,
trackedFeaturesCount: 6,
lastEvaluatedAt: '2026-04-09T00:00:00.000Z',
lastSyncedAt: '2026-04-09T01:00:00.000Z',
},
achievements: [
{
id: 'STREAK_7',
title: '7-Day Streak',
description: 'Use OpenHuman on seven consecutive active days.',
actionLabel: 'Keep your streak alive for 7 days',
unlocked: true,
progressLabel: 'Unlocked',
roleId: 'role-streak-7',
discordRoleStatus: 'assigned',
creditAmountUsd: null,
},
],
});
render(
<MemoryRouter>
<Rewards />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByTestId('rewards-error')).toBeInTheDocument();
});
expect(rewardsApi.getMyRewards).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByTestId('rewards-retry'));
await waitFor(() => {
expect(screen.getByText('7-Day Streak')).toBeInTheDocument();
});
expect(screen.queryByTestId('rewards-error')).not.toBeInTheDocument();
expect(rewardsApi.getMyRewards).toHaveBeenCalledTimes(2);
});
it('switches to the referrals tab content', async () => {
rewardsApi.getMyRewards.mockResolvedValueOnce({
discord: {