feat: qr codes
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user