feat: referal support

This commit is contained in:
2026-08-10 12:24:51 +07:00
parent c2f0dc5b0a
commit 42f168e1ac
6 changed files with 113 additions and 30 deletions

View File

@@ -7,6 +7,7 @@ import { Landing } from './pages/Landing';
import { LoginPage } from './pages/LoginPage'; import { LoginPage } from './pages/LoginPage';
import { PlanPage } from './pages/PlanPage'; import { PlanPage } from './pages/PlanPage';
import { AccountPage } from './pages/AccountPage'; import { AccountPage } from './pages/AccountPage';
import { saveReferralCode } from './lib/referral';
function ProtectedRoute() { function ProtectedRoute() {
const access = useAuthStore((s) => s.access); const access = useAuthStore((s) => s.access);
@@ -21,6 +22,17 @@ function ProtectedRoute() {
return <AccountPage />; return <AccountPage />;
} }
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() { export default function App() {
const hydrate = useAuthStore((s) => s.hydrate); const hydrate = useAuthStore((s) => s.hydrate);
@@ -32,6 +44,7 @@ export default function App() {
<BrowserRouter> <BrowserRouter>
<Cursor /> <Cursor />
<Toast /> <Toast />
<ReferralCapture />
<Routes> <Routes>
<Route path="/" element={<Landing />} /> <Route path="/" element={<Landing />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />

View File

@@ -113,7 +113,18 @@ describe('AccountPage', () => {
vi.mocked(api.getDevices).mockResolvedValue(DEVICES); vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
renderAccount(); renderAccount();
expect(await screen.findByText(SUB.subscription_link)).toBeInTheDocument(); 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 () => { it('opens and closes the subscription QR code dialog', async () => {
@@ -124,7 +135,7 @@ describe('AccountPage', () => {
renderAccount(); renderAccount();
await screen.findByText(SUB.subscription_link); 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.findByRole('dialog', { name: /откройте на устройстве/i })).toBeInTheDocument();
expect(await screen.findByAltText(/qr-код ссылки подписки/i)).toBeInTheDocument(); expect(await screen.findByAltText(/qr-код ссылки подписки/i)).toBeInTheDocument();

View File

@@ -24,6 +24,7 @@ export function AccountPage() {
const [loadingMeta, setLoadingMeta] = useState(true); const [loadingMeta, setLoadingMeta] = useState(true);
const [pending, setPending] = useState<Device | null>(null); const [pending, setPending] = useState<Device | null>(null);
const [isQrOpen, setIsQrOpen] = useState(false); const [isQrOpen, setIsQrOpen] = useState(false);
const [isReferralQrOpen, setIsReferralQrOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) { if (!isAuthenticated) {
@@ -53,8 +54,11 @@ export function AccountPage() {
const handleDelete = (device: Device) => setPending(device); const handleDelete = (device: Device) => setPending(device);
const copySubscriptionLink = async () => { const referralLink = userInfo?.referal_code
const link = subscription?.subscription_link; ? `${window.location.origin}/?ref=${encodeURIComponent(userInfo.referal_code)}`
: null;
const copyLink = async (link: string | null, label: string) => {
if (!link) return; if (!link) return;
try { try {
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
@@ -69,7 +73,7 @@ export function AccountPage() {
document.execCommand('copy'); document.execCommand('copy');
input.remove(); input.remove();
} }
showToast('Ссылка подписки скопирована'); showToast(`${label} скопирована`);
} catch { } catch {
showToast('Не удалось скопировать ссылку'); showToast('Не удалось скопировать ссылку');
} }
@@ -148,7 +152,49 @@ export function AccountPage() {
> >
QR QR
</button> </button>
<button type="button" className="copy-link" onClick={() => void copySubscriptionLink()}> <button
type="button"
className="copy-link"
aria-label="Копировать ссылку подписки"
onClick={() => void copyLink(subscription.subscription_link, 'Ссылка подписки')}
>
Копировать
</button>
</div>
</section>
</Reveal>
) : null}
{!loadingMeta && referralLink ? (
<Reveal>
<section className="subscription-link" aria-label="Реферальная ссылка">
<div className="subscription-emblem" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="8" r="3" />
<path d="M6 20c.7-3.1 2.7-5 6-5s5.3 1.9 6 5M19 8h3M20.5 6.5v3" />
</svg>
</div>
<div className="subscription-copy">
<div className="subscription-label">Реферальная ссылка</div>
<span className="subscription-url" title={referralLink}>
{referralLink}
</span>
</div>
<div className="subscription-actions">
<button
type="button"
className="subscription-qr-button"
aria-label="Показать QR-код реферальной ссылки"
onClick={() => setIsReferralQrOpen(true)}
>
QR
</button>
<button
type="button"
className="copy-link"
aria-label="Копировать реферальную ссылку"
onClick={() => void copyLink(referralLink, 'Реферальная ссылка')}
>
Копировать Копировать
</button> </button>
</div> </div>
@@ -226,6 +272,12 @@ export function AccountPage() {
link={isQrOpen ? subscription?.subscription_link ?? null : null} link={isQrOpen ? subscription?.subscription_link ?? null : null}
onClose={() => setIsQrOpen(false)} onClose={() => setIsQrOpen(false)}
/> />
<SubscriptionQrModal
link={isReferralQrOpen ? referralLink : null}
label="Реферальная ссылка"
imageAlt="QR-код реферальной ссылки"
onClose={() => setIsReferralQrOpen(false)}
/>
</Shell> </Shell>
); );
} }

View File

@@ -79,12 +79,37 @@ describe('LoginPage', () => {
expect(pass.type).toBe('text'); 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(); const user = userEvent.setup();
renderLogin(); renderLogin();
await user.click(screen.getByRole('tab', { name: /Регистрация/i })); await user.click(screen.getByRole('tab', { name: /Регистрация/i }));
expect(screen.getByRole('heading', { name: /создать аккаунт/i })).toBeInTheDocument(); 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 () => { it('shows backend error on login failure', async () => {

View File

@@ -6,6 +6,7 @@ import { useToastStore } from '../store/useToast';
import { Emblem } from '../components/Emblem'; import { Emblem } from '../components/Emblem';
import { Logo } from '../components/Logo'; import { Logo } from '../components/Logo';
import { Reveal } from '../components/Reveal'; import { Reveal } from '../components/Reveal';
import { getReferralCode } from '../lib/referral';
type Mode = 'signin' | 'signup'; type Mode = 'signin' | 'signup';
@@ -43,7 +44,6 @@ export function LoginPage() {
const [mode, setMode] = useState<Mode>('signin'); const [mode, setMode] = useState<Mode>('signin');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [referral, setReferral] = useState('');
const [remember, setRemember] = useState(true); const [remember, setRemember] = useState(true);
const [showPass, setShowPass] = useState(false); const [showPass, setShowPass] = useState(false);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
@@ -96,7 +96,7 @@ export function LoginPage() {
provider: 'credentials', provider: 'credentials',
username: username.trim(), username: username.trim(),
password, password,
referal_code: referral.trim() || null, referal_code: getReferralCode(),
}); });
setSession(res, remember); setSession(res, remember);
showToast('Аккаунт создан'); showToast('Аккаунт создан');
@@ -204,25 +204,7 @@ export function LoginPage() {
</button> </button>
</div> </div>
{mode === 'signup' ? ( {mode === 'signin' && (
<div className="field">
<label className="field-label" htmlFor="referral">
Реферальный код (необязательно)
</label>
<input
id="referral"
name="referral"
className="field-input"
type="text"
autoComplete="off"
placeholder="код от друга"
spellCheck={false}
value={referral}
onChange={(e) => setReferral(e.target.value)}
/>
<span className="field-line" />
</div>
) : (
<div className="auth-row"> <div className="auth-row">
<label className="check"> <label className="check">
<input <input

View File

@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/api/client.test.ts","./src/api/client.ts","./src/components/AddonList.tsx","./src/components/Breakdown.tsx","./src/components/Cursor.tsx","./src/components/DeviceCard.tsx","./src/components/DeviceSlider.tsx","./src/components/DurationSelect.tsx","./src/components/Emblem.tsx","./src/components/Logo.tsx","./src/components/Modal.tsx","./src/components/Reveal.tsx","./src/components/Shell.tsx","./src/components/Skeleton.tsx","./src/components/StatStrip.tsx","./src/components/SubscriptionQrModal.tsx","./src/components/Toast.tsx","./src/components/TopBar.tsx","./src/hooks/useAuth.ts","./src/hooks/useDevices.ts","./src/hooks/useLocalStorage.ts","./src/hooks/usePlans.ts","./src/lib/format.test.ts","./src/lib/format.ts","./src/lib/planConfig.test.ts","./src/lib/planConfig.ts","./src/lib/redirect.ts","./src/pages/AccountPage.test.tsx","./src/pages/AccountPage.tsx","./src/pages/Landing.test.tsx","./src/pages/Landing.tsx","./src/pages/LoginPage.test.tsx","./src/pages/LoginPage.tsx","./src/pages/PlanPage.test.tsx","./src/pages/PlanPage.tsx","./src/store/useStore.ts","./src/store/useToast.ts","./src/test/setup.ts"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/api/client.test.ts","./src/api/client.ts","./src/components/AddonList.tsx","./src/components/Breakdown.tsx","./src/components/Cursor.tsx","./src/components/DeviceCard.tsx","./src/components/DeviceSlider.tsx","./src/components/DurationSelect.tsx","./src/components/Emblem.tsx","./src/components/Logo.tsx","./src/components/Modal.tsx","./src/components/Reveal.tsx","./src/components/Shell.tsx","./src/components/Skeleton.tsx","./src/components/StatStrip.tsx","./src/components/SubscriptionQrModal.tsx","./src/components/Toast.tsx","./src/components/TopBar.tsx","./src/hooks/useAuth.ts","./src/hooks/useDevices.ts","./src/hooks/useLocalStorage.ts","./src/hooks/usePlans.ts","./src/lib/format.test.ts","./src/lib/format.ts","./src/lib/planConfig.test.ts","./src/lib/planConfig.ts","./src/lib/redirect.ts","./src/lib/referral.ts","./src/pages/AccountPage.test.tsx","./src/pages/AccountPage.tsx","./src/pages/Landing.test.tsx","./src/pages/Landing.tsx","./src/pages/LoginPage.test.tsx","./src/pages/LoginPage.tsx","./src/pages/PlanPage.test.tsx","./src/pages/PlanPage.tsx","./src/store/useStore.ts","./src/store/useToast.ts","./src/test/setup.ts"],"version":"5.9.3"}