diff --git a/apps/web/app/accounts/AccountCard.test.tsx b/apps/web/app/accounts/AccountCard.test.tsx new file mode 100644 index 0000000..7dd6fba --- /dev/null +++ b/apps/web/app/accounts/AccountCard.test.tsx @@ -0,0 +1,100 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { AccountCard } from './AccountCard'; + +const activeAccount = { + id: 'acc_1', + jid: '111@s.whatsapp.net', + displayName: 'My Account', + status: 'ACTIVE', + platform: 'whatsapp', +}; + +const disconnectedAccount = { + id: 'acc_2', + jid: '222@s.whatsapp.net', + displayName: null, + status: 'DISCONNECTED', + platform: 'whatsapp', +}; + +let fetchSpy: jest.SpyInstance; + +beforeEach(() => { + fetchSpy = jest.spyOn(global, 'fetch'); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('AccountCard', () => { + it('shows displayName and Connected badge when ACTIVE', () => { + render(); + expect(screen.getByText('My Account')).toBeInTheDocument(); + expect(screen.getByText('Connected')).toBeInTheDocument(); + }); + + it('falls back to jid when displayName is null', () => { + render(); + expect(screen.getByText('222@s.whatsapp.net')).toBeInTheDocument(); + }); + + it('shows Awaiting scan badge when DISCONNECTED', () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + render(); + expect(screen.getByText('Awaiting scan')).toBeInTheDocument(); + }); + + it('does not fetch QR when account is ACTIVE', () => { + render(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('fetches QR from /api/accounts/:id/qr when DISCONNECTED', async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + render(); + await waitFor(() => + expect(fetchSpy).toHaveBeenCalledWith('/api/accounts/acc_2/qr'), + ); + }); + + it('shows QR image when qrDataUrl is returned', async () => { + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,abc123' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + render(); + await waitFor(() => { + expect(screen.getByRole('img', { name: /qr code/i })).toBeInTheDocument(); + }); + expect(screen.getByRole('img', { name: /qr code/i })).toHaveAttribute( + 'src', + 'data:image/png;base64,abc123', + ); + }); + + it('shows waiting message when DISCONNECTED but no QR yet', async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + render(); + await waitFor(() => { + expect(screen.getByText(/waiting for qr/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/app/accounts/AccountCard.tsx b/apps/web/app/accounts/AccountCard.tsx new file mode 100644 index 0000000..b68b215 --- /dev/null +++ b/apps/web/app/accounts/AccountCard.tsx @@ -0,0 +1,72 @@ +'use client'; +import { useEffect, useState } from 'react'; + +interface Account { + id: string; + jid: string; + displayName: string | null; + status: string; + platform: string; +} + +export function AccountCard({ account }: { account: Account }) { + const [qrDataUrl, setQrDataUrl] = useState(null); + const isDisconnected = account.status === 'DISCONNECTED'; + + useEffect(() => { + if (!isDisconnected) { + setQrDataUrl(null); + return; + } + + async function fetchQr() { + try { + const res = await fetch(`/api/accounts/${account.id}/qr`); + if (!res.ok) return; + const data = await res.json(); + setQrDataUrl(data.qrDataUrl ?? null); + } catch { + // ignore fetch errors (e.g. network issues) + } + } + + fetchQr(); + const interval = setInterval(fetchQr, 5000); + return () => clearInterval(interval); + }, [account.id, isDisconnected]); + + return ( +
+
+
+

{account.displayName ?? account.jid}

+ {account.displayName && ( +

{account.jid}

+ )} +
+ + {account.status === 'ACTIVE' ? 'Connected' : 'Awaiting scan'} + +
+ + {isDisconnected && qrDataUrl && ( +
+

+ Open WhatsApp → Linked Devices → Link a Device → scan below +

+ WhatsApp QR Code +
+ )} + + {isDisconnected && !qrDataUrl && ( +

Waiting for QR code from worker...

+ )} +
+ ); +} diff --git a/apps/web/app/accounts/page.tsx b/apps/web/app/accounts/page.tsx new file mode 100644 index 0000000..61c375d --- /dev/null +++ b/apps/web/app/accounts/page.tsx @@ -0,0 +1,33 @@ +import { AccountCard } from './AccountCard'; + +interface Account { + id: string; + jid: string; + displayName: string | null; + status: string; + platform: string; +} + +export default async function AccountsPage() { + const apiUrl = process.env.API_URL ?? 'http://localhost:3001'; + let accounts: Account[] = []; + try { + const res = await fetch(`${apiUrl}/accounts`, { cache: 'no-store' }); + if (res.ok) accounts = await res.json(); + } catch {} + + return ( +
+

Accounts

+ {accounts.length === 0 ? ( +

No accounts found.

+ ) : ( +
+ {accounts.map((a) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index c86ce41..1a37ebb 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -19,6 +19,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) Groups & Routes + + Accounts +
{children}