diff --git a/.gitignore b/.gitignore index 399e24b2..91cf9856 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ temp*build*/ /.codex-temp /target/ /logs +.worktrees/ diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 9dd27453..18484be1 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -692,6 +692,7 @@ afterEach(() => { configurable: true, value: undefined, }); + window.history.replaceState(null, '', '/'); }); test('opens wallet ledger modal from narrative coin card', async () => { @@ -859,6 +860,37 @@ test('profile redeem invite shortcut hides after redeemed or one day old', async ).toBeNull(); }); + +test('invite query opens login modal for logged out users', async () => { + const openLoginModal = vi.fn(); + window.history.replaceState(null, '', '/?inviteCode=spring-2026'); + + renderLoggedOutHomeView(openLoginModal); + + await waitFor(() => { + expect(openLoginModal).toHaveBeenCalledTimes(1); + }); +}); + +test('invite query opens redeem modal directly for logged in users', async () => { + window.history.replaceState(null, '', '/?inviteCode=spring-2026'); + + renderProfileView(); + + const input = await screen.findByLabelText('邀请码'); + expect((input as HTMLInputElement).value).toBe('SPRING2026'); +}); + +test('profile redeem invite modal reads query invite code after login', async () => { + window.history.replaceState(null, '', '/?inviteCode=spring-2026'); + + renderProfileView(); + + const input = await screen.findByLabelText('邀请码'); + + expect((input as HTMLInputElement).value).toBe('SPRING2026'); +}); + test('profile redeem invite modal submits code and hides shortcut after success', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index f8de96a6..dedfad73 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -62,10 +62,10 @@ import { } from '../../services/authService'; import { copyTextToClipboard } from '../../services/clipboard'; import { + claimRpgProfileTaskReward, getRpgProfileReferralInviteCenter, getRpgProfileTasks, getRpgProfileWalletLedger, - claimRpgProfileTaskReward, redeemRpgProfileReferralInviteCode, redeemRpgProfileRewardCode, } from '../../services/rpg-entry/rpgProfileClient'; @@ -158,6 +158,8 @@ const AVATAR_OUTPUT_SIZE = 256; const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200; const PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS = 24 * 60 * 60 * 1000; +const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const; + type ProfilePopupPanel = 'invite' | 'redeem' | 'community'; type MobileHomeChannel = 'recommend' | 'today' | 'category'; type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like'; @@ -1514,6 +1516,24 @@ function isWithinProfileInviteRedeemWindow( return Date.now() - createdTime <= PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS; } +function normalizeProfileInviteQueryCode(value: string | null | undefined) { + return (value ?? '') + .trim() + .replace(/[^0-9a-z]/giu, '') + .toUpperCase(); +} + +function readProfileInviteCodeFromLocationSearch(search: string) { + const params = new URLSearchParams(search); + for (const key of PROFILE_INVITE_QUERY_KEYS) { + const inviteCode = normalizeProfileInviteQueryCode(params.get(key)); + if (inviteCode) { + return inviteCode; + } + } + return ''; +} + function formatPlayedWorkType(value: string | null | undefined) { const normalizedValue = (value ?? '').toLowerCase(); if (normalizedValue === 'puzzle') { @@ -2695,7 +2715,17 @@ export function RpgEntryHomeView({ const [isLoadingReferral, setIsLoadingReferral] = useState(false); const [isReferralCenterInitialized, setIsReferralCenterInitialized] = useState(false); - const [referralRedeemCode, setReferralRedeemCode] = useState(''); + const pendingProfileInviteCode = useMemo( + () => + typeof window === 'undefined' + ? '' + : readProfileInviteCodeFromLocationSearch(window.location.search), + [], + ); + const autoOpenedInviteQueryRef = useRef(false); + const [referralRedeemCode, setReferralRedeemCode] = useState( + pendingProfileInviteCode, + ); const [isSubmittingReferralRedeem, setIsSubmittingReferralRedeem] = useState(false); const [referralError, setReferralError] = useState(null); @@ -2936,6 +2966,23 @@ export function RpgEntryHomeView({ } authUi?.openLoginModal(); }; + + useEffect(() => { + if (!pendingProfileInviteCode || autoOpenedInviteQueryRef.current) { + return; + } + + autoOpenedInviteQueryRef.current = true; + if (!authUi?.user) { + authUi?.openLoginModal(); + return; + } + + setReferralRedeemCode(pendingProfileInviteCode); + setReferralError(null); + setReferralSuccess(null); + setProfilePopupPanel('redeem'); + }, [authUi, pendingProfileInviteCode]); const scheduleProfileCopyStateReset = () => { if (profileCopyResetTimerRef.current !== null) { window.clearTimeout(profileCopyResetTimerRef.current); @@ -3173,7 +3220,7 @@ export function RpgEntryHomeView({ setReferralError(null); setReferralSuccess(null); if (panel === 'redeem') { - setReferralRedeemCode(''); + setReferralRedeemCode(pendingProfileInviteCode); } if (panel === 'community') { return;