feat: qr codes

This commit is contained in:
2026-08-10 12:06:17 +07:00
parent 265b1202ad
commit 4323cde98f
7 changed files with 368 additions and 11 deletions

View File

@@ -6,6 +6,10 @@ import { AccountPage } from './AccountPage';
import { api } from '../api/client';
import { useAuthStore } from '../store/useStore';
vi.mock('qrcode', () => ({
default: { toDataURL: () => Promise.resolve('data:image/png;base64,qr') },
}));
vi.mock('../api/client', () => ({
api: {
getPlans: vi.fn(),
@@ -112,6 +116,22 @@ describe('AccountPage', () => {
expect(screen.getByRole('button', { name: /копировать/i })).toBeEnabled();
});
it('opens and closes the subscription QR code dialog', async () => {
vi.mocked(api.getMe).mockResolvedValue(ME);
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
const user = userEvent.setup();
renderAccount();
await screen.findByText(SUB.subscription_link);
await user.click(screen.getByRole('button', { name: /показать qr-код/i }));
expect(await screen.findByRole('dialog', { name: /откройте на устройстве/i })).toBeInTheDocument();
expect(await screen.findByAltText(/qr-код ссылки подписки/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /закрыть/i }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('renders empty state when no devices', async () => {
vi.mocked(api.getMe).mockResolvedValue(ME);
vi.mocked(api.getSubscription).mockResolvedValue(SUB);

View File

@@ -10,6 +10,7 @@ import { Skeleton } from '../components/Skeleton';
import { StatStrip } from '../components/StatStrip';
import { DeviceCard } from '../components/DeviceCard';
import { Modal } from '../components/Modal';
import { SubscriptionQrModal } from '../components/SubscriptionQrModal';
import type { Device, SubscriptionData, UserInfo } from '../types';
export function AccountPage() {
@@ -22,6 +23,7 @@ export function AccountPage() {
const [subscription, setSubscription] = useState<SubscriptionData | null>(null);
const [loadingMeta, setLoadingMeta] = useState(true);
const [pending, setPending] = useState<Device | null>(null);
const [isQrOpen, setIsQrOpen] = useState(false);
useEffect(() => {
if (!isAuthenticated) {
@@ -137,9 +139,19 @@ export function AccountPage() {
{subscription.subscription_link}
</span>
</div>
<button type="button" className="copy-link" onClick={() => void copySubscriptionLink()}>
Копировать
</button>
<div className="subscription-actions">
<button
type="button"
className="subscription-qr-button"
aria-label="Показать QR-код ссылки подписки"
onClick={() => setIsQrOpen(true)}
>
QR
</button>
<button type="button" className="copy-link" onClick={() => void copySubscriptionLink()}>
Копировать
</button>
</div>
</section>
</Reveal>
) : null}
@@ -210,6 +222,10 @@ export function AccountPage() {
confirmLabel="Удалить"
onConfirm={confirmDelete}
/>
<SubscriptionQrModal
link={isQrOpen ? subscription?.subscription_link ?? null : null}
onClose={() => setIsQrOpen(false)}
/>
</Shell>
);
}

View File

@@ -21,7 +21,7 @@ body::before {
top: 0;
left: 0;
pointer-events: none;
z-index: 9999;
z-index: 10002;
transform: translate(-50%, -50%);
transition: opacity 0.3s;
}

View File

@@ -563,6 +563,51 @@
.copy-link:active {
transform: translateY(1px);
}
.subscription-actions {
display: flex;
align-items: center;
gap: 8px;
}
.subscription-qr-button {
width: 44px;
height: 44px;
border: 1px solid var(--hairline-strong);
border-radius: var(--radius);
background: transparent;
color: var(--fg);
font-family: var(--font-body);
font-size: 10px;
letter-spacing: 0.1em;
transition: background 0.25s, border-color 0.25s, color 0.25s;
}
.subscription-qr-button:hover {
background: var(--accent);
border-color: var(--accent);
}
.subscription-qr-modal {
max-width: 360px;
}
.subscription-qr-image {
width: 224px;
min-height: 224px;
margin: 0 auto 26px;
padding: 10px;
display: grid;
place-items: center;
background: var(--bg);
border: 1px solid var(--hairline-strong);
}
.subscription-qr-image img {
display: block;
width: 100%;
height: auto;
}
.subscription-qr-image span {
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
}
/* ── Device grid (account) ──────────────────────────────── */
.devices-head {