feat(rewards): surface achievement token rewards and fix rewards page display (#4424)

This commit is contained in:
YellowSnnowmann
2026-07-03 16:44:28 +05:30
committed by GitHub
parent c43f79641d
commit 4e75a631a8
25 changed files with 1115 additions and 79 deletions
@@ -18,6 +18,35 @@ function formatNumber(value: number): string {
return new Intl.NumberFormat('en-US').format(Math.max(0, Math.trunc(value)));
}
// Compact token amounts for reward badges: 500000 -> "500K", 2000000 -> "2M".
function formatTokens(value: number): string {
return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(
Math.max(0, Math.trunc(value))
);
}
// Locale-aware USD so the money glyph matches the surrounding translated sentence.
function formatUsd(value: number): string {
return new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(
Math.max(0, value)
);
}
// Prefer the backend's actionable claim-error message (e.g. "not unlocked yet",
// "no active paid subscription"); fall back to the generic localized string.
function claimErrorMessage(err: unknown, fallback: string): string {
if (
err &&
typeof err === 'object' &&
'error' in err &&
typeof (err as { error?: unknown }).error === 'string'
) {
return (err as { error: string }).error;
}
if (err instanceof Error && err.message) return err.message;
return fallback;
}
function roleAccentTone(index: number) {
const tones = [
{
@@ -82,6 +111,8 @@ interface RewardsCommunityTabProps {
error: string | null;
isLoading: boolean;
onRetry?: () => void;
/** Reconcile the snapshot after a claim without the full-page loading state. */
onSilentRefresh?: () => Promise<void> | void;
snapshot: RewardsSnapshot | null;
}
@@ -89,6 +120,7 @@ export default function RewardsCommunityTab({
error,
isLoading,
onRetry,
onSilentRefresh,
snapshot,
}: RewardsCommunityTabProps) {
const { t } = useT();
@@ -96,23 +128,43 @@ export default function RewardsCommunityTab({
const [disconnectState, setDisconnectState] = useState<'idle' | 'disconnecting' | 'error'>(
'idle'
);
// Reward claim state, keyed by achievement id. Claimed/claimable are read from
// the server snapshot (single source of truth); these hold only the in-flight id,
// a transient "credited" note for a fresh grant, and per-card error text.
// Track in-flight claims as a Set of ids so concurrent claims on different
// achievements each disable their own button independently (a single scalar
// would let a second claim re-enable the first's button mid-flight).
const [claimingIds, setClaimingIds] = useState<ReadonlySet<string>>(() => new Set());
const [claimedFeedback, setClaimedFeedback] = useState<Record<string, string>>({});
const [claimErrors, setClaimErrors] = useState<Record<string, string>>({});
const rewardRoles: RewardsAchievement[] = snapshot?.achievements ?? [];
const unlocked =
snapshot?.summary.unlockedCount ?? rewardRoles.filter(role => role.unlocked).length;
const total = snapshot?.summary.totalCount ?? rewardRoles.length;
const inviteUrl = snapshot?.discord.inviteUrl ?? DISCORD_INVITE_URL;
const progressPercent = total > 0 ? Math.round((unlocked / total) * 100) : 0;
const achievementSlots =
rewardRoles.length > 0 ? rewardRoles.slice(0, 8) : new Array(4).fill(null);
// Render one progress circle per achievement so the row always matches the
// "{unlocked} of {total} achievements" count. Previously capped at 8, which
// silently hid the remaining badges (11 achievements → only 8 circles).
const achievementSlots: (RewardsAchievement | null)[] =
rewardRoles.length > 0 ? rewardRoles : new Array<null>(4).fill(null);
const ringCircumference = 2 * Math.PI * 24;
const ringOffset = ringCircumference - (progressPercent / 100) * ringCircumference;
const discordLinked = snapshot?.discord.linked ?? false;
const discordUsername = snapshot?.discord.username ?? null;
const membershipStatus = snapshot?.discord.membershipStatus ?? null;
const assignedRoleCount = snapshot?.summary.assignedDiscordRoleCount ?? 0;
// "Roles assigned" is a ratio over *assignable* achievements — the ones both unlocked
// and backed by a configured Discord role. Locked achievements (no role yet) and
// unlocked achievements with no configured role can never be assigned, so counting
// them would misreport the ratio (e.g. "3 of 4" when the 4th can never be granted).
const assignableRoles = rewardRoles.filter(role => role.unlocked && Boolean(role.roleId));
const assignableRoleCount = assignableRoles.length;
const assignedRoleCount = assignableRoles.filter(
role => role.discordRoleStatus === 'assigned'
).length;
// A connected member who unlocked a role-bearing achievement but has not joined the
// server yet cannot receive the role — surface an actionable prompt to join.
const hasUnlockedConfiguredRole = rewardRoles.some(role => role.unlocked && Boolean(role.roleId));
const hasUnlockedConfiguredRole = assignableRoles.length > 0;
const showClaimBanner =
discordLinked && membershipStatus === 'not_in_guild' && hasUnlockedConfiguredRole;
@@ -158,6 +210,57 @@ export default function RewardsCommunityTab({
setDisconnectState('error');
}
}, [onRetry]);
const handleClaim = useCallback(
async (role: RewardsAchievement) => {
log('claim requested reward=%s', role.id);
setClaimingIds(prev => new Set(prev).add(role.id));
setClaimErrors(prev => {
if (!(role.id in prev)) return prev;
const next = { ...prev };
delete next[role.id];
return next;
});
try {
const result = await rewardsApi.claimReward(role.id);
log(
'claim ok reward=%s amountUsd=%d alreadyClaimed=%s',
role.id,
result.amountUsd,
result.alreadyClaimed
);
// Only a fresh grant moves new money — an idempotent re-claim must NOT
// imply "$X credited", so gate the credited note on !alreadyClaimed.
if (!result.alreadyClaimed) {
setClaimedFeedback(prev => ({ ...prev, [role.id]: formatUsd(result.amountUsd) }));
}
// Reconcile with server truth (claimed / claimable / claimableCount and any
// balance surface) without the full-page loading flicker. The button stays
// "Claiming…" until this lands, then the server snapshot flips it to Claimed.
await onSilentRefresh?.();
} catch (err) {
log(
'claim failed reward=%s error=%s',
role.id,
err instanceof Error ? err.message : String(err)
);
setClaimErrors(prev => ({
...prev,
[role.id]: claimErrorMessage(err, t('rewards.community.claimError')),
}));
} finally {
// Only clear the id that just settled — leave any other in-flight claim's
// button disabled.
setClaimingIds(prev => {
const next = new Set(prev);
next.delete(role.id);
return next;
});
}
},
[onSilentRefresh, t]
);
return (
<>
<section className="relative overflow-hidden rounded-[1.25rem] bg-gradient-to-br from-[#004ad0] to-[#2b64f1] p-6 text-white shadow-[0_20px_40px_rgba(25,28,30,0.08)]">
@@ -327,6 +430,9 @@ export default function RewardsCommunityTab({
{achievementSlots.map((role, index) => (
<div
key={role?.id ?? `placeholder-${index}`}
title={role?.title ?? undefined}
aria-label={role?.title ?? undefined}
data-testid={role ? `rewards-achievement-badge-${role.id}` : undefined}
className={`flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full border-2 ${
role?.unlocked
? 'border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10 text-primary-600 dark:text-primary-300'
@@ -405,6 +511,13 @@ export default function RewardsCommunityTab({
: null
: null;
// Claimed/claimable come from the server snapshot (single source of
// truth); the local overlay only holds the transient credited note.
const claimed = role.claimed === true;
const feedback = claimedFeedback[role.id];
const claimError = claimErrors[role.id];
const showClaimFooter = role.claimable === true || claimed;
return (
<div
key={role.id}
@@ -426,6 +539,23 @@ export default function RewardsCommunityTab({
<p className="mt-1 text-xs leading-relaxed text-content-secondary">
{role.description}
</p>
{!role.unlocked && role.progressLabel ? (
<p
data-testid={`rewards-achievement-progress-${role.id}`}
className="mt-1.5 text-[11px] font-semibold text-primary-600 dark:text-primary-300">
{role.progressLabel}
</p>
) : null}
{role.rewardTokens ? (
<p
data-testid={`rewards-achievement-reward-${role.id}`}
className="mt-1.5 inline-flex items-center rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-700 dark:bg-amber-500/10 dark:text-amber-300">
{(role.rewardRecurring
? t('rewards.community.rewardTokensMonthly')
: t('rewards.community.rewardTokens')
).replace('{tokens}', formatTokens(role.rewardTokens))}
</p>
) : null}
</div>
</div>
<div className="flex items-center gap-1 text-primary-700 dark:text-primary-300">
@@ -456,6 +586,60 @@ export default function RewardsCommunityTab({
</span>
</div>
) : null}
{showClaimFooter ? (
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-line pt-3">
{claimed ? (
<>
<span
data-testid={`rewards-claimed-${role.id}`}
className="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-3 py-1.5 text-xs font-semibold text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
<svg
className="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true">
<path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{t('rewards.community.claimed')}
</span>
{feedback ? (
<span
role="status"
data-testid={`rewards-claim-credited-${role.id}`}
className="text-xs font-semibold text-emerald-600 dark:text-emerald-300">
{t('rewards.community.claimCredited').replace('{amount}', feedback)}
</span>
) : null}
</>
) : (
<>
<Button
variant="primary"
size="sm"
data-testid={`rewards-claim-${role.id}`}
disabled={claimingIds.has(role.id)}
onClick={() => {
void handleClaim(role);
}}>
{claimingIds.has(role.id)
? t('rewards.community.claiming')
: t('rewards.community.claimTokens').replace(
'{tokens}',
formatTokens(role.rewardTokens ?? 0)
)}
</Button>
{claimError ? (
<span
role="alert"
data-testid={`rewards-claim-error-${role.id}`}
className="text-xs font-semibold text-coral-600 dark:text-coral-300">
{claimError}
</span>
) : null}
</>
)}
</div>
) : null}
</div>
);
})
@@ -471,7 +655,14 @@ export default function RewardsCommunityTab({
)}
</section>
<section className="rounded-[1.25rem] bg-[#f2f4f6] dark:bg-surface-muted/60 p-4 text-sm text-content-secondary">
{/* Discord-specific status — kept separate from product activity metrics
so the two are no longer conflated in a single list. */}
<section
data-testid="rewards-discord-stats"
className="rounded-[1.25rem] bg-[#f2f4f6] dark:bg-surface-muted/60 p-4 text-sm text-content-secondary">
<h2 className="mb-3 text-sm font-bold text-content">
{t('rewards.community.discordDetails')}
</h2>
<div className="flex items-center justify-between gap-3">
<span>{t('rewards.community.discordServer')}</span>
<span className="font-semibold text-content">
@@ -500,13 +691,24 @@ export default function RewardsCommunityTab({
<span data-testid="rewards-roles-assigned" className="font-semibold text-content">
{t('rewards.community.roleAssignmentCount')
.replace('{assigned}', String(assignedRoleCount))
.replace('{unlocked}', String(unlocked))}
.replace('{unlocked}', String(assignableRoleCount))}
</span>
</div>
) : null}
<div className="mt-3 flex items-center justify-between gap-3">
</section>
{/* Product-usage metrics — the activity streak counts consecutive days the
user actually used OpenHuman (token-processing days), not a check-in. */}
<section
data-testid="rewards-activity-stats"
className="rounded-[1.25rem] bg-[#f2f4f6] dark:bg-surface-muted/60 p-4 text-sm text-content-secondary">
<h2 className="text-sm font-bold text-content">{t('rewards.community.activityTitle')}</h2>
<p className="mb-3 mt-0.5 text-xs leading-relaxed text-content-muted">
{t('rewards.community.activityStreakHint')}
</p>
<div className="flex items-center justify-between gap-3">
<span>{t('rewards.community.currentStreak')}</span>
<span className="font-semibold text-content">
<span data-testid="rewards-current-streak" className="font-semibold text-content">
{snapshot
? t('rewards.community.streakDays').replace(
'{n}',
@@ -515,6 +717,17 @@ export default function RewardsCommunityTab({
: t('rewards.community.unknown')}
</span>
</div>
<div className="mt-3 flex items-center justify-between gap-3">
<span>{t('rewards.community.longestStreak')}</span>
<span data-testid="rewards-longest-streak" className="font-semibold text-content">
{snapshot
? t('rewards.community.streakDays').replace(
'{n}',
String(snapshot.metrics.longestStreakDays)
)
: t('rewards.community.unknown')}
</span>
</div>
<div className="mt-3 flex items-center justify-between gap-3">
<span>{t('rewards.community.cumulativeTokens')}</span>
<span className="font-semibold text-content">
@@ -9,16 +9,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { RewardsSnapshot } from '../../../types/rewards';
const { openUrl, callCoreRpc, setOAuthReturnRoute, disconnectDiscord } = vi.hoisted(() => ({
openUrl: vi.fn(),
callCoreRpc: vi.fn(),
setOAuthReturnRoute: vi.fn(),
disconnectDiscord: vi.fn(),
}));
const { openUrl, callCoreRpc, setOAuthReturnRoute, disconnectDiscord, claimReward } = vi.hoisted(
() => ({
openUrl: vi.fn(),
callCoreRpc: vi.fn(),
setOAuthReturnRoute: vi.fn(),
disconnectDiscord: vi.fn(),
claimReward: vi.fn(),
})
);
vi.mock('../../../utils/openUrl', () => ({ openUrl }));
vi.mock('../../../services/coreRpcClient', () => ({ callCoreRpc }));
vi.mock('../../../services/api/rewardsApi', () => ({ rewardsApi: { disconnectDiscord } }));
vi.mock('../../../services/api/rewardsApi', () => ({
rewardsApi: { disconnectDiscord, claimReward },
}));
vi.mock('../../../utils/oauthReturnRoute', () => ({ setOAuthReturnRoute }));
function buildSnapshot(): RewardsSnapshot {
@@ -57,6 +62,8 @@ function buildSnapshot(): RewardsSnapshot {
roleId: 'discord-role-1',
discordRoleStatus: 'assigned',
creditAmountUsd: null,
rewardTokens: 500000,
rewardRecurring: false,
},
{
id: 'role-2',
@@ -68,6 +75,8 @@ function buildSnapshot(): RewardsSnapshot {
roleId: 'discord-role-2',
discordRoleStatus: 'not_assigned',
creditAmountUsd: null,
rewardTokens: 2000000,
rewardRecurring: false,
},
],
};
@@ -307,3 +316,393 @@ describe('RewardsCommunityTab — Discord role assignment', () => {
expect(screen.queryByTestId('rewards-roles-assigned')).not.toBeInTheDocument();
});
});
describe('RewardsCommunityTab — Claim reward', () => {
beforeEach(() => {
vi.clearAllMocks();
});
function claimableSnapshot(): RewardsSnapshot {
const snapshot = buildSnapshot();
// role-1: unlocked + claimable (not yet claimed). role-2 stays locked.
snapshot.achievements[0] = { ...snapshot.achievements[0], claimable: true, claimed: false };
return snapshot;
}
function claimedSnapshot(): RewardsSnapshot {
const snapshot = buildSnapshot();
// Server truth after a claim: no longer claimable, now claimed.
snapshot.achievements[0] = { ...snapshot.achievements[0], claimable: false, claimed: true };
return snapshot;
}
const claimResult = (over: Record<string, unknown> = {}) => ({
reward: 'role-1',
recurring: false,
period: null,
tokens: 500000,
amountUsd: 1.25,
alreadyClaimed: false,
claimedAt: '2026-07-03T00:00:00.000Z',
newPromoBalanceUsd: 5.25,
...over,
});
it('shows a Claim button for a claimable reward and hides it for locked ones', async () => {
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
render(
<MemoryRouter>
<RewardsCommunityTab error={null} isLoading={false} snapshot={claimableSnapshot()} />
</MemoryRouter>
);
expect(screen.getByTestId('rewards-claim-role-1')).toHaveTextContent('Claim 500K tokens');
// Locked role-2 is neither claimable nor claimed -> no claim footer.
expect(screen.queryByTestId('rewards-claim-role-2')).not.toBeInTheDocument();
expect(screen.queryByTestId('rewards-claimed-role-2')).not.toBeInTheDocument();
});
it('claims a reward, triggers a silent refresh, and shows the credited amount once the server confirms', async () => {
claimReward.mockResolvedValueOnce(claimResult());
const onSilentRefresh = vi.fn().mockResolvedValue(undefined);
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
const { rerender } = render(
<MemoryRouter>
<RewardsCommunityTab
error={null}
isLoading={false}
onSilentRefresh={onSilentRefresh}
snapshot={claimableSnapshot()}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByTestId('rewards-claim-role-1'));
await waitFor(() => expect(claimReward).toHaveBeenCalledWith('role-1'));
// The claim reconciles server truth via a silent refresh (no full-page reload).
await waitFor(() => expect(onSilentRefresh).toHaveBeenCalledTimes(1));
// Simulate the refetched snapshot arriving (server marks it claimed).
rerender(
<MemoryRouter>
<RewardsCommunityTab
error={null}
isLoading={false}
onSilentRefresh={onSilentRefresh}
snapshot={claimedSnapshot()}
/>
</MemoryRouter>
);
expect(screen.getByTestId('rewards-claimed-role-1')).toBeInTheDocument();
expect(screen.getByTestId('rewards-claim-credited-role-1')).toHaveTextContent(
'$1.25 credited to your balance'
);
expect(screen.queryByTestId('rewards-claim-role-1')).not.toBeInTheDocument();
});
it('does not show a fresh-credit note on an idempotent re-claim', async () => {
claimReward.mockResolvedValueOnce(claimResult({ alreadyClaimed: true }));
const onSilentRefresh = vi.fn().mockResolvedValue(undefined);
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
const { rerender } = render(
<MemoryRouter>
<RewardsCommunityTab
error={null}
isLoading={false}
onSilentRefresh={onSilentRefresh}
snapshot={claimableSnapshot()}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByTestId('rewards-claim-role-1'));
await waitFor(() => expect(onSilentRefresh).toHaveBeenCalledTimes(1));
rerender(
<MemoryRouter>
<RewardsCommunityTab
error={null}
isLoading={false}
onSilentRefresh={onSilentRefresh}
snapshot={claimedSnapshot()}
/>
</MemoryRouter>
);
// Claimed pill shows, but no "credited" note — nothing new was credited.
expect(screen.getByTestId('rewards-claimed-role-1')).toBeInTheDocument();
expect(screen.queryByTestId('rewards-claim-credited-role-1')).not.toBeInTheDocument();
});
it('disables the button and shows a claiming label while the claim is in flight', async () => {
let resolveClaim: (value: unknown) => void = () => {};
claimReward.mockImplementationOnce(
() =>
new Promise(resolve => {
resolveClaim = resolve;
})
);
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
render(
<MemoryRouter>
<RewardsCommunityTab error={null} isLoading={false} snapshot={claimableSnapshot()} />
</MemoryRouter>
);
fireEvent.click(screen.getByTestId('rewards-claim-role-1'));
// In-flight: the button is disabled and shows the claiming label (guards double-submit).
await waitFor(() => {
const button = screen.getByTestId('rewards-claim-role-1');
expect(button).toBeDisabled();
expect(button).toHaveTextContent('Claiming');
});
resolveClaim(claimResult());
});
it('tracks in-flight claims per achievement (one pending claim does not re-enable another)', async () => {
const snapshot = claimableSnapshot();
// role-2 is also claimable now.
snapshot.achievements[1] = {
...snapshot.achievements[1],
claimable: true,
claimed: false,
rewardTokens: 2000000,
};
let resolveRole1: (value: unknown) => void = () => {};
claimReward
.mockImplementationOnce(
() =>
new Promise(resolve => {
resolveRole1 = resolve;
})
)
.mockResolvedValueOnce(claimResult({ reward: 'role-2', tokens: 2000000 }));
const onSilentRefresh = vi.fn().mockResolvedValue(undefined);
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
render(
<MemoryRouter>
<RewardsCommunityTab
error={null}
isLoading={false}
onSilentRefresh={onSilentRefresh}
snapshot={snapshot}
/>
</MemoryRouter>
);
// role-1 claim stays pending.
fireEvent.click(screen.getByTestId('rewards-claim-role-1'));
await waitFor(() => expect(screen.getByTestId('rewards-claim-role-1')).toBeDisabled());
// A second claim on role-2 settles fully.
fireEvent.click(screen.getByTestId('rewards-claim-role-2'));
await waitFor(() => expect(claimReward).toHaveBeenCalledWith('role-2'));
// role-1 must remain disabled while its own request is still in flight — a single
// shared scalar would have re-enabled it when role-2's claim settled.
await waitFor(() => expect(screen.getByTestId('rewards-claim-role-1')).toBeDisabled());
resolveRole1(claimResult());
});
it('renders a Claimed pill (no button) for an already-claimed reward', async () => {
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
render(
<MemoryRouter>
<RewardsCommunityTab error={null} isLoading={false} snapshot={claimedSnapshot()} />
</MemoryRouter>
);
expect(screen.getByTestId('rewards-claimed-role-1')).toHaveTextContent('Claimed');
expect(screen.queryByTestId('rewards-claim-role-1')).not.toBeInTheDocument();
// No in-session claim -> no credited note on a server-claimed card.
expect(screen.queryByTestId('rewards-claim-credited-role-1')).not.toBeInTheDocument();
});
it('surfaces the backend error message when a claim fails and keeps the button', async () => {
claimReward.mockRejectedValueOnce({ success: false, error: 'Reward not unlocked yet' });
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
render(
<MemoryRouter>
<RewardsCommunityTab error={null} isLoading={false} snapshot={claimableSnapshot()} />
</MemoryRouter>
);
fireEvent.click(screen.getByTestId('rewards-claim-role-1'));
// The actionable backend message is shown, not a generic string.
await waitFor(() =>
expect(screen.getByTestId('rewards-claim-error-role-1')).toHaveTextContent(
'Reward not unlocked yet'
)
);
// Claim did not succeed -> the button is still there to retry.
expect(screen.getByTestId('rewards-claim-role-1')).toBeInTheDocument();
expect(screen.queryByTestId('rewards-claimed-role-1')).not.toBeInTheDocument();
});
});
describe('RewardsCommunityTab — progress badges, progress labels, and stat split', () => {
it('renders one progress badge per achievement (no 8-item cap)', async () => {
const snapshot = buildSnapshot();
// 10 achievements > the old hard cap of 8: every one must get a badge.
snapshot.achievements = Array.from({ length: 10 }, (_, i) => ({
id: `ach-${i}`,
title: `Achievement ${i}`,
description: `Desc ${i}`,
actionLabel: 'View',
unlocked: i < 3,
progressLabel: i < 3 ? 'Unlocked' : `${i} / 10 active days`,
roleId: null,
discordRoleStatus: 'not_configured',
creditAmountUsd: null,
rewardTokens: null,
rewardRecurring: false,
}));
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
render(
<MemoryRouter>
<RewardsCommunityTab error={null} isLoading={false} snapshot={snapshot} />
</MemoryRouter>
);
for (let i = 0; i < 10; i++) {
expect(screen.getByTestId(`rewards-achievement-badge-ach-${i}`)).toBeInTheDocument();
}
});
it('shows the progress label on locked achievements only', async () => {
// buildSnapshot: role-1 unlocked (progressLabel "1/1"), role-2 locked ("0/1").
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
render(
<MemoryRouter>
<RewardsCommunityTab error={null} isLoading={false} snapshot={buildSnapshot()} />
</MemoryRouter>
);
expect(screen.getByTestId('rewards-achievement-progress-role-2')).toHaveTextContent('0/1');
// Unlocked achievements don't show a progress hint.
expect(screen.queryByTestId('rewards-achievement-progress-role-1')).not.toBeInTheDocument();
});
it('separates Discord status from product-activity metrics into two cards', async () => {
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
render(
<MemoryRouter>
<RewardsCommunityTab error={null} isLoading={false} snapshot={buildSnapshot()} />
</MemoryRouter>
);
const discordCard = screen.getByTestId('rewards-discord-stats');
const activityCard = screen.getByTestId('rewards-activity-stats');
// Discord identity lives in the Discord card…
expect(discordCard).toContainElement(screen.getByTestId('rewards-discord-username'));
// …and streaks/tokens live in the activity card, no longer mixed in.
expect(activityCard).toContainElement(screen.getByTestId('rewards-current-streak'));
expect(activityCard).toContainElement(screen.getByTestId('rewards-longest-streak'));
});
it('labels the streak in days and surfaces the longest streak', async () => {
// buildSnapshot: currentStreakDays 3, longestStreakDays 5.
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
render(
<MemoryRouter>
<RewardsCommunityTab error={null} isLoading={false} snapshot={buildSnapshot()} />
</MemoryRouter>
);
expect(screen.getByTestId('rewards-current-streak')).toHaveTextContent('3 days');
expect(screen.getByTestId('rewards-longest-streak')).toHaveTextContent('5 days');
});
it('shows the token reward pill with a compact amount', async () => {
// buildSnapshot: role-1 rewardTokens 500000, role-2 rewardTokens 2000000.
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
render(
<MemoryRouter>
<RewardsCommunityTab error={null} isLoading={false} snapshot={buildSnapshot()} />
</MemoryRouter>
);
expect(screen.getByTestId('rewards-achievement-reward-role-1')).toHaveTextContent(
'+500K tokens'
);
expect(screen.getByTestId('rewards-achievement-reward-role-2')).toHaveTextContent('+2M tokens');
});
it('shows a per-month reward pill for recurring subscriber rewards', async () => {
const snapshot = buildSnapshot();
snapshot.achievements = [
{
id: 'sub-1',
title: 'Soft Launch',
description: 'Monthly subscriber.',
actionLabel: 'View',
unlocked: true,
progressLabel: 'Unlocked',
roleId: null,
discordRoleStatus: 'not_configured',
creditAmountUsd: null,
rewardTokens: 5000000,
rewardRecurring: true,
},
];
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
render(
<MemoryRouter>
<RewardsCommunityTab error={null} isLoading={false} snapshot={snapshot} />
</MemoryRouter>
);
expect(screen.getByTestId('rewards-achievement-reward-sub-1')).toHaveTextContent(
'+5M tokens/mo'
);
});
it('counts only assignable (unlocked + role-configured) achievements in the roles ratio', async () => {
const snapshot = buildSnapshot();
// Two unlocked achievements, but only one has a configured Discord role. The
// role-less one can never be assigned, so it must not inflate the denominator.
snapshot.achievements = [
{
id: 'role-a',
title: 'Has role',
description: 'Unlocked with a configured Discord role, assigned.',
actionLabel: 'View',
unlocked: true,
progressLabel: 'Unlocked',
roleId: 'discord-role-a',
discordRoleStatus: 'assigned',
creditAmountUsd: null,
rewardTokens: null,
rewardRecurring: false,
},
{
id: 'role-b',
title: 'No role',
description: 'Unlocked but no Discord role configured.',
actionLabel: 'View',
unlocked: true,
progressLabel: 'Unlocked',
roleId: null,
discordRoleStatus: 'not_configured',
creditAmountUsd: null,
rewardTokens: null,
rewardRecurring: false,
},
];
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
render(
<MemoryRouter>
<RewardsCommunityTab error={null} isLoading={false} snapshot={snapshot} />
</MemoryRouter>
);
// 2 unlocked, 1 assignable, 1 assigned → "1 of 1", never "1 of 2".
expect(screen.getByTestId('rewards-roles-assigned')).toHaveTextContent('1 of 1 roles assigned');
});
});
+13 -2
View File
@@ -3555,17 +3555,25 @@ const messages: TranslationMap = {
'privacy.dataKind.raw': 'خام',
'privacy.whatLeaves.link.label': 'ما الذي يغادر جهازي؟',
'rewards.community.achievementsUnlocked': 'تم فتح {unlocked} من {total} إنجازات',
'rewards.community.activityStreakHint': 'أيام متتالية كنت نشطًا فيها على OpenHuman.',
'rewards.community.activityTitle': 'نشاطك',
'rewards.community.claimCredited': 'تمت إضافة {amount} إلى رصيدك',
'rewards.community.claimError': 'تعذّرت المطالبة. يرجى المحاولة مرة أخرى.',
'rewards.community.claimTokens': 'المطالبة بـ {tokens} رمز',
'rewards.community.claimed': 'تمت المطالبة',
'rewards.community.claiming': 'جارٍ المطالبة…',
'rewards.community.connectDiscord': 'ربط Discord',
'rewards.community.connectDiscordError': 'تعذّر بدء الاتصال بـ Discord. يرجى المحاولة مرة أخرى.',
'rewards.community.connectingDiscord': 'جارٍ الاتصال…',
'rewards.community.cumulativeTokens': 'الرموز التراكمية',
'rewards.community.currentStreak': 'السلسلة الحالية',
'rewards.community.currentStreak': 'سلسلة النشاط',
'rewards.community.disconnectDiscord': 'قطع الاتصال',
'rewards.community.disconnectDiscordError': 'تعذّر قطع اتصال Discord. يُرجى المحاولة مرة أخرى.',
'rewards.community.disconnectingDiscord': 'جارٍ قطع الاتصال…',
'rewards.community.discordAccount': 'حساب Discord',
'rewards.community.discordConnected': 'تم الاتصال بـ Discord',
'rewards.community.discordConnectedAs': 'متصل باسم {username}',
'rewards.community.discordDetails': 'Discord',
'rewards.community.discordLinkedNotInGuild': 'Discord مرتبط لكن ليس في السيرفر',
'rewards.community.discordMember': 'انضممت إلى الخادم',
'rewards.community.discordNotLinked': 'Discord غير مرتبط',
@@ -3577,7 +3585,10 @@ const messages: TranslationMap = {
'rewards.community.joinDiscord': 'انضم إلى Discord',
'rewards.community.loadingRewards': 'جارٍ تحميل المكافآت…',
'rewards.community.locked': 'مفتوح',
'rewards.community.longestStreak': 'أطول سلسلة',
'rewards.community.retrying': 'جارٍ إعادة المحاولة…',
'rewards.community.rewardTokens': '+{tokens} توكن',
'rewards.community.rewardTokensMonthly': '+{tokens} توكن/شهر',
'rewards.community.roleAssigned': 'تم تعيين الرتبة',
'rewards.community.roleAssignmentCount': 'تم تعيين {assigned} من {unlocked} رتبة',
'rewards.community.roleClaimDesc':
@@ -3586,7 +3597,7 @@ const messages: TranslationMap = {
'rewards.community.roleJoinToClaim': 'انضم إلى الخادم للحصول عليها',
'rewards.community.rolePending': 'جارٍ مزامنة الرتبة…',
'rewards.community.rolesAndRewards': 'الأدوار والمكافآت',
'rewards.community.streakDays': '{n}',
'rewards.community.streakDays': '{n} يوم',
'rewards.community.syncPending': 'مزامنة المكافآت معلقة',
'rewards.community.syncPendingDesc': 'وصف انتظار المزامنة',
'rewards.community.syncUnavailable': 'المزامنة غير متاحة',
+13 -2
View File
@@ -3633,11 +3633,18 @@ const messages: TranslationMap = {
'privacy.dataKind.raw': 'রা',
'privacy.whatLeaves.link.label': 'আমার কম্পিউটার থেকে কী বের হয়?',
'rewards.community.achievementsUnlocked': '{total}টির মধ্যে {unlocked}টি অর্জন আনলক হয়েছে',
'rewards.community.activityStreakHint': 'OpenHuman-এ পরপর সক্রিয় থাকার দিন।',
'rewards.community.activityTitle': 'আপনার কার্যকলাপ',
'rewards.community.claimCredited': 'আপনার ব্যালেন্সে {amount} যোগ করা হয়েছে',
'rewards.community.claimError': 'দাবি করা যায়নি। আবার চেষ্টা করুন।',
'rewards.community.claimTokens': '{tokens} টোকেন দাবি করুন',
'rewards.community.claimed': 'দাবি করা হয়েছে',
'rewards.community.claiming': 'দাবি করা হচ্ছে…',
'rewards.community.connectDiscord': 'Discord সংযুক্ত করুন',
'rewards.community.connectDiscordError': 'Discord সংযোগ শুরু করা যায়নি। আবার চেষ্টা করুন।',
'rewards.community.connectingDiscord': 'সংযুক্ত হচ্ছে…',
'rewards.community.cumulativeTokens': 'সঞ্চিত টোকেন',
'rewards.community.currentStreak': 'বর্তমান স্ট্রিক',
'rewards.community.currentStreak': 'কার্যকলাপ স্ট্রিক',
'rewards.community.disconnectDiscord': 'সংযোগ বিচ্ছিন্ন করুন',
'rewards.community.disconnectDiscordError':
'Discord সংযোগ বিচ্ছিন্ন করা যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।',
@@ -3645,6 +3652,7 @@ const messages: TranslationMap = {
'rewards.community.discordAccount': 'Discord অ্যাকাউন্ট',
'rewards.community.discordConnected': 'Discord সংযুক্ত',
'rewards.community.discordConnectedAs': '{username} হিসেবে সংযুক্ত',
'rewards.community.discordDetails': 'Discord',
'rewards.community.discordLinkedNotInGuild': 'Discord লিংক কিন্তু গিল্ডে নেই',
'rewards.community.discordMember': 'সার্ভারে যোগ দিয়েছেন',
'rewards.community.discordNotLinked': 'Discord লিংক করা হয়নি',
@@ -3656,7 +3664,10 @@ const messages: TranslationMap = {
'rewards.community.joinDiscord': 'Discord-এ যোগ দিন',
'rewards.community.loadingRewards': 'পুরস্কার লোড হচ্ছে…',
'rewards.community.locked': 'আনলক করা',
'rewards.community.longestStreak': 'দীর্ঘতম স্ট্রিক',
'rewards.community.retrying': 'আবার চেষ্টা হচ্ছে…',
'rewards.community.rewardTokens': '+{tokens} টোকেন',
'rewards.community.rewardTokensMonthly': '+{tokens} টোকেন/মাস',
'rewards.community.roleAssigned': 'রোল বরাদ্দ হয়েছে',
'rewards.community.roleAssignmentCount': '{unlocked}টির মধ্যে {assigned}টি রোল বরাদ্দ হয়েছে',
'rewards.community.roleClaimDesc':
@@ -3665,7 +3676,7 @@ const messages: TranslationMap = {
'rewards.community.roleJoinToClaim': 'দাবি করতে সার্ভারে যোগ দিন',
'rewards.community.rolePending': 'রোল সিঙ্ক হচ্ছে…',
'rewards.community.rolesAndRewards': 'রোল ও পুরস্কার',
'rewards.community.streakDays': '{n}',
'rewards.community.streakDays': '{n} দিন',
'rewards.community.syncPending': 'পুরস্কার সিঙ্ক মুলতুবি',
'rewards.community.syncPendingDesc': 'সিঙ্ক মুলতুবির বিবরণ',
'rewards.community.syncUnavailable': 'সিঙ্ক পাওয়া যাচ্ছে না',
+13 -1
View File
@@ -3721,12 +3721,20 @@ const messages: TranslationMap = {
'privacy.dataKind.raw': 'Roh',
'privacy.whatLeaves.link.label': 'Was verlässt meinen Computer?',
'rewards.community.achievementsUnlocked': '{unlocked} von {total} Erfolgen freigeschaltet',
'rewards.community.activityStreakHint':
'Aufeinanderfolgende Tage, an denen du auf OpenHuman aktiv warst.',
'rewards.community.activityTitle': 'Deine Aktivität',
'rewards.community.claimCredited': '{amount} deinem Guthaben gutgeschrieben',
'rewards.community.claimError': 'Einlösen fehlgeschlagen. Bitte versuche es erneut.',
'rewards.community.claimTokens': '{tokens} Tokens einlösen',
'rewards.community.claimed': 'Eingelöst',
'rewards.community.claiming': 'Wird eingelöst…',
'rewards.community.connectDiscord': 'Verbinde Discord',
'rewards.community.connectDiscordError':
'Discord-Verbindung konnte nicht gestartet werden. Bitte versuche es erneut.',
'rewards.community.connectingDiscord': 'Verbinden…',
'rewards.community.cumulativeTokens': 'Kumulierte Token',
'rewards.community.currentStreak': 'Aktuelle Serie',
'rewards.community.currentStreak': 'Aktivitätsserie',
'rewards.community.disconnectDiscord': 'Trennen',
'rewards.community.disconnectDiscordError':
'Discord konnte nicht getrennt werden. Bitte versuche es erneut.',
@@ -3734,6 +3742,7 @@ const messages: TranslationMap = {
'rewards.community.discordAccount': 'Discord-Konto',
'rewards.community.discordConnected': 'Discord verbunden',
'rewards.community.discordConnectedAs': 'Verbunden als {username}',
'rewards.community.discordDetails': 'Discord',
'rewards.community.discordLinkedNotInGuild': 'Verlinkt, aber nicht auf dem Server',
'rewards.community.discordMember': 'Dem Server beigetreten',
'rewards.community.discordNotLinked': 'Nicht verlinkt',
@@ -3746,7 +3755,10 @@ const messages: TranslationMap = {
'rewards.community.joinDiscord': 'Tritt Discord bei',
'rewards.community.loadingRewards': 'Prämien werden geladen…',
'rewards.community.locked': 'Gesperrt',
'rewards.community.longestStreak': 'Längste Serie',
'rewards.community.retrying': 'Erneuter Versuch…',
'rewards.community.rewardTokens': '+{tokens} Tokens',
'rewards.community.rewardTokensMonthly': '+{tokens} Tokens/Monat',
'rewards.community.roleAssigned': 'Rolle zugewiesen',
'rewards.community.roleAssignmentCount': '{assigned} von {unlocked} Rollen zugewiesen',
'rewards.community.roleClaimDesc':
+13 -2
View File
@@ -4273,17 +4273,25 @@ const en: TranslationMap = {
'privacy.dataKind.raw': 'Raw',
'privacy.whatLeaves.link.label': 'What leaves my computer?',
'rewards.community.achievementsUnlocked': '{unlocked} of {total} achievements unlocked',
'rewards.community.activityStreakHint': 'Consecutive days you were active on OpenHuman.',
'rewards.community.activityTitle': 'Your activity',
'rewards.community.claimCredited': '{amount} credited to your balance',
'rewards.community.claimError': 'Could not claim. Please try again.',
'rewards.community.claimTokens': 'Claim {tokens} tokens',
'rewards.community.claimed': 'Claimed',
'rewards.community.claiming': 'Claiming…',
'rewards.community.connectDiscord': 'Connect discord',
'rewards.community.connectDiscordError': 'Could not start Discord connection. Please try again.',
'rewards.community.connectingDiscord': 'Connecting…',
'rewards.community.cumulativeTokens': 'Cumulative tokens',
'rewards.community.currentStreak': 'Current streak',
'rewards.community.currentStreak': 'Activity streak',
'rewards.community.disconnectDiscord': 'Disconnect',
'rewards.community.disconnectDiscordError': 'Could not disconnect Discord. Please try again.',
'rewards.community.disconnectingDiscord': 'Disconnecting…',
'rewards.community.discordAccount': 'Discord account',
'rewards.community.discordConnected': 'Discord connected',
'rewards.community.discordConnectedAs': 'Connected as {username}',
'rewards.community.discordDetails': 'Discord',
'rewards.community.discordLinkedNotInGuild': 'Discord linked — not yet a server member',
'rewards.community.discordMember': 'Joined the server',
'rewards.community.discordNotLinked': 'Discord not connected',
@@ -4295,7 +4303,10 @@ const en: TranslationMap = {
'rewards.community.joinDiscord': 'Join Discord',
'rewards.community.loadingRewards': 'Loading rewards…',
'rewards.community.locked': 'Locked',
'rewards.community.longestStreak': 'Longest streak',
'rewards.community.retrying': 'Retrying…',
'rewards.community.rewardTokens': '+{tokens} tokens',
'rewards.community.rewardTokensMonthly': '+{tokens} tokens/mo',
'rewards.community.roleAssigned': 'Role assigned',
'rewards.community.roleAssignmentCount': '{assigned} of {unlocked} roles assigned',
'rewards.community.roleClaimDesc':
@@ -4304,7 +4315,7 @@ const en: TranslationMap = {
'rewards.community.roleJoinToClaim': 'Join server to claim',
'rewards.community.rolePending': 'Syncing role…',
'rewards.community.rolesAndRewards': 'Roles & Rewards',
'rewards.community.streakDays': '{n}',
'rewards.community.streakDays': '{n} days',
'rewards.community.syncPending': 'Rewards sync pending',
'rewards.community.syncPendingDesc': 'Your rewards are syncing. Check back shortly.',
'rewards.community.syncUnavailable': 'Sync unavailable',
+14 -2
View File
@@ -3698,18 +3698,27 @@ const messages: TranslationMap = {
'privacy.dataKind.raw': 'Sin procesar',
'privacy.whatLeaves.link.label': '¿Qué sale de mi ordenador?',
'rewards.community.achievementsUnlocked': '{unlocked} de {total} logros desbloqueados',
'rewards.community.activityStreakHint':
'Días consecutivos en los que estuviste activo en OpenHuman.',
'rewards.community.activityTitle': 'Tu actividad',
'rewards.community.claimCredited': '{amount} añadidos a tu saldo',
'rewards.community.claimError': 'No se pudo reclamar. Inténtalo de nuevo.',
'rewards.community.claimTokens': 'Reclamar {tokens} tokens',
'rewards.community.claimed': 'Reclamado',
'rewards.community.claiming': 'Reclamando…',
'rewards.community.connectDiscord': 'Conectar Discord',
'rewards.community.connectDiscordError':
'No se pudo iniciar la conexión con Discord. Inténtalo de nuevo.',
'rewards.community.connectingDiscord': 'Conectando…',
'rewards.community.cumulativeTokens': 'Tokens acumulados',
'rewards.community.currentStreak': 'Racha actual',
'rewards.community.currentStreak': 'Racha de actividad',
'rewards.community.disconnectDiscord': 'Desconectar',
'rewards.community.disconnectDiscordError': 'No se pudo desconectar Discord. Inténtalo de nuevo.',
'rewards.community.disconnectingDiscord': 'Desconectando…',
'rewards.community.discordAccount': 'Cuenta de Discord',
'rewards.community.discordConnected': 'Discord conectado',
'rewards.community.discordConnectedAs': 'Conectado como {username}',
'rewards.community.discordDetails': 'Discord',
'rewards.community.discordLinkedNotInGuild': 'Discord vinculado pero no en el servidor',
'rewards.community.discordMember': 'Te uniste al servidor',
'rewards.community.discordNotLinked': 'Discord no vinculado',
@@ -3721,7 +3730,10 @@ const messages: TranslationMap = {
'rewards.community.joinDiscord': 'Unirse a Discord',
'rewards.community.loadingRewards': 'Cargando recompensas…',
'rewards.community.locked': 'Desbloqueado',
'rewards.community.longestStreak': 'Racha más larga',
'rewards.community.retrying': 'Reintentando…',
'rewards.community.rewardTokens': '+{tokens} tokens',
'rewards.community.rewardTokensMonthly': '+{tokens} tokens/mes',
'rewards.community.roleAssigned': 'Rol asignado',
'rewards.community.roleAssignmentCount': '{assigned} de {unlocked} roles asignados',
'rewards.community.roleClaimDesc':
@@ -3730,7 +3742,7 @@ const messages: TranslationMap = {
'rewards.community.roleJoinToClaim': 'Únete al servidor para reclamar',
'rewards.community.rolePending': 'Sincronizando rol…',
'rewards.community.rolesAndRewards': 'Roles y recompensas',
'rewards.community.streakDays': '{n}',
'rewards.community.streakDays': '{n} días',
'rewards.community.syncPending': 'Sincronización de recompensas pendiente',
'rewards.community.syncPendingDesc': 'Descripción de sincronización pendiente',
'rewards.community.syncUnavailable': 'Sincronización no disponible',
+13 -2
View File
@@ -3712,12 +3712,19 @@ const messages: TranslationMap = {
'privacy.dataKind.raw': 'Brut',
'privacy.whatLeaves.link.label': "Qu'est-ce qui quitte mon ordinateur ?",
'rewards.community.achievementsUnlocked': '{unlocked} sur {total} succès débloqués',
'rewards.community.activityStreakHint': 'Jours consécutifs dactivité sur OpenHuman.',
'rewards.community.activityTitle': 'Votre activité',
'rewards.community.claimCredited': '{amount} crédités sur votre solde',
'rewards.community.claimError': 'Impossible de réclamer. Veuillez réessayer.',
'rewards.community.claimTokens': 'Réclamer {tokens} tokens',
'rewards.community.claimed': 'Réclamé',
'rewards.community.claiming': 'Réclamation…',
'rewards.community.connectDiscord': 'Connecter Discord',
'rewards.community.connectDiscordError':
'Impossible de démarrer la connexion à Discord. Veuillez réessayer.',
'rewards.community.connectingDiscord': 'Connexion…',
'rewards.community.cumulativeTokens': 'Tokens cumulés',
'rewards.community.currentStreak': 'Série actuelle',
'rewards.community.currentStreak': 'Série dactivité',
'rewards.community.disconnectDiscord': 'Déconnecter',
'rewards.community.disconnectDiscordError':
'Impossible de déconnecter Discord. Veuillez réessayer.',
@@ -3725,6 +3732,7 @@ const messages: TranslationMap = {
'rewards.community.discordAccount': 'Compte Discord',
'rewards.community.discordConnected': 'Discord connecté',
'rewards.community.discordConnectedAs': 'Connecté en tant que {username}',
'rewards.community.discordDetails': 'Discord',
'rewards.community.discordLinkedNotInGuild': 'Discord lié mais pas dans la guilde',
'rewards.community.discordMember': 'A rejoint le serveur',
'rewards.community.discordNotLinked': 'Discord non lié',
@@ -3736,7 +3744,10 @@ const messages: TranslationMap = {
'rewards.community.joinDiscord': 'Rejoindre Discord',
'rewards.community.loadingRewards': 'Chargement des récompenses…',
'rewards.community.locked': 'Débloqué',
'rewards.community.longestStreak': 'Plus longue série',
'rewards.community.retrying': 'Nouvelle tentative…',
'rewards.community.rewardTokens': '+{tokens} jetons',
'rewards.community.rewardTokensMonthly': '+{tokens} jetons/mois',
'rewards.community.roleAssigned': 'Rôle attribué',
'rewards.community.roleAssignmentCount': '{assigned} sur {unlocked} rôles attribués',
'rewards.community.roleClaimDesc':
@@ -3745,7 +3756,7 @@ const messages: TranslationMap = {
'rewards.community.roleJoinToClaim': 'Rejoindre le serveur pour réclamer',
'rewards.community.rolePending': 'Synchronisation du rôle…',
'rewards.community.rolesAndRewards': 'Rôles & Récompenses',
'rewards.community.streakDays': '{n}',
'rewards.community.streakDays': '{n} jours',
'rewards.community.syncPending': 'Synchronisation des récompenses en attente',
'rewards.community.syncPendingDesc': 'Description de la synchronisation en attente',
'rewards.community.syncUnavailable': 'Synchronisation indisponible',
+13 -2
View File
@@ -3635,12 +3635,19 @@ const messages: TranslationMap = {
'privacy.dataKind.raw': 'रॉ',
'privacy.whatLeaves.link.label': 'मेरे कंप्यूटर से क्या जाता है?',
'rewards.community.achievementsUnlocked': '{total} में से {unlocked} उपलब्धियाँ अनलॉक हुईं',
'rewards.community.activityStreakHint': 'OpenHuman पर लगातार सक्रिय रहने के दिन।',
'rewards.community.activityTitle': 'आपकी गतिविधि',
'rewards.community.claimCredited': 'आपके बैलेंस में {amount} जोड़े गए',
'rewards.community.claimError': 'दावा नहीं किया जा सका। कृपया फिर से प्रयास करें।',
'rewards.community.claimTokens': '{tokens} टोकन का दावा करें',
'rewards.community.claimed': 'दावा किया गया',
'rewards.community.claiming': 'दावा किया जा रहा है…',
'rewards.community.connectDiscord': 'Discord कनेक्ट करें',
'rewards.community.connectDiscordError':
'Discord कनेक्शन शुरू नहीं हो सका। कृपया फिर से प्रयास करें।',
'rewards.community.connectingDiscord': 'कनेक्ट हो रहा है…',
'rewards.community.cumulativeTokens': 'कुल टोकन',
'rewards.community.currentStreak': 'मौजूदा स्ट्रीक',
'rewards.community.currentStreak': 'गतिविधि स्ट्रीक',
'rewards.community.disconnectDiscord': 'डिस्कनेक्ट करें',
'rewards.community.disconnectDiscordError':
'Discord डिस्कनेक्ट नहीं हो सका। कृपया पुनः प्रयास करें।',
@@ -3648,6 +3655,7 @@ const messages: TranslationMap = {
'rewards.community.discordAccount': 'Discord खाता',
'rewards.community.discordConnected': 'Discord कनेक्ट हो गया',
'rewards.community.discordConnectedAs': '{username} के रूप में कनेक्टेड',
'rewards.community.discordDetails': 'Discord',
'rewards.community.discordLinkedNotInGuild': 'Discord लिंक्ड, Guild में नहीं',
'rewards.community.discordMember': 'सर्वर में शामिल हुए',
'rewards.community.discordNotLinked': 'Discord लिंक्ड नहीं',
@@ -3659,7 +3667,10 @@ const messages: TranslationMap = {
'rewards.community.joinDiscord': 'Discord से जुड़ें',
'rewards.community.loadingRewards': 'रिवॉर्ड लोड हो रहे हैं…',
'rewards.community.locked': 'अनलॉक्ड',
'rewards.community.longestStreak': 'सबसे लंबी स्ट्रीक',
'rewards.community.retrying': 'फिर से कोशिश हो रही है…',
'rewards.community.rewardTokens': '+{tokens} टोकन',
'rewards.community.rewardTokensMonthly': '+{tokens} टोकन/माह',
'rewards.community.roleAssigned': 'रोल असाइन हो गया',
'rewards.community.roleAssignmentCount': '{unlocked} में से {assigned} रोल असाइन किए गए',
'rewards.community.roleClaimDesc':
@@ -3668,7 +3679,7 @@ const messages: TranslationMap = {
'rewards.community.roleJoinToClaim': 'क्लेम करने के लिए सर्वर जॉइन करें',
'rewards.community.rolePending': 'रोल सिंक हो रहा है…',
'rewards.community.rolesAndRewards': 'रोल्स और रिवॉर्ड',
'rewards.community.streakDays': '{n}',
'rewards.community.streakDays': '{n} दिन',
'rewards.community.syncPending': 'रिवॉर्ड सिंक पेंडिंग',
'rewards.community.syncPendingDesc': 'सिंक लंबित विवरण',
'rewards.community.syncUnavailable': 'सिंक उपलब्ध नहीं',
+13 -2
View File
@@ -3643,18 +3643,26 @@ const messages: TranslationMap = {
'privacy.dataKind.raw': 'Mentah',
'privacy.whatLeaves.link.label': 'Apa yang keluar dari komputer saya?',
'rewards.community.achievementsUnlocked': '{unlocked} dari {total} pencapaian terbuka',
'rewards.community.activityStreakHint': 'Hari berturut-turut kamu aktif di OpenHuman.',
'rewards.community.activityTitle': 'Aktivitas kamu',
'rewards.community.claimCredited': '{amount} ditambahkan ke saldo kamu',
'rewards.community.claimError': 'Tidak dapat mengklaim. Silakan coba lagi.',
'rewards.community.claimTokens': 'Klaim {tokens} token',
'rewards.community.claimed': 'Diklaim',
'rewards.community.claiming': 'Mengklaim...',
'rewards.community.connectDiscord': 'Hubungkan Discord',
'rewards.community.connectDiscordError':
'Tidak dapat memulai koneksi Discord. Silakan coba lagi.',
'rewards.community.connectingDiscord': 'Menghubungkan…',
'rewards.community.cumulativeTokens': 'Token kumulatif',
'rewards.community.currentStreak': 'Streak saat ini',
'rewards.community.currentStreak': 'Rentetan aktivitas',
'rewards.community.disconnectDiscord': 'Putuskan',
'rewards.community.disconnectDiscordError': 'Tidak dapat memutuskan Discord. Silakan coba lagi.',
'rewards.community.disconnectingDiscord': 'Memutuskan…',
'rewards.community.discordAccount': 'Akun Discord',
'rewards.community.discordConnected': 'Discord terhubung',
'rewards.community.discordConnectedAs': 'Terhubung sebagai {username}',
'rewards.community.discordDetails': 'Discord',
'rewards.community.discordLinkedNotInGuild': 'Discord terhubung tidak ada di guild',
'rewards.community.discordMember': 'Bergabung ke server',
'rewards.community.discordNotLinked': 'Discord belum terhubung',
@@ -3666,7 +3674,10 @@ const messages: TranslationMap = {
'rewards.community.joinDiscord': 'Gabung Discord',
'rewards.community.loadingRewards': 'Memuat hadiah...',
'rewards.community.locked': 'Terbuka',
'rewards.community.longestStreak': 'Rentetan terpanjang',
'rewards.community.retrying': 'Mencoba ulang...',
'rewards.community.rewardTokens': '+{tokens} token',
'rewards.community.rewardTokensMonthly': '+{tokens} token/bln',
'rewards.community.roleAssigned': 'Role ditetapkan',
'rewards.community.roleAssignmentCount': '{assigned} dari {unlocked} role ditetapkan',
'rewards.community.roleClaimDesc':
@@ -3675,7 +3686,7 @@ const messages: TranslationMap = {
'rewards.community.roleJoinToClaim': 'Gabung server untuk klaim',
'rewards.community.rolePending': 'Menyinkronkan role…',
'rewards.community.rolesAndRewards': 'Peran & Hadiah',
'rewards.community.streakDays': '{n}',
'rewards.community.streakDays': '{n} hari',
'rewards.community.syncPending': 'Sinkronisasi hadiah tertunda',
'rewards.community.syncPendingDesc': 'Deskripsi tertunda sinkronisasi',
'rewards.community.syncUnavailable': 'Sinkronisasi tidak tersedia',
+13 -2
View File
@@ -3693,17 +3693,25 @@ const messages: TranslationMap = {
'privacy.dataKind.raw': 'Grezzi',
'privacy.whatLeaves.link.label': 'Cosa esce dal mio computer?',
'rewards.community.achievementsUnlocked': '{unlocked} di {total} obiettivi sbloccati',
'rewards.community.activityStreakHint': 'Giorni consecutivi di attività su OpenHuman.',
'rewards.community.activityTitle': 'La tua attività',
'rewards.community.claimCredited': '{amount} accreditati sul tuo saldo',
'rewards.community.claimError': 'Impossibile riscattare. Riprova.',
'rewards.community.claimTokens': 'Riscatta {tokens} token',
'rewards.community.claimed': 'Riscattato',
'rewards.community.claiming': 'Riscatto in corso…',
'rewards.community.connectDiscord': 'Connetti Discord',
'rewards.community.connectDiscordError': 'Impossibile avviare la connessione a Discord. Riprova.',
'rewards.community.connectingDiscord': 'Connessione…',
'rewards.community.cumulativeTokens': 'Token cumulativi',
'rewards.community.currentStreak': 'Streak attuale',
'rewards.community.currentStreak': 'Serie di attività',
'rewards.community.disconnectDiscord': 'Disconnetti',
'rewards.community.disconnectDiscordError': 'Impossibile disconnettere Discord. Riprova.',
'rewards.community.disconnectingDiscord': 'Disconnessione…',
'rewards.community.discordAccount': 'Account Discord',
'rewards.community.discordConnected': 'Discord connesso',
'rewards.community.discordConnectedAs': 'Connesso come {username}',
'rewards.community.discordDetails': 'Discord',
'rewards.community.discordLinkedNotInGuild': 'Discord collegato ma non nella gilda',
'rewards.community.discordMember': 'Sei entrato nel server',
'rewards.community.discordNotLinked': 'Discord non collegato',
@@ -3715,7 +3723,10 @@ const messages: TranslationMap = {
'rewards.community.joinDiscord': 'Unisciti a Discord',
'rewards.community.loadingRewards': 'Caricamento premi…',
'rewards.community.locked': 'Bloccato',
'rewards.community.longestStreak': 'Serie più lunga',
'rewards.community.retrying': 'Nuovo tentativo…',
'rewards.community.rewardTokens': '+{tokens} token',
'rewards.community.rewardTokensMonthly': '+{tokens} token/mese',
'rewards.community.roleAssigned': 'Ruolo assegnato',
'rewards.community.roleAssignmentCount': '{assigned} di {unlocked} ruoli assegnati',
'rewards.community.roleClaimDesc':
@@ -3724,7 +3735,7 @@ const messages: TranslationMap = {
'rewards.community.roleJoinToClaim': 'Unisciti al server per riscattare',
'rewards.community.rolePending': 'Sincronizzazione ruolo…',
'rewards.community.rolesAndRewards': 'Ruoli e premi',
'rewards.community.streakDays': '{n}',
'rewards.community.streakDays': '{n} giorni',
'rewards.community.syncPending': 'Sincronizzazione premi in attesa',
'rewards.community.syncPendingDesc': 'Descrizione sincronizzazione in attesa',
'rewards.community.syncUnavailable': 'Sincronizzazione non disponibile',
+12 -1
View File
@@ -3601,11 +3601,18 @@ const messages: TranslationMap = {
'privacy.dataKind.raw': '원본',
'privacy.whatLeaves.link.label': '내 컴퓨터를 떠나는 데이터는 무엇인가요?',
'rewards.community.achievementsUnlocked': '{total}개 중 {unlocked}개 업적 잠금 해제됨',
'rewards.community.activityStreakHint': 'OpenHuman에서 연속으로 활동한 일수입니다.',
'rewards.community.activityTitle': '내 활동',
'rewards.community.claimCredited': '{amount}이(가) 잔액에 적립되었습니다',
'rewards.community.claimError': '수령하지 못했습니다. 다시 시도해 주세요.',
'rewards.community.claimTokens': '{tokens} 토큰 수령',
'rewards.community.claimed': '수령 완료',
'rewards.community.claiming': '수령 중…',
'rewards.community.connectDiscord': 'Discord 연결',
'rewards.community.connectDiscordError': 'Discord 연결을 시작할 수 없습니다. 다시 시도해 주세요.',
'rewards.community.connectingDiscord': '연결 중…',
'rewards.community.cumulativeTokens': '누적 토큰',
'rewards.community.currentStreak': '현재 연속 기록',
'rewards.community.currentStreak': '활동 연속 기록',
'rewards.community.disconnectDiscord': '연결 해제',
'rewards.community.disconnectDiscordError':
'Discord 연결을 해제하지 못했습니다. 다시 시도해 주세요.',
@@ -3613,6 +3620,7 @@ const messages: TranslationMap = {
'rewards.community.discordAccount': 'Discord 계정',
'rewards.community.discordConnected': 'Discord 연결됨',
'rewards.community.discordConnectedAs': '{username}(으)로 연결됨',
'rewards.community.discordDetails': 'Discord',
'rewards.community.discordLinkedNotInGuild': '연결됨, 하지만 서버 멤버가 아님',
'rewards.community.discordMember': '서버에 참여함',
'rewards.community.discordNotLinked': '연결되지 않음',
@@ -3625,7 +3633,10 @@ const messages: TranslationMap = {
'rewards.community.joinDiscord': 'Discord 참여',
'rewards.community.loadingRewards': '보상 불러오는 중…',
'rewards.community.locked': '잠김',
'rewards.community.longestStreak': '최장 연속 기록',
'rewards.community.retrying': '다시 시도 중…',
'rewards.community.rewardTokens': '+{tokens} 토큰',
'rewards.community.rewardTokensMonthly': '+{tokens} 토큰/월',
'rewards.community.roleAssigned': '역할이 부여됨',
'rewards.community.roleAssignmentCount': '{unlocked}개 중 {assigned}개 역할 부여됨',
'rewards.community.roleClaimDesc':
+12 -1
View File
@@ -3682,12 +3682,19 @@ const messages: TranslationMap = {
'privacy.dataKind.raw': 'Surowe',
'privacy.whatLeaves.link.label': 'Co opuszcza mój komputer?',
'rewards.community.achievementsUnlocked': 'Odblokowano {unlocked} z {total} osiągnięć',
'rewards.community.activityStreakHint': 'Kolejne dni Twojej aktywności w OpenHuman.',
'rewards.community.activityTitle': 'Twoja aktywność',
'rewards.community.claimCredited': '{amount} dodano do Twojego salda',
'rewards.community.claimError': 'Nie udało się odebrać. Spróbuj ponownie.',
'rewards.community.claimTokens': 'Odbierz {tokens} tokenów',
'rewards.community.claimed': 'Odebrano',
'rewards.community.claiming': 'Odbieranie…',
'rewards.community.connectDiscord': 'Połącz Discord',
'rewards.community.connectDiscordError':
'Nie udało się rozpocząć łączenia z Discord. Spróbuj ponownie.',
'rewards.community.connectingDiscord': 'Łączenie…',
'rewards.community.cumulativeTokens': 'Łączna liczba tokenów',
'rewards.community.currentStreak': 'Aktualna seria',
'rewards.community.currentStreak': 'Passa aktywności',
'rewards.community.disconnectDiscord': 'Rozłącz',
'rewards.community.disconnectDiscordError':
'Nie udało się rozłączyć konta Discord. Spróbuj ponownie.',
@@ -3695,6 +3702,7 @@ const messages: TranslationMap = {
'rewards.community.discordAccount': 'Konto Discord',
'rewards.community.discordConnected': 'Discord połączony',
'rewards.community.discordConnectedAs': 'Połączono jako {username}',
'rewards.community.discordDetails': 'Discord',
'rewards.community.discordLinkedNotInGuild': 'Powiązane, ale nie na serwerze',
'rewards.community.discordMember': 'Dołączono do serwera',
'rewards.community.discordNotLinked': 'Nie powiązane',
@@ -3707,7 +3715,10 @@ const messages: TranslationMap = {
'rewards.community.joinDiscord': 'Dołącz do Discorda',
'rewards.community.loadingRewards': 'Wczytywanie nagród…',
'rewards.community.locked': 'Zablokowane',
'rewards.community.longestStreak': 'Najdłuższa passa',
'rewards.community.retrying': 'Ponawianie…',
'rewards.community.rewardTokens': '+{tokens} tokenów',
'rewards.community.rewardTokensMonthly': '+{tokens} tokenów/mies.',
'rewards.community.roleAssigned': 'Rola przypisana',
'rewards.community.roleAssignmentCount': 'Przypisano {assigned} z {unlocked} ról',
'rewards.community.roleClaimDesc':
+14 -2
View File
@@ -3694,12 +3694,20 @@ const messages: TranslationMap = {
'privacy.dataKind.raw': 'Bruto',
'privacy.whatLeaves.link.label': 'O que sai do meu computador?',
'rewards.community.achievementsUnlocked': '{unlocked} de {total} conquistas desbloqueadas',
'rewards.community.activityStreakHint':
'Dias consecutivos em que você esteve ativo no OpenHuman.',
'rewards.community.activityTitle': 'Sua atividade',
'rewards.community.claimCredited': '{amount} creditados no seu saldo',
'rewards.community.claimError': 'Não foi possível resgatar. Tente novamente.',
'rewards.community.claimTokens': 'Resgatar {tokens} tokens',
'rewards.community.claimed': 'Resgatado',
'rewards.community.claiming': 'Resgatando…',
'rewards.community.connectDiscord': 'Conectar Discord',
'rewards.community.connectDiscordError':
'Não foi possível iniciar a conexão com o Discord. Tente novamente.',
'rewards.community.connectingDiscord': 'Conectando…',
'rewards.community.cumulativeTokens': 'Tokens acumulados',
'rewards.community.currentStreak': 'Sequência atual',
'rewards.community.currentStreak': 'Sequência de atividade',
'rewards.community.disconnectDiscord': 'Desconectar',
'rewards.community.disconnectDiscordError':
'Não foi possível desconectar o Discord. Tente novamente.',
@@ -3707,6 +3715,7 @@ const messages: TranslationMap = {
'rewards.community.discordAccount': 'Conta do Discord',
'rewards.community.discordConnected': 'Discord conectado',
'rewards.community.discordConnectedAs': 'Conectado como {username}',
'rewards.community.discordDetails': 'Discord',
'rewards.community.discordLinkedNotInGuild': 'Discord vinculado mas não no servidor',
'rewards.community.discordMember': 'Entrou no servidor',
'rewards.community.discordNotLinked': 'Discord não vinculado',
@@ -3718,7 +3727,10 @@ const messages: TranslationMap = {
'rewards.community.joinDiscord': 'Entrar no Discord',
'rewards.community.loadingRewards': 'Carregando recompensas…',
'rewards.community.locked': 'Desbloqueado',
'rewards.community.longestStreak': 'Maior sequência',
'rewards.community.retrying': 'Tentando novamente…',
'rewards.community.rewardTokens': '+{tokens} tokens',
'rewards.community.rewardTokensMonthly': '+{tokens} tokens/mês',
'rewards.community.roleAssigned': 'Cargo atribuído',
'rewards.community.roleAssignmentCount': '{assigned} de {unlocked} cargos atribuídos',
'rewards.community.roleClaimDesc':
@@ -3727,7 +3739,7 @@ const messages: TranslationMap = {
'rewards.community.roleJoinToClaim': 'Entre no servidor para resgatar',
'rewards.community.rolePending': 'Sincronizando cargo…',
'rewards.community.rolesAndRewards': 'Funções e Recompensas',
'rewards.community.streakDays': '{n}',
'rewards.community.streakDays': '{n} dias',
'rewards.community.syncPending': 'Sincronização de recompensas pendente',
'rewards.community.syncPendingDesc': 'Descrição de sincronização pendente',
'rewards.community.syncUnavailable': 'Sincronização indisponível',
+13 -2
View File
@@ -3663,18 +3663,26 @@ const messages: TranslationMap = {
'privacy.dataKind.raw': 'Необработанные данные',
'privacy.whatLeaves.link.label': 'Что покидает мой компьютер?',
'rewards.community.achievementsUnlocked': 'Открыто достижений: {unlocked} из {total}',
'rewards.community.activityStreakHint': 'Дней подряд с активностью в OpenHuman.',
'rewards.community.activityTitle': 'Ваша активность',
'rewards.community.claimCredited': '{amount} зачислено на ваш баланс',
'rewards.community.claimError': 'Не удалось получить. Попробуйте ещё раз.',
'rewards.community.claimTokens': 'Получить {tokens} токенов',
'rewards.community.claimed': 'Получено',
'rewards.community.claiming': 'Получение…',
'rewards.community.connectDiscord': 'Подключить Discord',
'rewards.community.connectDiscordError':
'Не удалось начать подключение к Discord. Повторите попытку.',
'rewards.community.connectingDiscord': 'Подключение…',
'rewards.community.cumulativeTokens': 'Токенов всего',
'rewards.community.currentStreak': 'Текущая серия',
'rewards.community.currentStreak': 'Серия активности',
'rewards.community.disconnectDiscord': 'Отключить',
'rewards.community.disconnectDiscordError': 'Не удалось отключить Discord. Попробуйте ещё раз.',
'rewards.community.disconnectingDiscord': 'Отключение…',
'rewards.community.discordAccount': 'Аккаунт Discord',
'rewards.community.discordConnected': 'Discord подключён',
'rewards.community.discordConnectedAs': 'Подключено как {username}',
'rewards.community.discordDetails': 'Discord',
'rewards.community.discordLinkedNotInGuild': 'Discord привязан, но не в сервере',
'rewards.community.discordMember': 'Присоединился к серверу',
'rewards.community.discordNotLinked': 'Discord не привязан',
@@ -3686,7 +3694,10 @@ const messages: TranslationMap = {
'rewards.community.joinDiscord': 'Присоединиться к Discord',
'rewards.community.loadingRewards': 'Загрузка наград…',
'rewards.community.locked': 'Разблокировано',
'rewards.community.longestStreak': 'Самая длинная серия',
'rewards.community.retrying': 'Повтор…',
'rewards.community.rewardTokens': '+{tokens} токенов',
'rewards.community.rewardTokensMonthly': '+{tokens} токенов/мес',
'rewards.community.roleAssigned': 'Роль назначена',
'rewards.community.roleAssignmentCount': 'Назначено ролей: {assigned} из {unlocked}',
'rewards.community.roleClaimDesc':
@@ -3695,7 +3706,7 @@ const messages: TranslationMap = {
'rewards.community.roleJoinToClaim': 'Присоединиться к серверу',
'rewards.community.rolePending': 'Синхронизация роли…',
'rewards.community.rolesAndRewards': 'Роли и награды',
'rewards.community.streakDays': '{n}',
'rewards.community.streakDays': '{n} дней',
'rewards.community.syncPending': 'Синхронизация наград ожидает',
'rewards.community.syncPendingDesc': 'Ожидание синхронизации',
'rewards.community.syncUnavailable': 'Синхронизация недоступна',
+12 -1
View File
@@ -3454,17 +3454,25 @@ const messages: TranslationMap = {
'privacy.dataKind.raw': '原始数据',
'privacy.whatLeaves.link.label': '哪些数据会离开我的电脑?',
'rewards.community.achievementsUnlocked': '已解锁 {unlocked}/{total} 项成就',
'rewards.community.activityStreakHint': '你在 OpenHuman 连续活跃的天数。',
'rewards.community.activityTitle': '你的活动',
'rewards.community.claimCredited': '{amount} 已计入你的余额',
'rewards.community.claimError': '无法领取,请重试。',
'rewards.community.claimTokens': '领取 {tokens} 代币',
'rewards.community.claimed': '已领取',
'rewards.community.claiming': '领取中…',
'rewards.community.connectDiscord': '连接 Discord',
'rewards.community.connectDiscordError': '无法启动 Discord 连接,请重试。',
'rewards.community.connectingDiscord': '正在连接…',
'rewards.community.cumulativeTokens': '累计令牌',
'rewards.community.currentStreak': '当前连续天数',
'rewards.community.currentStreak': '活跃连续天数',
'rewards.community.disconnectDiscord': '断开连接',
'rewards.community.disconnectDiscordError': '无法断开 Discord 连接,请重试。',
'rewards.community.disconnectingDiscord': '正在断开连接…',
'rewards.community.discordAccount': 'Discord 账户',
'rewards.community.discordConnected': 'Discord 已连接',
'rewards.community.discordConnectedAs': '已连接为 {username}',
'rewards.community.discordDetails': 'Discord',
'rewards.community.discordLinkedNotInGuild': '已关联 Discord 但未加入服务器',
'rewards.community.discordMember': '已加入服务器',
'rewards.community.discordNotLinked': '未关联 Discord',
@@ -3476,7 +3484,10 @@ const messages: TranslationMap = {
'rewards.community.joinDiscord': '加入 Discord',
'rewards.community.loadingRewards': '正在加载奖励…',
'rewards.community.locked': '已解锁',
'rewards.community.longestStreak': '最长连续天数',
'rewards.community.retrying': '重试中…',
'rewards.community.rewardTokens': '+{tokens} 代币',
'rewards.community.rewardTokensMonthly': '+{tokens} 代币/月',
'rewards.community.roleAssigned': '身份组已分配',
'rewards.community.roleAssignmentCount': '已分配 {assigned}/{unlocked} 个身份组',
'rewards.community.roleClaimDesc':
+39 -24
View File
@@ -39,31 +39,45 @@ const Rewards = () => {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadRewards = useCallback(async (signal?: { cancelled: boolean }) => {
log('fetching snapshot');
setIsLoading(true);
setError(null);
try {
const result = await rewardsApi.getMyRewards();
if (signal?.cancelled) return;
setRewardsSnapshot(result);
log(
'snapshot applied unlockedCount=%d totalCount=%d',
result.summary.unlockedCount,
result.summary.totalCount
);
} catch (err) {
const message = errorMessage(err);
log('snapshot load failed error=%s', message);
if (signal?.cancelled) return;
setRewardsSnapshot(null);
setError(message);
} finally {
if (!signal?.cancelled) {
setIsLoading(false);
const loadRewards = useCallback(
async (signal?: { cancelled: boolean }, opts?: { silent?: boolean }) => {
const silent = opts?.silent === true;
log('fetching snapshot silent=%s', silent);
// A silent refresh (e.g. reconciling after a claim) keeps the current view
// and never flips into the loading/error state — a failed background refetch
// must not blank a page whose data is still valid.
if (!silent) {
setIsLoading(true);
setError(null);
}
}
}, []);
try {
const result = await rewardsApi.getMyRewards();
if (signal?.cancelled) return;
setRewardsSnapshot(result);
log(
'snapshot applied unlockedCount=%d totalCount=%d',
result.summary.unlockedCount,
result.summary.totalCount
);
} catch (err) {
const message = errorMessage(err);
log('snapshot load failed silent=%s error=%s', silent, message);
if (signal?.cancelled || silent) return;
setRewardsSnapshot(null);
setError(message);
} finally {
if (!signal?.cancelled && !silent) {
setIsLoading(false);
}
}
},
[]
);
const handleSilentRefresh = useCallback(
() => loadRewards(undefined, { silent: true }),
[loadRewards]
);
useEffect(() => {
if (isLocalSession) {
@@ -156,6 +170,7 @@ const Rewards = () => {
error={error}
isLoading={isLoading}
onRetry={handleRetry}
onSilentRefresh={handleSilentRefresh}
snapshot={rewardsSnapshot}
/>
)}
+87 -1
View File
@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import Rewards from '../Rewards';
const { rewardsApi, openUrl } = vi.hoisted(() => ({
rewardsApi: { getMyRewards: vi.fn() },
rewardsApi: { getMyRewards: vi.fn(), claimReward: vi.fn() },
openUrl: vi.fn(),
}));
@@ -86,6 +86,8 @@ describe('Rewards page', () => {
roleId: 'role-streak-7',
discordRoleStatus: 'assigned',
creditAmountUsd: null,
rewardTokens: 500000,
rewardRecurring: false,
},
],
});
@@ -160,6 +162,8 @@ describe('Rewards page', () => {
roleId: 'role-streak-7',
discordRoleStatus: 'assigned',
creditAmountUsd: null,
rewardTokens: 500000,
rewardRecurring: false,
},
],
});
@@ -305,6 +309,88 @@ describe('Rewards page', () => {
expect(openUrl).toHaveBeenCalledWith('https://discord.gg/openhuman');
});
it('silently refetches after a claim without flipping into the loading or error state', async () => {
const claimableSnapshot = {
discord: {
linked: false,
discordId: null,
username: null,
inviteUrl: 'https://discord.gg/openhuman',
membershipStatus: 'not_linked',
},
summary: {
unlockedCount: 1,
totalCount: 1,
assignedDiscordRoleCount: 0,
claimableCount: 1,
plan: 'FREE',
hasActiveSubscription: false,
},
metrics: {
currentStreakDays: 7,
longestStreakDays: 7,
cumulativeTokens: 0,
featuresUsedCount: 0,
trackedFeaturesCount: 6,
lastEvaluatedAt: null,
lastSyncedAt: null,
},
achievements: [
{
id: 'STREAK_7',
title: '7-Day Streak',
description: 'Seven consecutive active days.',
actionLabel: 'Reach a 7-day streak',
unlocked: true,
progressLabel: 'Unlocked',
roleId: null,
discordRoleStatus: 'not_configured',
creditAmountUsd: 1.25,
rewardTokens: 500000,
rewardRecurring: false,
claimable: true,
claimed: false,
claimedAt: null,
claimPeriod: null,
},
],
};
// Initial load succeeds; the post-claim silent refetch fails — the page must
// swallow it (no error banner, no blanking), proving the silent path.
rewardsApi.getMyRewards
.mockResolvedValueOnce(claimableSnapshot)
.mockRejectedValueOnce({ error: 'refetch blip' });
rewardsApi.claimReward.mockResolvedValueOnce({
reward: 'STREAK_7',
recurring: false,
period: null,
tokens: 500000,
amountUsd: 1.25,
alreadyClaimed: false,
claimedAt: '2026-07-03T00:00:00.000Z',
newPromoBalanceUsd: 5,
});
render(
<MemoryRouter>
<Rewards />
</MemoryRouter>
);
await waitFor(() => expect(screen.getByText('7-Day Streak')).toBeInTheDocument());
expect(rewardsApi.getMyRewards).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByTestId('rewards-claim-STREAK_7'));
await waitFor(() => expect(rewardsApi.claimReward).toHaveBeenCalledWith('STREAK_7'));
// The claim triggers exactly one silent reconcile fetch.
await waitFor(() => expect(rewardsApi.getMyRewards).toHaveBeenCalledTimes(2));
// The silent refetch failed, but the page stayed intact — no error, no blank.
expect(screen.getByText('7-Day Streak')).toBeInTheDocument();
expect(screen.queryByTestId('rewards-error')).not.toBeInTheDocument();
});
it('refetches the snapshot when an oauth:success event fires', async () => {
rewardsApi.getMyRewards.mockResolvedValue({
discord: {
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import { normalizeRewardsApiError, normalizeRewardsSnapshot, rewardsApi } from '../rewardsApi';
vi.mock('../../apiClient', () => ({ apiClient: { get: vi.fn(), delete: vi.fn() } }));
vi.mock('../../apiClient', () => ({ apiClient: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } }));
describe('normalizeRewardsSnapshot', () => {
it('normalizes a backend rewards payload', () => {
@@ -41,6 +41,12 @@ describe('normalizeRewardsSnapshot', () => {
roleId: 'role-streak-7',
discordRoleStatus: 'assigned',
creditAmountUsd: null,
rewardTokens: 500000,
rewardRecurring: true,
claimable: true,
claimed: false,
claimedAt: null,
claimPeriod: '2026-07',
},
],
});
@@ -50,6 +56,11 @@ describe('normalizeRewardsSnapshot', () => {
expect(snapshot.summary.plan).toBe('PRO');
expect(snapshot.metrics.currentStreakDays).toBe(7);
expect(snapshot.achievements[0].discordRoleStatus).toBe('assigned');
expect(snapshot.achievements[0].rewardTokens).toBe(500000);
expect(snapshot.achievements[0].rewardRecurring).toBe(true);
expect(snapshot.achievements[0].claimable).toBe(true);
expect(snapshot.achievements[0].claimed).toBe(false);
expect(snapshot.achievements[0].claimPeriod).toBe('2026-07');
});
it('falls back safely for malformed payloads', () => {
@@ -67,6 +78,70 @@ describe('normalizeRewardsSnapshot', () => {
expect(snapshot.summary.unlockedCount).toBe(2);
expect(snapshot.achievements[0].discordRoleStatus).toBe('unavailable');
expect(snapshot.achievements[0].creditAmountUsd).toBeNull();
// Missing reward fields default safely.
expect(snapshot.achievements[0].rewardTokens).toBeNull();
expect(snapshot.achievements[0].rewardRecurring).toBe(false);
// Missing claim fields default to not-claimable / not-claimed.
expect(snapshot.achievements[0].claimable).toBe(false);
expect(snapshot.achievements[0].claimed).toBe(false);
expect(snapshot.achievements[0].claimedAt).toBeNull();
expect(snapshot.achievements[0].claimPeriod).toBeNull();
});
});
describe('rewardsApi.claimReward', () => {
it('posts the reward type and normalizes the claim result', async () => {
const { apiClient } = await import('../../apiClient');
vi.mocked(apiClient.post).mockResolvedValueOnce({
success: true,
data: {
reward: 'POWER_10M',
recurring: false,
period: null,
tokens: 2000000,
amountUsd: 4,
alreadyClaimed: false,
claimedAt: '2026-07-03T00:00:00.000Z',
newPromoBalanceUsd: 9,
},
});
const result = await rewardsApi.claimReward('POWER_10M');
expect(apiClient.post).toHaveBeenCalledWith(
'/rewards/claim',
{ rewardType: 'POWER_10M' },
{ timeout: 15000 }
);
expect(result.reward).toBe('POWER_10M');
expect(result.tokens).toBe(2000000);
expect(result.amountUsd).toBe(4);
expect(result.alreadyClaimed).toBe(false);
expect(result.newPromoBalanceUsd).toBe(9);
});
it('throws the backend error message when a claim is rejected', async () => {
const { apiClient } = await import('../../apiClient');
vi.mocked(apiClient.post).mockResolvedValueOnce({
success: false,
data: null,
error: 'This achievement is not unlocked yet, so it cannot be claimed.',
});
await expect(rewardsApi.claimReward('POWER_10M')).rejects.toMatchObject({
success: false,
error: 'This achievement is not unlocked yet, so it cannot be claimed.',
});
});
it('normalizes a transport failure into a retryable error', async () => {
const { apiClient } = await import('../../apiClient');
vi.mocked(apiClient.post).mockRejectedValueOnce(new Error('network error'));
await expect(rewardsApi.claimReward('POWER_10M')).rejects.toMatchObject({
success: false,
error: 'network error',
});
});
});
+59 -1
View File
@@ -1,7 +1,7 @@
import createDebug from 'debug';
import type { ApiError, ApiResponse } from '../../types/api';
import type { RewardsAchievement, RewardsSnapshot } from '../../types/rewards';
import type { RewardClaimResult, RewardsAchievement, RewardsSnapshot } from '../../types/rewards';
import { apiClient } from '../apiClient';
const REWARDS_SNAPSHOT_TIMEOUT_MS = 15_000;
@@ -81,6 +81,7 @@ export function normalizeRewardsApiError(error: unknown): RewardsApiError {
function normalizeAchievement(value: unknown): RewardsAchievement {
const raw = asRecord(value) ?? {};
const creditAmountUsd = asFiniteNumberOrNull(raw.creditAmountUsd);
const rewardTokens = asFiniteNumberOrNull(raw.rewardTokens);
return {
id: typeof raw.id === 'string' ? raw.id : '',
@@ -100,6 +101,26 @@ function normalizeAchievement(value: unknown): RewardsAchievement {
? raw.discordRoleStatus
: 'unavailable',
creditAmountUsd: creditAmountUsd == null ? null : asNumber(creditAmountUsd),
rewardTokens: rewardTokens == null ? null : asNumber(rewardTokens),
rewardRecurring: raw.rewardRecurring === true,
claimable: raw.claimable === true,
claimed: raw.claimed === true,
claimedAt: asStringOrNull(raw.claimedAt),
claimPeriod: asStringOrNull(raw.claimPeriod),
};
}
function normalizeClaimResult(payload: unknown): RewardClaimResult {
const raw = asRecord(payload) ?? {};
return {
reward: typeof raw.reward === 'string' ? raw.reward : '',
recurring: raw.recurring === true,
period: asStringOrNull(raw.period),
tokens: asNumber(raw.tokens),
amountUsd: asNumber(raw.amountUsd),
alreadyClaimed: raw.alreadyClaimed === true,
claimedAt: asStringOrNull(raw.claimedAt),
newPromoBalanceUsd: asNumber(raw.newPromoBalanceUsd),
};
}
@@ -130,6 +151,7 @@ export function normalizeRewardsSnapshot(payload: unknown): RewardsSnapshot {
unlockedCount: asNumber(rawSummary.unlockedCount),
totalCount: asNumber(rawSummary.totalCount),
assignedDiscordRoleCount: asNumber(rawSummary.assignedDiscordRoleCount),
claimableCount: asNumber(rawSummary.claimableCount),
plan:
rawSummary.plan === 'BASIC' || rawSummary.plan === 'PRO' || rawSummary.plan === 'FREE'
? rawSummary.plan
@@ -192,6 +214,42 @@ export const rewardsApi = {
return normalizeRewardsSnapshot(response.data);
},
async claimReward(rewardType: string): Promise<RewardClaimResult> {
let response: ApiResponse<unknown>;
try {
response = await apiClient.post<ApiResponse<unknown>>(
'/rewards/claim',
{ rewardType },
{ timeout: REWARDS_SNAPSHOT_TIMEOUT_MS }
);
} catch (transportError) {
const normalized = normalizeRewardsApiError(transportError);
log('claim transport failed reward=%s error=%s', rewardType, normalized.error);
throw normalized;
}
if (!response.success) {
// Preserve the backend's exact message (e.g. "not unlocked yet", "no active
// paid subscription") so the UI can surface the real, actionable signal.
const appError: RewardsApiError = {
success: false,
error: response.error ?? response.message ?? 'Unable to claim reward',
};
log('claim backend error reward=%s error=%s', rewardType, appError.error);
throw appError;
}
const result = normalizeClaimResult(response.data);
log(
'claimed reward=%s tokens=%d amountUsd=%d alreadyClaimed=%s',
result.reward,
result.tokens,
result.amountUsd,
result.alreadyClaimed
);
return result;
},
async disconnectDiscord(): Promise<void> {
let response: ApiResponse<unknown>;
try {
@@ -42,6 +42,8 @@ function makeAchievement(overrides: Partial<RewardsAchievement> = {}): RewardsAc
roleId: null,
discordRoleStatus: 'unavailable',
creditAmountUsd: null,
rewardTokens: null,
rewardRecurring: false,
...overrides,
};
}
+29
View File
@@ -24,6 +24,8 @@ export interface RewardsSnapshot {
unlockedCount: number;
totalCount: number;
assignedDiscordRoleCount: number;
/** Optional: absent when talking to a backend without the claim feature. */
claimableCount?: number;
plan: 'FREE' | 'BASIC' | 'PRO';
hasActiveSubscription: boolean;
};
@@ -49,4 +51,31 @@ export interface RewardsAchievement {
roleId: string | null;
discordRoleStatus: RewardsDiscordRoleStatus;
creditAmountUsd: number | null;
/** Token reward advertised for this achievement (one-time or monthly amount). */
rewardTokens: number | null;
/** True when the token reward recurs monthly (subscriber tiers). */
rewardRecurring: boolean;
// Claim fields are optional so an older backend (without the claim feature)
// still yields a valid snapshot; the normalizer always populates them.
/** True when the user can claim this reward's credit right now. */
claimable?: boolean;
/** True when the credit has already been claimed (this calendar month if recurring). */
claimed?: boolean;
/** When the reward was claimed (ISO string), or null if not yet claimed. */
claimedAt?: string | null;
/** Calendar month (YYYY-MM) a recurring reward's claim state refers to; null for one-time. */
claimPeriod?: string | null;
}
/** Result of a successful (or idempotent) POST /rewards/claim. */
export interface RewardClaimResult {
reward: string;
recurring: boolean;
period: string | null;
tokens: number;
amountUsd: number;
/** True when the reward had already been claimed (idempotent re-claim). */
alreadyClaimed: boolean;
claimedAt: string | null;
newPromoBalanceUsd: number;
}
@@ -26,7 +26,7 @@ import {
* state, not Redux, so we read the rendered text instead). High-usage
* scenario sets featuresUsedCount=6; we confirm cumulativeTokens render
* reflects the high number.
* - 12.2.2 usage metrics: assert the `Current streak` + `Cumulative tokens`
* - 12.2.2 usage metrics: assert the `Activity streak` + `Cumulative tokens`
* rows in the metrics footer reflect the high-usage scenario values.
* - 12.2.3 state persistence: switch to `post_restart` (same metric values,
* later `lastSyncedAt`) to simulate a backend re-sync after the app
@@ -154,9 +154,9 @@ describe('Rewards progression & persistence', () => {
await waitForText('Your Progress', 15_000);
await waitForRewardsSnapshot();
// Current streak row in the metrics footer.
expect(await textExists('Current streak')).toBe(true);
expect(await getRewardsMetricValue('Current streak')).toBe('14');
// Activity streak row in the metrics footer.
expect(await textExists('Activity streak')).toBe(true);
expect(await getRewardsMetricValue('Activity streak')).toBe('14 days');
// Cumulative tokens row — value formatted via en-US Intl.NumberFormat
// (see RewardsCommunityTab.formatNumber). 12_500_000 → "12,500,000".
@@ -182,7 +182,7 @@ describe('Rewards progression & persistence', () => {
await waitForRewardsSnapshot();
// Capture the durable counters from the rendered DOM before the restart.
expect(await getRewardsMetricValue('Current streak')).toBe('14');
expect(await getRewardsMetricValue('Activity streak')).toBe('14 days');
expect(await getRewardsMetricValue('Cumulative tokens')).toBe('12,500,000');
// Phase 2: simulate a restart by unmounting Rewards (navigate away),
@@ -202,7 +202,7 @@ describe('Rewards progression & persistence', () => {
await waitForRewardsSnapshot();
// Durable counters must survive the restart unchanged.
expect(await getRewardsMetricValue('Current streak')).toBe('14');
expect(await getRewardsMetricValue('Activity streak')).toBe('14 days');
expect(await getRewardsMetricValue('Cumulative tokens')).toBe('12,500,000');
expect(await textExists('3 of 3 achievements unlocked')).toBe(true);
@@ -265,6 +265,6 @@ describe('Rewards progression & persistence', () => {
await waitForRewardsSnapshot();
expect(await textExists('3 of 3 achievements unlocked')).toBe(true);
expect(await getRewardsMetricValue('Current streak')).toBe('14');
expect(await getRewardsMetricValue('Activity streak')).toBe('14 days');
});
});
@@ -55,8 +55,8 @@ test.describe('Rewards Progression Persistence', () => {
await setMockBehavior('rewardsScenario', 'high_usage');
await gotoRewards(page, 'pw-rewards-progress-metrics');
await expect(page.getByText('Current streak')).toBeVisible();
await expect(page.getByText('14')).toBeVisible();
await expect(page.getByText('Activity streak')).toBeVisible();
await expect(page.getByText('14 days')).toBeVisible();
await expect(page.getByText('Cumulative tokens')).toBeVisible();
await expect(page.getByText('12,500,000')).toBeVisible();
});
@@ -67,8 +67,8 @@ test.describe('Rewards Progression Persistence', () => {
await setMockBehavior('rewardsLastSyncedAt', '2026-04-28T09:00:00.000Z');
await gotoRewards(page, 'pw-rewards-progress-persist');
await expect(page.getByText('Current streak')).toBeVisible();
await expect(page.getByText('14')).toBeVisible();
await expect(page.getByText('Activity streak')).toBeVisible();
await expect(page.getByText('14 days')).toBeVisible();
await expect(page.getByText('12,500,000')).toBeVisible();
await setMockBehavior('rewardsScenario', 'post_restart');
@@ -82,8 +82,8 @@ test.describe('Rewards Progression Persistence', () => {
await expect(page.getByText('Your Progress')).toBeVisible();
await expect(page.getByText('3 of 3 achievements unlocked')).toBeVisible();
await expect(page.getByText('Current streak')).toBeVisible();
await expect(page.getByText('14')).toBeVisible();
await expect(page.getByText('Activity streak')).toBeVisible();
await expect(page.getByText('14 days')).toBeVisible();
await expect(page.getByText('12,500,000')).toBeVisible();
await expect.poll(() => rewardsRequestCount()).toBeGreaterThanOrEqual(2);
});
+2
View File
@@ -66,6 +66,8 @@ const INTENTIONAL_ENGLISH = new Set([
"memorySources.globPatternPlaceholder",
"modelCouncil.editCouncilAria",
"modelCouncil.jurorLabel",
"rewards.community.discordDetails", // "Discord" — brand/product name, same in every locale
"rewards.community.rewardTokens", // "+{tokens} tokens" — "tokens" is the technical unit, kept in every locale (the recurring "/mo" variant IS translated)
"nav.agentWorld",
"memorySources.searchQueryPlaceholder",
"migration.vendor.hermes",