mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(rewards): Discord connect + disconnect/re-link on the Rewards page (#3766)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e2355ba142
commit
1f00ab79b1
@@ -2,6 +2,7 @@ import createDebug from 'debug';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { rewardsApi } from '../../services/api/rewardsApi';
|
||||
import { callCoreRpc } from '../../services/coreRpcClient';
|
||||
import type { RewardsAchievement, RewardsSnapshot } from '../../types/rewards';
|
||||
import { DISCORD_INVITE_URL } from '../../utils/links';
|
||||
@@ -91,6 +92,9 @@ export default function RewardsCommunityTab({
|
||||
}: RewardsCommunityTabProps) {
|
||||
const { t } = useT();
|
||||
const [connectState, setConnectState] = useState<'idle' | 'connecting' | 'error'>('idle');
|
||||
const [disconnectState, setDisconnectState] = useState<'idle' | 'disconnecting' | 'error'>(
|
||||
'idle'
|
||||
);
|
||||
const rewardRoles: RewardsAchievement[] = snapshot?.achievements ?? [];
|
||||
const unlocked =
|
||||
snapshot?.summary.unlockedCount ?? rewardRoles.filter(role => role.unlocked).length;
|
||||
@@ -103,6 +107,13 @@ export default function RewardsCommunityTab({
|
||||
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;
|
||||
// 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 showClaimBanner =
|
||||
discordLinked && membershipStatus === 'not_in_guild' && hasUnlockedConfiguredRole;
|
||||
|
||||
const handleConnectDiscord = useCallback(async () => {
|
||||
log('connect discord requested');
|
||||
@@ -129,6 +140,23 @@ export default function RewardsCommunityTab({
|
||||
setConnectState('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDisconnectDiscord = useCallback(async () => {
|
||||
log('disconnect discord requested');
|
||||
setDisconnectState('disconnecting');
|
||||
try {
|
||||
// Clears user.discordId/discordUsername on the backend (idempotent), which flips the
|
||||
// rewards snapshot back to unlinked.
|
||||
await rewardsApi.disconnectDiscord();
|
||||
log('disconnect discord ok; refreshing snapshot');
|
||||
setDisconnectState('idle');
|
||||
// Refetch the snapshot so the connected state flips back to the Connect button (re-link path).
|
||||
onRetry?.();
|
||||
} catch (err) {
|
||||
log('disconnect discord failed error=%s', err instanceof Error ? err.message : String(err));
|
||||
setDisconnectState('error');
|
||||
}
|
||||
}, [onRetry]);
|
||||
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)]">
|
||||
@@ -143,16 +171,36 @@ export default function RewardsCommunityTab({
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
{discordLinked ? (
|
||||
<div
|
||||
data-testid="rewards-discord-connected"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-white/15 px-4 py-3 text-sm font-semibold text-white">
|
||||
<svg className="h-4 w-4" 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>
|
||||
{discordUsername
|
||||
? t('rewards.community.discordConnectedAs').replace('{username}', discordUsername)
|
||||
: t('rewards.community.discordConnected')}
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
data-testid="rewards-discord-connected"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-white/15 px-4 py-3 text-sm font-semibold text-white">
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
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>
|
||||
{discordUsername
|
||||
? t('rewards.community.discordConnectedAs').replace(
|
||||
'{username}',
|
||||
discordUsername
|
||||
)
|
||||
: t('rewards.community.discordConnected')}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
void handleDisconnectDiscord();
|
||||
}}
|
||||
disabled={disconnectState === 'disconnecting'}
|
||||
data-testid="rewards-disconnect-discord"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/20 bg-white/10 px-4 py-3 text-sm font-semibold text-white backdrop-blur-sm transition-colors hover:bg-white/15 disabled:cursor-not-allowed disabled:opacity-70">
|
||||
{disconnectState === 'disconnecting'
|
||||
? t('rewards.community.disconnectingDiscord')
|
||||
: t('rewards.community.disconnectDiscord')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -198,6 +246,14 @@ export default function RewardsCommunityTab({
|
||||
{t('rewards.community.connectDiscordError')}
|
||||
</p>
|
||||
) : null}
|
||||
{discordLinked && disconnectState === 'error' ? (
|
||||
<p
|
||||
role="alert"
|
||||
data-testid="rewards-disconnect-discord-error"
|
||||
className="text-xs font-medium text-white/90">
|
||||
{t('rewards.community.disconnectDiscordError')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="absolute -right-10 -top-10 h-32 w-32 rounded-full bg-white/10 blur-2xl" />
|
||||
<div className="absolute -bottom-10 -left-8 h-24 w-24 rounded-full bg-white/15 blur-xl" />
|
||||
@@ -291,6 +347,30 @@ export default function RewardsCommunityTab({
|
||||
{t('rewards.community.rolesAndRewards')}
|
||||
</h2>
|
||||
</div>
|
||||
{showClaimBanner ? (
|
||||
<div
|
||||
role="status"
|
||||
data-testid="rewards-claim-roles-banner"
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-blue-100 dark:border-blue-500/30 bg-blue-50 dark:bg-blue-500/10 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-bold text-stone-900 dark:text-neutral-100">
|
||||
{t('rewards.community.roleClaimTitle')}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-stone-600 dark:text-neutral-300">
|
||||
{t('rewards.community.roleClaimDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="rewards-claim-roles-join"
|
||||
onClick={() => {
|
||||
void openUrl(inviteUrl);
|
||||
}}
|
||||
className="inline-flex flex-shrink-0 items-center justify-center gap-2 rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-primary-700">
|
||||
{t('rewards.community.joinDiscord')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-5 shadow-soft">
|
||||
<div className="text-sm text-stone-600 dark:text-neutral-300">
|
||||
@@ -300,6 +380,30 @@ export default function RewardsCommunityTab({
|
||||
) : rewardRoles.length > 0 ? (
|
||||
rewardRoles.map((role, index) => {
|
||||
const tone = roleAccentTone(index);
|
||||
// Surface Discord role-assignment status only for a linked user's unlocked
|
||||
// achievements — locked badges have no role to claim yet.
|
||||
const roleStatus =
|
||||
discordLinked && role.unlocked
|
||||
? role.discordRoleStatus === 'assigned'
|
||||
? {
|
||||
label: t('rewards.community.roleAssigned'),
|
||||
classes:
|
||||
'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300',
|
||||
}
|
||||
: role.discordRoleStatus === 'not_assigned'
|
||||
? {
|
||||
label: t('rewards.community.rolePending'),
|
||||
classes:
|
||||
'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-300',
|
||||
}
|
||||
: role.discordRoleStatus === 'not_in_guild'
|
||||
? {
|
||||
label: t('rewards.community.roleJoinToClaim'),
|
||||
classes:
|
||||
'bg-blue-50 text-primary-700 dark:bg-blue-500/10 dark:text-primary-300',
|
||||
}
|
||||
: null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -345,6 +449,15 @@ export default function RewardsCommunityTab({
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{roleStatus ? (
|
||||
<div className="mt-3">
|
||||
<span
|
||||
data-testid={`rewards-role-status-${role.id}`}
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-semibold ${roleStatus.classes}`}>
|
||||
{roleStatus.label}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
@@ -385,6 +498,18 @@ export default function RewardsCommunityTab({
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{discordLinked && membershipStatus === 'member' ? (
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<span>{t('rewards.community.rolesAndRewards')}</span>
|
||||
<span
|
||||
data-testid="rewards-roles-assigned"
|
||||
className="font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{t('rewards.community.roleAssignmentCount')
|
||||
.replace('{assigned}', String(assignedRoleCount))
|
||||
.replace('{unlocked}', String(unlocked))}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<span>{t('rewards.community.currentStreak')}</span>
|
||||
<span className="font-semibold text-stone-900 dark:text-neutral-100">
|
||||
|
||||
@@ -9,14 +9,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { RewardsSnapshot } from '../../../types/rewards';
|
||||
|
||||
const { openUrl, callCoreRpc, setOAuthReturnRoute } = vi.hoisted(() => ({
|
||||
const { openUrl, callCoreRpc, setOAuthReturnRoute, disconnectDiscord } = vi.hoisted(() => ({
|
||||
openUrl: vi.fn(),
|
||||
callCoreRpc: vi.fn(),
|
||||
setOAuthReturnRoute: vi.fn(),
|
||||
disconnectDiscord: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/openUrl', () => ({ openUrl }));
|
||||
vi.mock('../../../services/coreRpcClient', () => ({ callCoreRpc }));
|
||||
vi.mock('../../../services/api/rewardsApi', () => ({ rewardsApi: { disconnectDiscord } }));
|
||||
vi.mock('../../../utils/oauthReturnRoute', () => ({ setOAuthReturnRoute }));
|
||||
|
||||
function buildSnapshot(): RewardsSnapshot {
|
||||
@@ -174,3 +176,134 @@ describe('RewardsCommunityTab — Connect Discord', () => {
|
||||
expect(screen.queryByTestId('rewards-connect-discord')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RewardsCommunityTab — Disconnect Discord', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('disconnects Discord and refreshes the snapshot', async () => {
|
||||
disconnectDiscord.mockResolvedValueOnce(undefined);
|
||||
const onRetry = vi.fn();
|
||||
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RewardsCommunityTab
|
||||
error={null}
|
||||
isLoading={false}
|
||||
onRetry={onRetry}
|
||||
snapshot={buildSnapshot()}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('rewards-disconnect-discord'));
|
||||
|
||||
await waitFor(() => expect(disconnectDiscord).toHaveBeenCalledTimes(1));
|
||||
// Snapshot is refetched so the connected state can flip back to Connect (re-link path).
|
||||
await waitFor(() => expect(onRetry).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByTestId('rewards-disconnect-discord-error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces an error and does not refetch when disconnect fails', async () => {
|
||||
disconnectDiscord.mockRejectedValueOnce(new Error('disconnect failed'));
|
||||
const onRetry = vi.fn();
|
||||
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RewardsCommunityTab
|
||||
error={null}
|
||||
isLoading={false}
|
||||
onRetry={onRetry}
|
||||
snapshot={buildSnapshot()}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('rewards-disconnect-discord'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('rewards-disconnect-discord-error')).toBeInTheDocument()
|
||||
);
|
||||
expect(onRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RewardsCommunityTab — Discord role assignment', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows an assigned badge and the assigned-count for an in-guild member', async () => {
|
||||
// buildSnapshot: member, role-1 unlocked + assigned, role-2 locked.
|
||||
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RewardsCommunityTab error={null} isLoading={false} snapshot={buildSnapshot()} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('rewards-role-status-role-1')).toHaveTextContent('Role assigned');
|
||||
// Locked achievements have no role to claim yet, so no badge.
|
||||
expect(screen.queryByTestId('rewards-role-status-role-2')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('rewards-roles-assigned')).toHaveTextContent('1 of 1 roles assigned');
|
||||
// Already in the guild -> no join-to-claim prompt.
|
||||
expect(screen.queryByTestId('rewards-claim-roles-banner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a pending badge when an unlocked achievement has no role assigned yet', async () => {
|
||||
const snapshot = buildSnapshot();
|
||||
snapshot.achievements[0].discordRoleStatus = 'not_assigned';
|
||||
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RewardsCommunityTab error={null} isLoading={false} snapshot={snapshot} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('rewards-role-status-role-1')).toHaveTextContent('Syncing role');
|
||||
});
|
||||
|
||||
it('prompts a connected non-member to join the server to claim unlocked roles', async () => {
|
||||
const snapshot = buildSnapshot();
|
||||
snapshot.discord.membershipStatus = 'not_in_guild';
|
||||
snapshot.achievements[0].discordRoleStatus = 'not_in_guild';
|
||||
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RewardsCommunityTab error={null} isLoading={false} snapshot={snapshot} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('rewards-claim-roles-banner')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('rewards-role-status-role-1')).toHaveTextContent(
|
||||
'Join server to claim'
|
||||
);
|
||||
// The member-only assigned-count row is hidden when the user is not in the guild.
|
||||
expect(screen.queryByTestId('rewards-roles-assigned')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('rewards-claim-roles-join'));
|
||||
expect(openUrl).toHaveBeenCalledWith('https://discord.gg/example');
|
||||
});
|
||||
|
||||
it('hides role-assignment status entirely when Discord is not linked', async () => {
|
||||
const snapshot = buildSnapshot();
|
||||
snapshot.discord = {
|
||||
linked: false,
|
||||
discordId: null,
|
||||
username: null,
|
||||
inviteUrl: 'https://discord.gg/example',
|
||||
membershipStatus: 'not_linked',
|
||||
};
|
||||
const { default: RewardsCommunityTab } = await import('../RewardsCommunityTab');
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RewardsCommunityTab error={null} isLoading={false} snapshot={snapshot} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('rewards-role-status-role-1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('rewards-claim-roles-banner')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('rewards-roles-assigned')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3181,6 +3181,9 @@ const messages: TranslationMap = {
|
||||
'rewards.community.connectingDiscord': 'جارٍ الاتصال…',
|
||||
'rewards.community.cumulativeTokens': 'الرموز التراكمية',
|
||||
'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}',
|
||||
@@ -3196,6 +3199,13 @@ const messages: TranslationMap = {
|
||||
'rewards.community.loadingRewards': 'جارٍ تحميل المكافآت…',
|
||||
'rewards.community.locked': 'مفتوح',
|
||||
'rewards.community.retrying': 'جارٍ إعادة المحاولة…',
|
||||
'rewards.community.roleAssigned': 'تم تعيين الرتبة',
|
||||
'rewards.community.roleAssignmentCount': 'تم تعيين {assigned} من {unlocked} رتبة',
|
||||
'rewards.community.roleClaimDesc':
|
||||
'لقد فتحت رتب Discord لكنك لم تنضم بعد إلى خادم OpenHuman. انضم ليتم تعيينها تلقائيًا.',
|
||||
'rewards.community.roleClaimTitle': 'احصل على رتب Discord الخاصة بك',
|
||||
'rewards.community.roleJoinToClaim': 'انضم إلى الخادم للحصول عليها',
|
||||
'rewards.community.rolePending': 'جارٍ مزامنة الرتبة…',
|
||||
'rewards.community.rolesAndRewards': 'الأدوار والمكافآت',
|
||||
'rewards.community.streakDays': '{n}',
|
||||
'rewards.community.syncPending': 'مزامنة المكافآت معلقة',
|
||||
|
||||
@@ -3252,6 +3252,10 @@ const messages: TranslationMap = {
|
||||
'rewards.community.connectingDiscord': 'সংযুক্ত হচ্ছে…',
|
||||
'rewards.community.cumulativeTokens': 'সঞ্চিত টোকেন',
|
||||
'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} হিসেবে সংযুক্ত',
|
||||
@@ -3267,6 +3271,13 @@ const messages: TranslationMap = {
|
||||
'rewards.community.loadingRewards': 'পুরস্কার লোড হচ্ছে…',
|
||||
'rewards.community.locked': 'আনলক করা',
|
||||
'rewards.community.retrying': 'আবার চেষ্টা হচ্ছে…',
|
||||
'rewards.community.roleAssigned': 'রোল বরাদ্দ হয়েছে',
|
||||
'rewards.community.roleAssignmentCount': '{unlocked}টির মধ্যে {assigned}টি রোল বরাদ্দ হয়েছে',
|
||||
'rewards.community.roleClaimDesc':
|
||||
'আপনি Discord রোল আনলক করেছেন কিন্তু এখনও OpenHuman সার্ভারে যোগ দেননি। যোগ দিন যাতে সেগুলি স্বয়ংক্রিয়ভাবে বরাদ্দ হয়।',
|
||||
'rewards.community.roleClaimTitle': 'আপনার Discord রোল দাবি করুন',
|
||||
'rewards.community.roleJoinToClaim': 'দাবি করতে সার্ভারে যোগ দিন',
|
||||
'rewards.community.rolePending': 'রোল সিঙ্ক হচ্ছে…',
|
||||
'rewards.community.rolesAndRewards': 'রোল ও পুরস্কার',
|
||||
'rewards.community.streakDays': '{n}',
|
||||
'rewards.community.syncPending': 'পুরস্কার সিঙ্ক মুলতুবি',
|
||||
|
||||
@@ -3334,6 +3334,10 @@ const messages: TranslationMap = {
|
||||
'rewards.community.connectingDiscord': 'Verbinden…',
|
||||
'rewards.community.cumulativeTokens': 'Kumulierte Token',
|
||||
'rewards.community.currentStreak': 'Aktuelle Serie',
|
||||
'rewards.community.disconnectDiscord': 'Trennen',
|
||||
'rewards.community.disconnectDiscordError':
|
||||
'Discord konnte nicht getrennt werden. Bitte versuche es erneut.',
|
||||
'rewards.community.disconnectingDiscord': 'Wird getrennt…',
|
||||
'rewards.community.discordAccount': 'Discord-Konto',
|
||||
'rewards.community.discordConnected': 'Discord verbunden',
|
||||
'rewards.community.discordConnectedAs': 'Verbunden als {username}',
|
||||
@@ -3350,6 +3354,13 @@ const messages: TranslationMap = {
|
||||
'rewards.community.loadingRewards': 'Prämien werden geladen…',
|
||||
'rewards.community.locked': 'Gesperrt',
|
||||
'rewards.community.retrying': 'Erneuter Versuch…',
|
||||
'rewards.community.roleAssigned': 'Rolle zugewiesen',
|
||||
'rewards.community.roleAssignmentCount': '{assigned} von {unlocked} Rollen zugewiesen',
|
||||
'rewards.community.roleClaimDesc':
|
||||
'Du hast Discord-Rollen freigeschaltet, bist dem OpenHuman-Server aber noch nicht beigetreten. Tritt bei, damit sie automatisch zugewiesen werden.',
|
||||
'rewards.community.roleClaimTitle': 'Hol dir deine Discord-Rollen',
|
||||
'rewards.community.roleJoinToClaim': 'Server beitreten zum Abholen',
|
||||
'rewards.community.rolePending': 'Rolle wird synchronisiert…',
|
||||
'rewards.community.rolesAndRewards': 'Rollen und Belohnungen',
|
||||
'rewards.community.streakDays': '{n} Tage',
|
||||
'rewards.community.syncPending': 'Synchronisierung der Prämien steht aus',
|
||||
|
||||
@@ -3851,6 +3851,9 @@ const en: TranslationMap = {
|
||||
'rewards.community.connectingDiscord': 'Connecting…',
|
||||
'rewards.community.cumulativeTokens': 'Cumulative tokens',
|
||||
'rewards.community.currentStreak': 'Current 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}',
|
||||
@@ -3866,6 +3869,13 @@ const en: TranslationMap = {
|
||||
'rewards.community.loadingRewards': 'Loading rewards…',
|
||||
'rewards.community.locked': 'Locked',
|
||||
'rewards.community.retrying': 'Retrying…',
|
||||
'rewards.community.roleAssigned': 'Role assigned',
|
||||
'rewards.community.roleAssignmentCount': '{assigned} of {unlocked} roles assigned',
|
||||
'rewards.community.roleClaimDesc':
|
||||
'You unlocked Discord roles but have not joined the OpenHuman server yet. Join to get them assigned automatically.',
|
||||
'rewards.community.roleClaimTitle': 'Claim your Discord roles',
|
||||
'rewards.community.roleJoinToClaim': 'Join server to claim',
|
||||
'rewards.community.rolePending': 'Syncing role…',
|
||||
'rewards.community.rolesAndRewards': 'Roles & Rewards',
|
||||
'rewards.community.streakDays': '{n}',
|
||||
'rewards.community.syncPending': 'Rewards sync pending',
|
||||
|
||||
@@ -3313,6 +3313,9 @@ const messages: TranslationMap = {
|
||||
'rewards.community.connectingDiscord': 'Conectando…',
|
||||
'rewards.community.cumulativeTokens': 'Tokens acumulados',
|
||||
'rewards.community.currentStreak': 'Racha actual',
|
||||
'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}',
|
||||
@@ -3328,6 +3331,13 @@ const messages: TranslationMap = {
|
||||
'rewards.community.loadingRewards': 'Cargando recompensas…',
|
||||
'rewards.community.locked': 'Desbloqueado',
|
||||
'rewards.community.retrying': 'Reintentando…',
|
||||
'rewards.community.roleAssigned': 'Rol asignado',
|
||||
'rewards.community.roleAssignmentCount': '{assigned} de {unlocked} roles asignados',
|
||||
'rewards.community.roleClaimDesc':
|
||||
'Has desbloqueado roles de Discord, pero aún no te has unido al servidor de OpenHuman. Únete para que se asignen automáticamente.',
|
||||
'rewards.community.roleClaimTitle': 'Reclama tus roles de Discord',
|
||||
'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.syncPending': 'Sincronización de recompensas pendiente',
|
||||
|
||||
@@ -3325,6 +3325,10 @@ const messages: TranslationMap = {
|
||||
'rewards.community.connectingDiscord': 'Connexion…',
|
||||
'rewards.community.cumulativeTokens': 'Tokens cumulés',
|
||||
'rewards.community.currentStreak': 'Série actuelle',
|
||||
'rewards.community.disconnectDiscord': 'Déconnecter',
|
||||
'rewards.community.disconnectDiscordError':
|
||||
'Impossible de déconnecter Discord. Veuillez réessayer.',
|
||||
'rewards.community.disconnectingDiscord': 'Déconnexion…',
|
||||
'rewards.community.discordAccount': 'Compte Discord',
|
||||
'rewards.community.discordConnected': 'Discord connecté',
|
||||
'rewards.community.discordConnectedAs': 'Connecté en tant que {username}',
|
||||
@@ -3340,6 +3344,13 @@ const messages: TranslationMap = {
|
||||
'rewards.community.loadingRewards': 'Chargement des récompenses…',
|
||||
'rewards.community.locked': 'Débloqué',
|
||||
'rewards.community.retrying': 'Nouvelle tentative…',
|
||||
'rewards.community.roleAssigned': 'Rôle attribué',
|
||||
'rewards.community.roleAssignmentCount': '{assigned} sur {unlocked} rôles attribués',
|
||||
'rewards.community.roleClaimDesc':
|
||||
"Vous avez débloqué des rôles Discord mais n'avez pas encore rejoint le serveur OpenHuman. Rejoignez-le pour qu'ils soient attribués automatiquement.",
|
||||
'rewards.community.roleClaimTitle': 'Réclamez vos rôles Discord',
|
||||
'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.syncPending': 'Synchronisation des récompenses en attente',
|
||||
|
||||
@@ -3255,6 +3255,10 @@ const messages: TranslationMap = {
|
||||
'rewards.community.connectingDiscord': 'कनेक्ट हो रहा है…',
|
||||
'rewards.community.cumulativeTokens': 'कुल टोकन',
|
||||
'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} के रूप में कनेक्टेड',
|
||||
@@ -3270,6 +3274,13 @@ const messages: TranslationMap = {
|
||||
'rewards.community.loadingRewards': 'रिवॉर्ड लोड हो रहे हैं…',
|
||||
'rewards.community.locked': 'अनलॉक्ड',
|
||||
'rewards.community.retrying': 'फिर से कोशिश हो रही है…',
|
||||
'rewards.community.roleAssigned': 'रोल असाइन हो गया',
|
||||
'rewards.community.roleAssignmentCount': '{unlocked} में से {assigned} रोल असाइन किए गए',
|
||||
'rewards.community.roleClaimDesc':
|
||||
'आपने Discord रोल अनलॉक किए हैं लेकिन अभी तक OpenHuman सर्वर में शामिल नहीं हुए हैं। शामिल हों ताकि वे अपने-आप असाइन हो जाएँ।',
|
||||
'rewards.community.roleClaimTitle': 'अपने Discord रोल क्लेम करें',
|
||||
'rewards.community.roleJoinToClaim': 'क्लेम करने के लिए सर्वर जॉइन करें',
|
||||
'rewards.community.rolePending': 'रोल सिंक हो रहा है…',
|
||||
'rewards.community.rolesAndRewards': 'रोल्स और रिवॉर्ड',
|
||||
'rewards.community.streakDays': '{n}',
|
||||
'rewards.community.syncPending': 'रिवॉर्ड सिंक पेंडिंग',
|
||||
|
||||
@@ -3260,6 +3260,9 @@ const messages: TranslationMap = {
|
||||
'rewards.community.connectingDiscord': 'Menghubungkan…',
|
||||
'rewards.community.cumulativeTokens': 'Token kumulatif',
|
||||
'rewards.community.currentStreak': 'Streak saat ini',
|
||||
'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}',
|
||||
@@ -3275,6 +3278,13 @@ const messages: TranslationMap = {
|
||||
'rewards.community.loadingRewards': 'Memuat hadiah...',
|
||||
'rewards.community.locked': 'Terbuka',
|
||||
'rewards.community.retrying': 'Mencoba ulang...',
|
||||
'rewards.community.roleAssigned': 'Role ditetapkan',
|
||||
'rewards.community.roleAssignmentCount': '{assigned} dari {unlocked} role ditetapkan',
|
||||
'rewards.community.roleClaimDesc':
|
||||
'Kamu telah membuka role Discord tetapi belum bergabung ke server OpenHuman. Bergabunglah agar role ditetapkan secara otomatis.',
|
||||
'rewards.community.roleClaimTitle': 'Klaim role Discord kamu',
|
||||
'rewards.community.roleJoinToClaim': 'Gabung server untuk klaim',
|
||||
'rewards.community.rolePending': 'Menyinkronkan role…',
|
||||
'rewards.community.rolesAndRewards': 'Peran & Hadiah',
|
||||
'rewards.community.streakDays': '{n}',
|
||||
'rewards.community.syncPending': 'Sinkronisasi hadiah tertunda',
|
||||
|
||||
@@ -3305,6 +3305,9 @@ const messages: TranslationMap = {
|
||||
'rewards.community.connectingDiscord': 'Connessione…',
|
||||
'rewards.community.cumulativeTokens': 'Token cumulativi',
|
||||
'rewards.community.currentStreak': 'Streak attuale',
|
||||
'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}',
|
||||
@@ -3320,6 +3323,13 @@ const messages: TranslationMap = {
|
||||
'rewards.community.loadingRewards': 'Caricamento premi…',
|
||||
'rewards.community.locked': 'Bloccato',
|
||||
'rewards.community.retrying': 'Nuovo tentativo…',
|
||||
'rewards.community.roleAssigned': 'Ruolo assegnato',
|
||||
'rewards.community.roleAssignmentCount': '{assigned} di {unlocked} ruoli assegnati',
|
||||
'rewards.community.roleClaimDesc':
|
||||
'Hai sbloccato i ruoli Discord ma non ti sei ancora unito al server OpenHuman. Unisciti per assegnarli automaticamente.',
|
||||
'rewards.community.roleClaimTitle': 'Riscatta i tuoi ruoli Discord',
|
||||
'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.syncPending': 'Sincronizzazione premi in attesa',
|
||||
|
||||
@@ -3224,6 +3224,10 @@ const messages: TranslationMap = {
|
||||
'rewards.community.connectingDiscord': '연결 중…',
|
||||
'rewards.community.cumulativeTokens': '누적 토큰',
|
||||
'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}(으)로 연결됨',
|
||||
@@ -3240,6 +3244,13 @@ const messages: TranslationMap = {
|
||||
'rewards.community.loadingRewards': '보상 불러오는 중…',
|
||||
'rewards.community.locked': '잠김',
|
||||
'rewards.community.retrying': '다시 시도 중…',
|
||||
'rewards.community.roleAssigned': '역할이 부여됨',
|
||||
'rewards.community.roleAssignmentCount': '{unlocked}개 중 {assigned}개 역할 부여됨',
|
||||
'rewards.community.roleClaimDesc':
|
||||
'Discord 역할을 잠금 해제했지만 아직 OpenHuman 서버에 참여하지 않았습니다. 참여하면 자동으로 부여됩니다.',
|
||||
'rewards.community.roleClaimTitle': 'Discord 역할 받기',
|
||||
'rewards.community.roleJoinToClaim': '받으려면 서버 참여',
|
||||
'rewards.community.rolePending': '역할 동기화 중…',
|
||||
'rewards.community.rolesAndRewards': '역할 및 보상',
|
||||
'rewards.community.streakDays': '{n}일',
|
||||
'rewards.community.syncPending': '보상 동기화 대기 중',
|
||||
|
||||
@@ -3301,6 +3301,10 @@ const messages: TranslationMap = {
|
||||
'rewards.community.connectingDiscord': 'Łączenie…',
|
||||
'rewards.community.cumulativeTokens': 'Łączna liczba tokenów',
|
||||
'rewards.community.currentStreak': 'Aktualna seria',
|
||||
'rewards.community.disconnectDiscord': 'Rozłącz',
|
||||
'rewards.community.disconnectDiscordError':
|
||||
'Nie udało się rozłączyć konta Discord. Spróbuj ponownie.',
|
||||
'rewards.community.disconnectingDiscord': 'Rozłączanie…',
|
||||
'rewards.community.discordAccount': 'Konto Discord',
|
||||
'rewards.community.discordConnected': 'Discord połączony',
|
||||
'rewards.community.discordConnectedAs': 'Połączono jako {username}',
|
||||
@@ -3317,6 +3321,13 @@ const messages: TranslationMap = {
|
||||
'rewards.community.loadingRewards': 'Wczytywanie nagród…',
|
||||
'rewards.community.locked': 'Zablokowane',
|
||||
'rewards.community.retrying': 'Ponawianie…',
|
||||
'rewards.community.roleAssigned': 'Rola przypisana',
|
||||
'rewards.community.roleAssignmentCount': 'Przypisano {assigned} z {unlocked} ról',
|
||||
'rewards.community.roleClaimDesc':
|
||||
'Odblokowałeś role Discord, ale nie dołączyłeś jeszcze do serwera OpenHuman. Dołącz, aby zostały przypisane automatycznie.',
|
||||
'rewards.community.roleClaimTitle': 'Odbierz swoje role Discord',
|
||||
'rewards.community.roleJoinToClaim': 'Dołącz do serwera, aby odebrać',
|
||||
'rewards.community.rolePending': 'Synchronizowanie roli…',
|
||||
'rewards.community.rolesAndRewards': 'Role i nagrody',
|
||||
'rewards.community.streakDays': '{n} dni',
|
||||
'rewards.community.syncPending': 'Synchronizacja nagród w toku',
|
||||
|
||||
@@ -3309,6 +3309,10 @@ const messages: TranslationMap = {
|
||||
'rewards.community.connectingDiscord': 'Conectando…',
|
||||
'rewards.community.cumulativeTokens': 'Tokens acumulados',
|
||||
'rewards.community.currentStreak': 'Sequência atual',
|
||||
'rewards.community.disconnectDiscord': 'Desconectar',
|
||||
'rewards.community.disconnectDiscordError':
|
||||
'Não foi possível desconectar o Discord. Tente novamente.',
|
||||
'rewards.community.disconnectingDiscord': 'Desconectando…',
|
||||
'rewards.community.discordAccount': 'Conta do Discord',
|
||||
'rewards.community.discordConnected': 'Discord conectado',
|
||||
'rewards.community.discordConnectedAs': 'Conectado como {username}',
|
||||
@@ -3324,6 +3328,13 @@ const messages: TranslationMap = {
|
||||
'rewards.community.loadingRewards': 'Carregando recompensas…',
|
||||
'rewards.community.locked': 'Desbloqueado',
|
||||
'rewards.community.retrying': 'Tentando novamente…',
|
||||
'rewards.community.roleAssigned': 'Cargo atribuído',
|
||||
'rewards.community.roleAssignmentCount': '{assigned} de {unlocked} cargos atribuídos',
|
||||
'rewards.community.roleClaimDesc':
|
||||
'Você desbloqueou cargos do Discord, mas ainda não entrou no servidor do OpenHuman. Entre para que sejam atribuídos automaticamente.',
|
||||
'rewards.community.roleClaimTitle': 'Resgate seus cargos do Discord',
|
||||
'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.syncPending': 'Sincronização de recompensas pendente',
|
||||
|
||||
@@ -3281,6 +3281,9 @@ const messages: TranslationMap = {
|
||||
'rewards.community.connectingDiscord': 'Подключение…',
|
||||
'rewards.community.cumulativeTokens': 'Токенов всего',
|
||||
'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}',
|
||||
@@ -3296,6 +3299,13 @@ const messages: TranslationMap = {
|
||||
'rewards.community.loadingRewards': 'Загрузка наград…',
|
||||
'rewards.community.locked': 'Разблокировано',
|
||||
'rewards.community.retrying': 'Повтор…',
|
||||
'rewards.community.roleAssigned': 'Роль назначена',
|
||||
'rewards.community.roleAssignmentCount': 'Назначено ролей: {assigned} из {unlocked}',
|
||||
'rewards.community.roleClaimDesc':
|
||||
'Вы разблокировали роли Discord, но ещё не присоединились к серверу OpenHuman. Присоединитесь, чтобы они назначились автоматически.',
|
||||
'rewards.community.roleClaimTitle': 'Получите свои роли в Discord',
|
||||
'rewards.community.roleJoinToClaim': 'Присоединиться к серверу',
|
||||
'rewards.community.rolePending': 'Синхронизация роли…',
|
||||
'rewards.community.rolesAndRewards': 'Роли и награды',
|
||||
'rewards.community.streakDays': '{n}',
|
||||
'rewards.community.syncPending': 'Синхронизация наград ожидает',
|
||||
|
||||
@@ -3093,6 +3093,9 @@ const messages: TranslationMap = {
|
||||
'rewards.community.connectingDiscord': '正在连接…',
|
||||
'rewards.community.cumulativeTokens': '累计令牌',
|
||||
'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}',
|
||||
@@ -3108,6 +3111,13 @@ const messages: TranslationMap = {
|
||||
'rewards.community.loadingRewards': '正在加载奖励…',
|
||||
'rewards.community.locked': '已解锁',
|
||||
'rewards.community.retrying': '重试中…',
|
||||
'rewards.community.roleAssigned': '身份组已分配',
|
||||
'rewards.community.roleAssignmentCount': '已分配 {assigned}/{unlocked} 个身份组',
|
||||
'rewards.community.roleClaimDesc':
|
||||
'你已解锁 Discord 身份组,但尚未加入 OpenHuman 服务器。加入后将自动为你分配。',
|
||||
'rewards.community.roleClaimTitle': '领取你的 Discord 身份组',
|
||||
'rewards.community.roleJoinToClaim': '加入服务器领取',
|
||||
'rewards.community.rolePending': '正在同步身份组…',
|
||||
'rewards.community.rolesAndRewards': '角色与奖励',
|
||||
'rewards.community.streakDays': '{n} 天',
|
||||
'rewards.community.syncPending': '奖励同步待处理',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { normalizeRewardsApiError, normalizeRewardsSnapshot, rewardsApi } from '../rewardsApi';
|
||||
|
||||
vi.mock('../../apiClient', () => ({ apiClient: { get: vi.fn() } }));
|
||||
vi.mock('../../apiClient', () => ({ apiClient: { get: vi.fn(), delete: vi.fn() } }));
|
||||
|
||||
describe('normalizeRewardsSnapshot', () => {
|
||||
it('normalizes a backend rewards payload', () => {
|
||||
@@ -153,6 +153,50 @@ describe('rewardsApi', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewardsApi.disconnectDiscord', () => {
|
||||
it('resolves when the backend returns success', async () => {
|
||||
const { apiClient } = await import('../../apiClient');
|
||||
vi.mocked(apiClient.delete).mockResolvedValueOnce({ success: true, data: null });
|
||||
|
||||
await expect(rewardsApi.disconnectDiscord()).resolves.toBeUndefined();
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/rewards/discord', { timeout: 15000 });
|
||||
});
|
||||
|
||||
it('throws a normalized error on transport failure', async () => {
|
||||
const { apiClient } = await import('../../apiClient');
|
||||
vi.mocked(apiClient.delete).mockRejectedValueOnce(new Error('network error'));
|
||||
|
||||
await expect(rewardsApi.disconnectDiscord()).rejects.toMatchObject({
|
||||
success: false,
|
||||
error: 'network error',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws a RewardsApiError when the backend reports failure', async () => {
|
||||
const { apiClient } = await import('../../apiClient');
|
||||
vi.mocked(apiClient.delete).mockResolvedValueOnce({
|
||||
success: false,
|
||||
data: null,
|
||||
error: 'Unable to disconnect Discord',
|
||||
});
|
||||
|
||||
await expect(rewardsApi.disconnectDiscord()).rejects.toMatchObject({
|
||||
success: false,
|
||||
error: 'Unable to disconnect Discord',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a default message when backend error has no message', async () => {
|
||||
const { apiClient } = await import('../../apiClient');
|
||||
vi.mocked(apiClient.delete).mockResolvedValueOnce({ success: false, data: null });
|
||||
|
||||
await expect(rewardsApi.disconnectDiscord()).rejects.toMatchObject({
|
||||
success: false,
|
||||
error: 'Unable to disconnect Discord',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRewardsApiError', () => {
|
||||
it('keeps useful backend errors intact', () => {
|
||||
expect(normalizeRewardsApiError({ error: 'Rewards service unavailable' })).toEqual({
|
||||
|
||||
@@ -191,4 +191,28 @@ export const rewardsApi = {
|
||||
);
|
||||
return normalizeRewardsSnapshot(response.data);
|
||||
},
|
||||
|
||||
async disconnectDiscord(): Promise<void> {
|
||||
let response: ApiResponse<unknown>;
|
||||
try {
|
||||
response = await apiClient.delete<ApiResponse<unknown>>('/rewards/discord', {
|
||||
timeout: REWARDS_SNAPSHOT_TIMEOUT_MS,
|
||||
});
|
||||
} catch (transportError) {
|
||||
const normalized = normalizeRewardsApiError(transportError);
|
||||
log('disconnect transport failed error=%s', normalized.error);
|
||||
throw normalized;
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
const appError: RewardsApiError = {
|
||||
success: false,
|
||||
error: response.error ?? response.message ?? 'Unable to disconnect Discord',
|
||||
};
|
||||
log('disconnect backend error error=%s', appError.error);
|
||||
throw appError;
|
||||
}
|
||||
|
||||
log('discord disconnected');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -81,7 +81,14 @@ export async function waitForHomePage(timeout = 15_000) {
|
||||
// Home page (Home.tsx) renders t('home.askAssistant') = 'Ask your assistant anything...'
|
||||
// as a stable CTA button. The animated typewriter heading ('Welcome, <name> 👋' etc.)
|
||||
// and old strings ('Good morning', 'Message OpenHuman', 'Upgrade to Premium') are gone.
|
||||
const candidates = ['Ask your assistant anything', 'Your device is connected'];
|
||||
// After the /home → /chat redirect (AppRoutes.tsx), the chat new-window hero renders
|
||||
// t('home.statusOk') instead, so include both the old CTA text and the new status copy.
|
||||
const candidates = [
|
||||
'Ask your assistant anything',
|
||||
'Your device is connected',
|
||||
'Your assistant is ready when you are',
|
||||
'Type something below to get started',
|
||||
];
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
for (const text of candidates) {
|
||||
@@ -122,14 +129,15 @@ export async function clickFirstMatch(candidates, timeout = 5_000) {
|
||||
* Appium Mac2 cannot run W3C Execute Script in WKWebView — use sidebar labels
|
||||
* instead.
|
||||
*
|
||||
* Current IA (bottom-tab bar, see app/src/config/navConfig.ts): the six tabs
|
||||
* are Home, Chat, Human, Brain, Connections, Settings. The earlier
|
||||
* Current IA (bottom-tab bar, see app/src/config/navConfig.ts): the four tabs
|
||||
* are Chat, Human, Brain, Connections. Settings is reached via the gear icon in
|
||||
* the sidebar header. Home no longer has its own tab (it was merged into Chat in
|
||||
* Phase 6 — /home redirects to /chat via HASH_REDIRECTS below). The earlier
|
||||
* "Assistant"/"Activity"/"Alerts" labels are gone. Only real tabs belong here;
|
||||
* routes that redirect (e.g. /activity, /intelligence, /skills, /channels) are
|
||||
* resolved through HASH_REDIRECTS below — they have no sidebar button.
|
||||
* routes that redirect (e.g. /home, /activity, /intelligence, /skills, /channels)
|
||||
* are resolved through HASH_REDIRECTS below — they have no sidebar button.
|
||||
*/
|
||||
const HASH_TO_SIDEBAR_LABEL = {
|
||||
'/home': 'Home',
|
||||
'/chat': 'Chat',
|
||||
'/human': 'Human',
|
||||
'/brain': 'Brain',
|
||||
@@ -144,6 +152,7 @@ const HASH_TO_SIDEBAR_LABEL = {
|
||||
* app/src/AppRoutes.tsx.
|
||||
*/
|
||||
const HASH_REDIRECTS = {
|
||||
'/home': '/chat',
|
||||
'/skills': '/connections',
|
||||
'/channels': '/connections',
|
||||
'/activity': '/settings/notifications',
|
||||
@@ -339,7 +348,8 @@ export async function navigateViaHash(hash) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = HASH_TO_SIDEBAR_LABEL[normalized];
|
||||
// Resolve redirect before label lookup so that e.g. /home → Chat works on Mac2.
|
||||
const label = HASH_TO_SIDEBAR_LABEL[resolveRedirect(normalized)];
|
||||
if (label) {
|
||||
try {
|
||||
await clickText(label, 12_000);
|
||||
@@ -357,20 +367,23 @@ export async function navigateViaHash(hash) {
|
||||
}
|
||||
|
||||
export async function navigateToHome() {
|
||||
await navigateViaHash('/home');
|
||||
// /home redirects to /chat (AppRoutes.tsx). Navigate directly to /chat so
|
||||
// the sidebar button click path uses the 'Chat' label which exists, rather
|
||||
// than 'Home' which no longer has a dedicated tab.
|
||||
await navigateViaHash('/chat');
|
||||
const homeText = await waitForHomePage(10_000);
|
||||
if (!homeText) {
|
||||
if (supportsExecuteScript()) {
|
||||
try {
|
||||
await browser.execute(() => {
|
||||
window.location.hash = '/home';
|
||||
window.location.hash = '/chat';
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await clickText('Home', 8_000);
|
||||
await clickText('Chat', 8_000);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -34,10 +34,21 @@ interface RouteCheck {
|
||||
|
||||
const ROUTES: RouteCheck[] = [
|
||||
// Chat composer header: "New" thread button, agent-profile "Reasoning" pill.
|
||||
{ hash: '/chat', markers: ['New', 'Chat', 'Message', 'Reasoning'] },
|
||||
{ hash: '/chat', markers: ['New', 'Chat', 'Message', 'Reasoning', 'Your assistant is ready'] },
|
||||
// Connections page (was /skills) — tabs: Apps, Messaging, Tools, Explorer
|
||||
{ hash: '/connections', markers: ['Apps', 'Messaging', 'Tools', 'Connections'] },
|
||||
{ hash: '/home', markers: ['Ask your assistant anything', 'Your device is connected', 'Home'] },
|
||||
// /home redirects to /chat (AppRoutes.tsx Phase 6); markers cover both old
|
||||
// Home.tsx CTA text and the new chat new-window hero status copy.
|
||||
{
|
||||
hash: '/home',
|
||||
markers: [
|
||||
'Ask your assistant anything',
|
||||
'Your device is connected',
|
||||
'Your assistant is ready',
|
||||
'Home',
|
||||
'Chat',
|
||||
],
|
||||
},
|
||||
{
|
||||
hash: '/notifications',
|
||||
markers: ['Notifications', 'Alerts', 'Notification', 'No notifications'],
|
||||
@@ -48,7 +59,16 @@ const ROUTES: RouteCheck[] = [
|
||||
// Subconscious surface and memory live here now). Tabs: Graph, Memory,
|
||||
// Sources, Subconscious, Sync.
|
||||
{ hash: '/brain', markers: ['Graph', 'Memory', 'Subconscious', 'Sources'] },
|
||||
{ hash: '/home', markers: ['Ask your assistant anything', 'Your device is connected', 'Home'] },
|
||||
{
|
||||
hash: '/home',
|
||||
markers: [
|
||||
'Ask your assistant anything',
|
||||
'Your device is connected',
|
||||
'Your assistant is ready',
|
||||
'Home',
|
||||
'Chat',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function rootTextLength(): Promise<number> {
|
||||
@@ -129,14 +149,16 @@ describe('Navigation smoothness', () => {
|
||||
console.log(`${LOG_PREFIX} N1.2: passed — rapid cycle complete`);
|
||||
});
|
||||
|
||||
it('N1.3 — final state is /home with correct content', async () => {
|
||||
it('N1.3 — final state is /home (or /chat redirect) with correct content', async () => {
|
||||
console.log(`${LOG_PREFIX} N1.3: navigating to /home for final check`);
|
||||
await navigateViaHash('/home');
|
||||
const homeText = await waitForHomePage(ROUTE_TIMEOUT);
|
||||
expect(homeText).toBeTruthy();
|
||||
|
||||
// AppRoutes.tsx redirects /home → /chat, so the settled hash is #/chat.
|
||||
// Accept either form to keep the test resilient across routing changes.
|
||||
const hash = await browser.execute(() => window.location.hash);
|
||||
expect(hash).toMatch(/^#\/home/);
|
||||
console.log(`${LOG_PREFIX} N1.3: passed — on /home, content: "${homeText}"`);
|
||||
expect(hash).toMatch(/^#\/(home|chat)(\/|$)/);
|
||||
console.log(`${LOG_PREFIX} N1.3: passed — on ${hash}, content: "${homeText}"`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,11 +29,15 @@ interface Route {
|
||||
}
|
||||
|
||||
// Phase 2/3/6 IA revamp:
|
||||
// /home → /chat (Phase 6 — /home is now the merged chat surface)
|
||||
// /human → /chat (Phase 6 — back-compat redirect)
|
||||
// /skills → /connections (Phase 2 — back-compat redirect)
|
||||
// /intelligence → /activity (Phase 3 — back-compat redirect)
|
||||
// Note: /home is intentionally omitted here because AppRoutes.tsx redirects it
|
||||
// to /chat — navigateViaHash('/home') settles on #/chat, which is covered by
|
||||
// the /chat row. Keeping /home in ROUTES would cause the hash assertion to
|
||||
// fail since the actual hash is #/chat, not #/home.
|
||||
const ROUTES: Route[] = [
|
||||
{ hash: '/home' },
|
||||
{ hash: '/chat' },
|
||||
{ hash: '/connections' },
|
||||
{ hash: '/activity' },
|
||||
|
||||
@@ -37,9 +37,13 @@ describe('Webhooks ingress surface (stub-level)', () => {
|
||||
|
||||
it('reaches the app shell after onboarding', async () => {
|
||||
// Home.tsx: t('home.askAssistant') is the stable home page CTA button text.
|
||||
// After the /home → /chat redirect (AppRoutes.tsx), the chat new-window hero
|
||||
// renders t('home.statusOk') instead of the old CTA button.
|
||||
const atHome =
|
||||
(await textExists('Ask your assistant anything')) ||
|
||||
(await textExists('Your device is connected'));
|
||||
(await textExists('Your device is connected')) ||
|
||||
(await textExists('Your assistant is ready when you are')) ||
|
||||
(await textExists('Type something below to get started'));
|
||||
expect(atHome).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -82,9 +82,13 @@ describe('Webhook tunnel CRUD (UI + core RPC + mock backend)', () => {
|
||||
|
||||
it('reached the logged-in shell after onboarding', async () => {
|
||||
// Home.tsx: t('home.askAssistant') is the stable home page CTA button text.
|
||||
// After the /home → /chat redirect (AppRoutes.tsx), the chat new-window hero
|
||||
// renders t('home.statusOk') instead of the old CTA button.
|
||||
const atHome =
|
||||
(await textExists('Ask your assistant anything')) ||
|
||||
(await textExists('Your device is connected'));
|
||||
(await textExists('Your device is connected')) ||
|
||||
(await textExists('Your assistant is ready when you are')) ||
|
||||
(await textExists('Type something below to get started'));
|
||||
expect(atHome).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ test.describe('Login Flow', () => {
|
||||
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => window.location.hash))
|
||||
.toMatch(/^#\/(home|chat)/);
|
||||
.toMatch(/^#\/(home|chat)(\/|$)/);
|
||||
await expect(await waitForMockRequest('GET', '/auth/me')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ test.describe('Login Flow', () => {
|
||||
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => window.location.hash))
|
||||
.toMatch(/^#\/(home|chat)/);
|
||||
.toMatch(/^#\/(home|chat)(\/|$)/);
|
||||
|
||||
const consumeCall = (await requests()).find(
|
||||
request => request.method === 'POST' && request.url.includes('/telegram/login-tokens/')
|
||||
|
||||
@@ -112,7 +112,7 @@ test.describe('Runtime picker -> login -> logout', () => {
|
||||
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => window.location.hash))
|
||||
.toMatch(/^#\/(home|chat)/);
|
||||
.toMatch(/^#\/(home|chat)(\/|$)/);
|
||||
await expect(await waitForMockRequest('GET', '/auth/me')).toBeTruthy();
|
||||
|
||||
await page.goto('/#/settings/account');
|
||||
|
||||
Reference in New Issue
Block a user