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 { 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 <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() {
const hydrate = useAuthStore((s) => s.hydrate);
@@ -32,6 +44,7 @@ export default function App() {
<BrowserRouter>
<Cursor />
<Toast />
<ReferralCapture />
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/login" element={<LoginPage />} />

View File

@@ -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();

View File

@@ -24,6 +24,7 @@ export function AccountPage() {
const [loadingMeta, setLoadingMeta] = useState(true);
const [pending, setPending] = useState<Device | null>(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
</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>
</div>
@@ -226,6 +272,12 @@ export function AccountPage() {
link={isQrOpen ? subscription?.subscription_link ?? null : null}
onClose={() => setIsQrOpen(false)}
/>
<SubscriptionQrModal
link={isReferralQrOpen ? referralLink : null}
label="Реферальная ссылка"
imageAlt="QR-код реферальной ссылки"
onClose={() => setIsReferralQrOpen(false)}
/>
</Shell>
);
}

View File

@@ -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 () => {

View File

@@ -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<Mode>('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<string>('');
@@ -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() {
</button>
</div>
{mode === 'signup' ? (
<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>
) : (
{mode === 'signin' && (
<div className="auth-row">
<label className="check">
<input