feat: referal support
This commit is contained in:
13
src/App.tsx
13
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 <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 />} />
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user