mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 05:12:33 +00:00
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:
co-authored by
Jwalin Shah
parent
c78c957821
commit
5113b0fd8b
@@ -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
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user