mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(rewards): surface achievement token rewards and fix rewards page display (#4424)
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 d’activité 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 d’activité',
|
||||
'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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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': 'Синхронизация недоступна',
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user