diff --git a/app/src/components/rewards/RewardsCommunityTab.tsx b/app/src/components/rewards/RewardsCommunityTab.tsx index bba6a562a..7447e3eb0 100644 --- a/app/src/components/rewards/RewardsCommunityTab.tsx +++ b/app/src/components/rewards/RewardsCommunityTab.tsx @@ -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; 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>(() => new Set()); + const [claimedFeedback, setClaimedFeedback] = useState>({}); + const [claimErrors, setClaimErrors] = useState>({}); 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(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 ( <>
@@ -327,6 +430,9 @@ export default function RewardsCommunityTab({ {achievementSlots.map((role, index) => (
{role.description}

+ {!role.unlocked && role.progressLabel ? ( +

+ {role.progressLabel} +

+ ) : null} + {role.rewardTokens ? ( +

+ {(role.rewardRecurring + ? t('rewards.community.rewardTokensMonthly') + : t('rewards.community.rewardTokens') + ).replace('{tokens}', formatTokens(role.rewardTokens))} +

+ ) : null}
@@ -456,6 +586,60 @@ export default function RewardsCommunityTab({
) : null} + {showClaimFooter ? ( +
+ {claimed ? ( + <> + + + {t('rewards.community.claimed')} + + {feedback ? ( + + {t('rewards.community.claimCredited').replace('{amount}', feedback)} + + ) : null} + + ) : ( + <> + + {claimError ? ( + + {claimError} + + ) : null} + + )} +
+ ) : null} ); }) @@ -471,7 +655,14 @@ export default function RewardsCommunityTab({ )}
-
+ {/* Discord-specific status — kept separate from product activity metrics + so the two are no longer conflated in a single list. */} +
+

+ {t('rewards.community.discordDetails')} +

{t('rewards.community.discordServer')} @@ -500,13 +691,24 @@ export default function RewardsCommunityTab({ {t('rewards.community.roleAssignmentCount') .replace('{assigned}', String(assignedRoleCount)) - .replace('{unlocked}', String(unlocked))} + .replace('{unlocked}', String(assignableRoleCount))}
) : null} -
+
+ + {/* Product-usage metrics — the activity streak counts consecutive days the + user actually used OpenHuman (token-processing days), not a check-in. */} +
+

{t('rewards.community.activityTitle')}

+

+ {t('rewards.community.activityStreakHint')} +

+
{t('rewards.community.currentStreak')} - + {snapshot ? t('rewards.community.streakDays').replace( '{n}', @@ -515,6 +717,17 @@ export default function RewardsCommunityTab({ : t('rewards.community.unknown')}
+
+ {t('rewards.community.longestStreak')} + + {snapshot + ? t('rewards.community.streakDays').replace( + '{n}', + String(snapshot.metrics.longestStreakDays) + ) + : t('rewards.community.unknown')} + +
{t('rewards.community.cumulativeTokens')} diff --git a/app/src/components/rewards/__tests__/RewardsCommunityTab.test.tsx b/app/src/components/rewards/__tests__/RewardsCommunityTab.test.tsx index a03648039..7bcd0578b 100644 --- a/app/src/components/rewards/__tests__/RewardsCommunityTab.test.tsx +++ b/app/src/components/rewards/__tests__/RewardsCommunityTab.test.tsx @@ -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 = {}) => ({ + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + fireEvent.click(screen.getByTestId('rewards-claim-role-1')); + await waitFor(() => expect(onSilentRefresh).toHaveBeenCalledTimes(1)); + + rerender( + + + + ); + + // 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( + + + + ); + + 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( + + + + ); + + // 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + // 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'); + }); +}); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index d40b1d5ac..fbde7a517 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': 'المزامنة غير متاحة', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 30ef6c016..3f9ed538d 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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': 'সিঙ্ক পাওয়া যাচ্ছে না', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 54bc7e893..8d2ba158d 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index fedc96011..7111c751b 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index facead313..ca549b36c 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index d2ef585b6..5caf845b2 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index a702e5c14..7db36e667 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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': 'सिंक उपलब्ध नहीं', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index a28b32317..76c8941be 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index ec8f20cd8..613a8cbca 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 49705223e..0a5c175f4 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 07d044c4a..971aa3172 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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': diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 870816980..15755004d 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index dbcecd687..b7a17b96e 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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': 'Синхронизация недоступна', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 8f3310885..78453ad35 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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': diff --git a/app/src/pages/Rewards.tsx b/app/src/pages/Rewards.tsx index de940b026..f31810e5a 100644 --- a/app/src/pages/Rewards.tsx +++ b/app/src/pages/Rewards.tsx @@ -39,31 +39,45 @@ const Rewards = () => { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(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} /> )} diff --git a/app/src/pages/__tests__/Rewards.test.tsx b/app/src/pages/__tests__/Rewards.test.tsx index 6eb87b4fa..dc2dbf427 100644 --- a/app/src/pages/__tests__/Rewards.test.tsx +++ b/app/src/pages/__tests__/Rewards.test.tsx @@ -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( + + + + ); + + 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: { diff --git a/app/src/services/api/__tests__/rewardsApi.test.ts b/app/src/services/api/__tests__/rewardsApi.test.ts index e953a095e..8ec6817cf 100644 --- a/app/src/services/api/__tests__/rewardsApi.test.ts +++ b/app/src/services/api/__tests__/rewardsApi.test.ts @@ -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', + }); }); }); diff --git a/app/src/services/api/rewardsApi.ts b/app/src/services/api/rewardsApi.ts index 109c67874..9c4a50485 100644 --- a/app/src/services/api/rewardsApi.ts +++ b/app/src/services/api/rewardsApi.ts @@ -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 { + let response: ApiResponse; + try { + response = await apiClient.post>( + '/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 { let response: ApiResponse; try { diff --git a/app/src/store/__tests__/rewardsSlice.test.ts b/app/src/store/__tests__/rewardsSlice.test.ts index 9d0ec4ad2..40a9c4238 100644 --- a/app/src/store/__tests__/rewardsSlice.test.ts +++ b/app/src/store/__tests__/rewardsSlice.test.ts @@ -42,6 +42,8 @@ function makeAchievement(overrides: Partial = {}): RewardsAc roleId: null, discordRoleStatus: 'unavailable', creditAmountUsd: null, + rewardTokens: null, + rewardRecurring: false, ...overrides, }; } diff --git a/app/src/types/rewards.ts b/app/src/types/rewards.ts index a299dae37..2ba0d4046 100644 --- a/app/src/types/rewards.ts +++ b/app/src/types/rewards.ts @@ -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; } diff --git a/app/test/e2e/specs/rewards-progression-persistence.spec.ts b/app/test/e2e/specs/rewards-progression-persistence.spec.ts index a4f393ac0..a69e2ecf1 100644 --- a/app/test/e2e/specs/rewards-progression-persistence.spec.ts +++ b/app/test/e2e/specs/rewards-progression-persistence.spec.ts @@ -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'); }); }); diff --git a/app/test/playwright/specs/rewards-progression-persistence.spec.ts b/app/test/playwright/specs/rewards-progression-persistence.spec.ts index 9d5cc1043..2df0bb145 100644 --- a/app/test/playwright/specs/rewards-progression-persistence.spec.ts +++ b/app/test/playwright/specs/rewards-progression-persistence.spec.ts @@ -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); }); diff --git a/scripts/i18n-find-english.ts b/scripts/i18n-find-english.ts index 56a63669b..ae186c9e5 100644 --- a/scripts/i18n-find-english.ts +++ b/scripts/i18n-find-english.ts @@ -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",