diff --git a/src/App.tsx b/src/App.tsx index 7058911..1fb3fee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { Landing } from './pages/Landing'; import { LoginPage } from './pages/LoginPage'; import { PlanPage } from './pages/PlanPage'; import { AccountPage } from './pages/AccountPage'; +import { saveReferralCode } from './lib/referral'; function ProtectedRoute() { const access = useAuthStore((s) => s.access); @@ -21,6 +22,17 @@ function ProtectedRoute() { return ; } +function ReferralCapture() { + const location = useLocation(); + + useEffect(() => { + const code = new URLSearchParams(location.search).get('ref'); + if (code) saveReferralCode(code); + }, [location.search]); + + return null; +} + export default function App() { const hydrate = useAuthStore((s) => s.hydrate); @@ -32,6 +44,7 @@ export default function App() { + } /> } /> diff --git a/src/pages/AccountPage.test.tsx b/src/pages/AccountPage.test.tsx index 9fec9b6..ebeb312 100644 --- a/src/pages/AccountPage.test.tsx +++ b/src/pages/AccountPage.test.tsx @@ -113,7 +113,18 @@ describe('AccountPage', () => { vi.mocked(api.getDevices).mockResolvedValue(DEVICES); renderAccount(); expect(await screen.findByText(SUB.subscription_link)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /копировать/i })).toBeEnabled(); + expect(screen.getByRole('button', { name: /копировать ссылку подписки/i })).toBeEnabled(); + }); + + it('renders a referral link with copy and QR actions', async () => { + vi.mocked(api.getMe).mockResolvedValue(ME); + vi.mocked(api.getSubscription).mockResolvedValue(SUB); + vi.mocked(api.getDevices).mockResolvedValue(DEVICES); + renderAccount(); + + const link = `${window.location.origin}/?ref=c`; + expect(await screen.findByText(link)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /показать qr-код реферальной ссылки/i })).toBeEnabled(); }); it('opens and closes the subscription QR code dialog', async () => { @@ -124,7 +135,7 @@ describe('AccountPage', () => { renderAccount(); await screen.findByText(SUB.subscription_link); - await user.click(screen.getByRole('button', { name: /показать qr-код/i })); + await user.click(screen.getByRole('button', { name: /показать qr-код ссылки подписки/i })); expect(await screen.findByRole('dialog', { name: /откройте на устройстве/i })).toBeInTheDocument(); expect(await screen.findByAltText(/qr-код ссылки подписки/i)).toBeInTheDocument(); diff --git a/src/pages/AccountPage.tsx b/src/pages/AccountPage.tsx index b771376..64fe43c 100644 --- a/src/pages/AccountPage.tsx +++ b/src/pages/AccountPage.tsx @@ -24,6 +24,7 @@ export function AccountPage() { const [loadingMeta, setLoadingMeta] = useState(true); const [pending, setPending] = useState(null); const [isQrOpen, setIsQrOpen] = useState(false); + const [isReferralQrOpen, setIsReferralQrOpen] = useState(false); useEffect(() => { if (!isAuthenticated) { @@ -53,8 +54,11 @@ export function AccountPage() { const handleDelete = (device: Device) => setPending(device); - const copySubscriptionLink = async () => { - const link = subscription?.subscription_link; + const referralLink = userInfo?.referal_code + ? `${window.location.origin}/?ref=${encodeURIComponent(userInfo.referal_code)}` + : null; + + const copyLink = async (link: string | null, label: string) => { if (!link) return; try { if (navigator.clipboard?.writeText) { @@ -69,7 +73,7 @@ export function AccountPage() { document.execCommand('copy'); input.remove(); } - showToast('Ссылка подписки скопирована'); + showToast(`${label} скопирована`); } catch { showToast('Не удалось скопировать ссылку'); } @@ -148,7 +152,49 @@ export function AccountPage() { > QR - + + + + ) : null} + + {!loadingMeta && referralLink ? ( + +
+ +
+
Реферальная ссылка
+ + {referralLink} + +
+
+ +
@@ -226,6 +272,12 @@ export function AccountPage() { link={isQrOpen ? subscription?.subscription_link ?? null : null} onClose={() => setIsQrOpen(false)} /> + setIsReferralQrOpen(false)} + /> ); } diff --git a/src/pages/LoginPage.test.tsx b/src/pages/LoginPage.test.tsx index a53f4a5..4cf51e1 100644 --- a/src/pages/LoginPage.test.tsx +++ b/src/pages/LoginPage.test.tsx @@ -79,12 +79,37 @@ describe('LoginPage', () => { expect(pass.type).toBe('text'); }); - it('switches to signup and shows referral field', async () => { + it('switches to signup without a referral field', async () => { const user = userEvent.setup(); renderLogin(); await user.click(screen.getByRole('tab', { name: /Регистрация/i })); expect(screen.getByRole('heading', { name: /создать аккаунт/i })).toBeInTheDocument(); - expect(screen.getByLabelText(/реферальный код/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/реферальный код/i)).not.toBeInTheDocument(); + }); + + it('submits the referral code saved from a referral link', async () => { + const user = userEvent.setup(); + localStorage.setItem('malenia.referral_code', 'friend-code'); + vi.mocked(api.signup).mockResolvedValue({ + access_token: 'a', + refresh_token: 'r', + expires_at: 9999999999, + user: { username: 'alice', telegram_id: null, referal_code: 'new-code' }, + }); + renderLogin(); + await user.click(screen.getByRole('tab', { name: /Регистрация/i })); + await user.type(screen.getByLabelText('Имя пользователя'), 'alice'); + await user.type(screen.getByLabelText('Пароль'), 'secret'); + await user.click(screen.getByRole('button', { name: /создать аккаунт/i })); + + await waitFor(() => + expect(api.signup).toHaveBeenCalledWith({ + provider: 'credentials', + username: 'alice', + password: 'secret', + referal_code: 'friend-code', + }), + ); }); it('shows backend error on login failure', async () => { diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 2577b50..e0c9c6d 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -6,6 +6,7 @@ import { useToastStore } from '../store/useToast'; import { Emblem } from '../components/Emblem'; import { Logo } from '../components/Logo'; import { Reveal } from '../components/Reveal'; +import { getReferralCode } from '../lib/referral'; type Mode = 'signin' | 'signup'; @@ -43,7 +44,6 @@ export function LoginPage() { const [mode, setMode] = useState('signin'); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - const [referral, setReferral] = useState(''); const [remember, setRemember] = useState(true); const [showPass, setShowPass] = useState(false); const [status, setStatus] = useState(''); @@ -96,7 +96,7 @@ export function LoginPage() { provider: 'credentials', username: username.trim(), password, - referal_code: referral.trim() || null, + referal_code: getReferralCode(), }); setSession(res, remember); showToast('Аккаунт создан'); @@ -204,25 +204,7 @@ export function LoginPage() { - {mode === 'signup' ? ( -
- - setReferral(e.target.value)} - /> - -
- ) : ( + {mode === 'signin' && (