diff --git a/client/src/components/ShareLinkFallback.test.tsx b/client/src/components/ShareLinkFallback.test.tsx
new file mode 100644
index 0000000..8a6671d
--- /dev/null
+++ b/client/src/components/ShareLinkFallback.test.tsx
@@ -0,0 +1,36 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { ThemeProvider } from '@mui/material/styles';
+import { describe, expect, it, vi } from 'vitest';
+import theme from '../theme/theme';
+import ShareLinkFallback from './ShareLinkFallback';
+
+const link = 'https://ameet.example/lobby/abc-defg-hij';
+
+function renderFallback(value: string | null, onClose = vi.fn()) {
+ render(
+
+
+ ,
+ );
+ return onClose;
+}
+
+describe('ShareLinkFallback', () => {
+ it('shows the full joining link when automatic copy did not work', () => {
+ renderFallback(link);
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: 'Meeting link' })).toHaveValue(link);
+ expect(screen.getByText(/blocked automatic copy/i)).toBeInTheDocument();
+ });
+
+ it('stays closed when there is no link to show', () => {
+ renderFallback(null);
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('closes when the user dismisses it', () => {
+ const onClose = renderFallback(link);
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }));
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+});
diff --git a/client/src/components/ShareLinkFallback.tsx b/client/src/components/ShareLinkFallback.tsx
new file mode 100644
index 0000000..a7da35e
--- /dev/null
+++ b/client/src/components/ShareLinkFallback.tsx
@@ -0,0 +1,39 @@
+import type { FocusEvent } from 'react';
+import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from '@mui/material';
+
+interface ShareLinkFallbackProps {
+ link: string | null;
+ onClose: () => void;
+}
+
+export default function ShareLinkFallback({ link, onClose }: ShareLinkFallbackProps) {
+ return (
+
+ );
+}
diff --git a/client/src/pages/LandingPage.test.tsx b/client/src/pages/LandingPage.test.tsx
new file mode 100644
index 0000000..7e1e546
--- /dev/null
+++ b/client/src/pages/LandingPage.test.tsx
@@ -0,0 +1,130 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router';
+import { ThemeProvider } from '@mui/material/styles';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import theme from '../theme/theme';
+
+const { navigateMock, postMock } = vi.hoisted(() => ({
+ navigateMock: vi.fn(),
+ postMock: vi.fn(),
+}));
+
+vi.mock('react-router', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useNavigate: () => navigateMock,
+}));
+
+vi.mock('../context/AuthContext', () => ({
+ useAuth: () => ({
+ user: { name: 'Ada Lovelace', avatar: '' },
+ login: vi.fn(),
+ logout: vi.fn(),
+ }),
+}));
+
+vi.mock('../api/axios', () => ({
+ default: { post: postMock },
+}));
+
+vi.mock('../components/EtherealShadow', () => ({ default: () => null }));
+vi.mock('../components/UpcomingMeetings', () => ({ default: () => null }));
+vi.mock('../components/ScheduleMeetingDialog', () => ({ default: () => null }));
+
+import LandingPage from './LandingPage';
+
+function renderLanding() {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+beforeAll(() => {
+ window.matchMedia ||= vi.fn().mockImplementation((query) => ({
+ matches: false, media: query, onchange: null,
+ addListener: vi.fn(), removeListener: vi.fn(),
+ addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(),
+ }));
+});
+
+beforeEach(() => {
+ navigateMock.mockClear();
+ postMock.mockReset();
+});
+
+describe('LandingPage join and create', () => {
+ it('joins from a pasted invite link instead of keeping the query string', () => {
+ renderLanding();
+ fireEvent.change(screen.getByRole('textbox', { name: 'Meeting code or link' }), {
+ target: { value: 'https://ameet.raja-dev.me/lobby/abc-defg-hij?guest=1' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Join' }));
+ expect(navigateMock).toHaveBeenCalledWith('/lobby/abc-defg-hij');
+ });
+
+ it('joins when the code is typed as three words', () => {
+ renderLanding();
+ fireEvent.change(screen.getByRole('textbox', { name: 'Meeting code or link' }), {
+ target: { value: 'abc defg hij' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Join' }));
+ expect(navigateMock).toHaveBeenCalledWith('/lobby/abc-defg-hij');
+ });
+
+ it('explains a bad code and stays on the page', () => {
+ renderLanding();
+ fireEvent.change(screen.getByRole('textbox', { name: 'Meeting code or link' }), {
+ target: { value: 'not a meeting' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Join' }));
+ expect(navigateMock).not.toHaveBeenCalled();
+ expect(screen.getByRole('alert')).toHaveTextContent(/meeting code/i);
+ });
+
+ it('tells the user when a new meeting cannot be created', async () => {
+ postMock.mockRejectedValue(new Error('offline'));
+ renderLanding();
+ fireEvent.click(screen.getByRole('button', { name: 'New meeting' }));
+ expect(await screen.findByRole('alert')).toHaveTextContent(/could not start/i);
+ expect(navigateMock).not.toHaveBeenCalled();
+ });
+
+ it('keeps a failed create beside New meeting when the join field changes', async () => {
+ postMock.mockRejectedValue(new Error('offline'));
+ renderLanding();
+ fireEvent.click(screen.getByRole('button', { name: 'New meeting' }));
+ expect(await screen.findByRole('alert')).toHaveTextContent(/could not start/i);
+
+ const code = screen.getByRole('textbox', { name: 'Meeting code or link' });
+ const newMeeting = screen.getByRole('button', { name: 'New meeting' });
+ fireEvent.change(code, { target: { value: 'not a meeting' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Join' }));
+
+ expect(screen.getByText(/could not start/i)).toBeInTheDocument();
+ expect(screen.getByText(/meeting code/i)).toBeInTheDocument();
+ expect(code).toHaveAttribute('aria-describedby', 'join-code-error');
+ expect(newMeeting).toHaveAttribute('aria-describedby', 'create-meeting-error');
+ expect(code).not.toHaveAccessibleDescription(/could not start/i);
+
+ fireEvent.change(code, { target: { value: 'not a meetin' } });
+
+ expect(screen.queryByText(/meeting code/i)).not.toBeInTheDocument();
+ expect(screen.getByText(/could not start/i)).toBeInTheDocument();
+ expect(code).not.toHaveAttribute('aria-describedby');
+ expect(newMeeting).toHaveAttribute('aria-describedby', 'create-meeting-error');
+ expect(code).not.toHaveAccessibleDescription(/could not start/i);
+ });
+
+ it('opens the room after a meeting is created', async () => {
+ postMock.mockResolvedValue({ data: { roomId: 'abc-defg-hij' } });
+ renderLanding();
+ fireEvent.click(screen.getByRole('button', { name: 'New meeting' }));
+ await waitFor(() => expect(navigateMock).toHaveBeenCalledWith(
+ '/room/abc-defg-hij',
+ { state: { fromCreate: true } },
+ ));
+ });
+});
diff --git a/client/src/pages/LandingPage.tsx b/client/src/pages/LandingPage.tsx
index fbb9554..d304b35 100644
--- a/client/src/pages/LandingPage.tsx
+++ b/client/src/pages/LandingPage.tsx
@@ -27,6 +27,7 @@ import EtherealShadow from "../components/EtherealShadow";
import ScheduleMeetingDialog from "../components/ScheduleMeetingDialog";
import UpcomingMeetings from "../components/UpcomingMeetings";
import type { RoomMetadataDto, ScheduledMeetingDto } from "@a-meet/contracts";
+import { parseMeetingCode } from "../utils/meeting-code";
// Warm graphite + ember + dusty sage. Smoke, not glow. Shared with the lobby.
import { DK } from "../theme/theme";
@@ -55,6 +56,16 @@ const primaryBtn = {
},
};
+const fieldErrorSx = {
+ maxWidth: 440,
+ mt: 1,
+ textAlign: "center",
+ fontFamily: DK.font,
+ fontSize: 13.5,
+ lineHeight: 1.45,
+ color: "#ffb4a8",
+};
+
// framer-motion entrance: a staggered rise for the hero column.
const container = {
hidden: {},
@@ -69,6 +80,8 @@ export default function LandingPage() {
const { user, login, logout } = useAuth();
const navigate = useNavigate();
const [joinCode, setJoinCode] = useState("");
+ const [joinError, setJoinError] = useState("");
+ const [createError, setCreateError] = useState("");
const [creating, setCreating] = useState(false);
const [scheduleOpen, setScheduleOpen] = useState(false);
const [editingMeeting, setEditingMeeting] = useState(null);
@@ -77,6 +90,8 @@ export default function LandingPage() {
async function handleNewMeeting() {
setCreating(true);
+ setCreateError("");
+ setJoinError("");
try {
const { data } = await api.post("/rooms");
// The creator instant-joins (Google-Meet behaviour) — straight into the
@@ -84,6 +99,8 @@ export default function LandingPage() {
// RoomGuard uses to allow this; anyone opening the shared link later (even
// this same account in another browser) has no marker and hits the lobby.
navigate(`/room/${data.roomId}`, { state: { fromCreate: true } });
+ } catch {
+ setCreateError("Could not start the meeting. Check your connection and try again.");
} finally {
setCreating(false);
}
@@ -107,11 +124,14 @@ export default function LandingPage() {
function handleJoin(e: FormEvent) {
e.preventDefault();
- let code = joinCode.trim();
- if (!code) return;
- if (code.includes("/")) code = code.split("/").filter(Boolean).pop() ?? "";
- code = code.toLowerCase().replace(/\s+/g, "");
- if (code) navigate(`/lobby/${encodeURIComponent(code)}`);
+ const code = parseMeetingCode(joinCode);
+ if (!code) {
+ setJoinError("That does not look like a meeting code. Use a code like abc-defg-hij, or paste the invite link.");
+ return;
+ }
+ setJoinError("");
+ setCreateError("");
+ navigate(`/lobby/${code}`);
}
return (
@@ -374,72 +394,104 @@ export default function LandingPage() {
alignItems: "center",
justifyContent: "center",
}}>
- }
- onClick={handleNewMeeting}
- disabled={creating}
- sx={{ ...primaryBtn, width: { xs: "100%", md: "auto" } }}>
- {creating ? "Starting…" : "New meeting"}
-
-
- {/* Join-by-code pill */}
-
- setJoinCode(e.target.value)}
- autoComplete="off"
- sx={{
- fontFamily: DK.font,
- fontSize: 15,
- color: DK.ink,
- "& input::placeholder": { color: DK.faint, opacity: 1 },
- }}
- />
}
+ startIcon={}
+ onClick={handleNewMeeting}
+ disabled={creating}
+ aria-describedby={createError ? "create-meeting-error" : undefined}
+ sx={{ ...primaryBtn, width: { xs: "100%", md: "auto" } }}>
+ {creating ? "Starting…" : "New meeting"}
+
+ {createError && (
+
+ {createError}
+
+ )}
+
+
+ {/* Join-by-code pill */}
+
+
- Join
-
+
+ {
+ setJoinCode(e.target.value);
+ if (joinError) setJoinError("");
+ }}
+ autoComplete="off"
+ inputProps={{
+ autoCapitalize: "none",
+ autoCorrect: "off",
+ spellCheck: false,
+ "aria-label": "Meeting code or link",
+ "aria-invalid": joinError ? true : undefined,
+ "aria-describedby": joinError ? "join-code-error" : undefined,
+ }}
+ sx={{
+ fontFamily: DK.font,
+ fontSize: 15,
+ color: DK.ink,
+ "& input::placeholder": { color: DK.faint, opacity: 1 },
+ }}
+ />
+ }
+ sx={{
+ flexShrink: 0,
+ borderRadius: "999px",
+ px: 2.25,
+ height: 44,
+ ml: 1,
+ fontFamily: DK.font,
+ fontWeight: 700,
+ fontSize: 14.5,
+ textTransform: "none",
+ color: joinCode.trim() ? "#fff" : DK.faint,
+ bgcolor: joinCode.trim() ? DK.ember : "transparent",
+ "&:hover": {
+ bgcolor: joinCode.trim() ? DK.emberDark : DK.surface2,
+ },
+ "&.Mui-disabled": { color: DK.faint },
+ transition: "background-color 0.2s, color 0.2s",
+ }}>
+ Join
+
+
+ {joinError && (
+
+ {joinError}
+
+ )}
diff --git a/client/src/pages/RoomPage.tsx b/client/src/pages/RoomPage.tsx
index 432f0c8..318b826 100644
--- a/client/src/pages/RoomPage.tsx
+++ b/client/src/pages/RoomPage.tsx
@@ -47,6 +47,8 @@ import type {
import { playSound, isSoundEnabled, toggleSound } from '../services/sounds';
import { appLogger } from '../utils/logger';
import { downloadTranscript, mergeTranscriptEntries } from '../utils/transcript';
+import { copyText } from '../utils/copy-text';
+import ShareLinkFallback from '../components/ShareLinkFallback';
const REACTION_EMOJIS = ['👍', '❤️', '😂', '😮', '👏', '🎉'];
const TRANSCRIPT_CONSENT_KEY = 'ameet:transcription-consent-v2';
@@ -130,6 +132,7 @@ export default function RoomPage() {
const [soundEnabled, setSoundEnabled] = useState(() => isSoundEnabled());
const [isHost, setIsHost] = useState(false);
const [leaveDialogOpen, setLeaveDialogOpen] = useState(false);
+ const [manualLink, setManualLink] = useState(null);
// Bottom-left transient flashes: join/leave, chat previews, copy confirmation.
const [notes, setNotes] = useState([]);
const noteIdRef = useRef(0);
@@ -587,12 +590,13 @@ export default function RoomPage() {
async function handleCopyLink() {
const link = `${window.location.origin}/lobby/${roomId}`;
- try {
- await navigator.clipboard.writeText(link);
+ if (await copyText(link)) {
+ setManualLink(null);
pushNote({ kind: 'event', variant: 'info', text: 'Meeting link copied' });
- } catch {
- pushNote({ kind: 'event', variant: 'info', text: 'Press the link button to copy' });
+ return;
}
+ // The info toast clips long text, so a failed copy opens the link itself.
+ setManualLink(link);
}
// --- Camera tiles (shared by presentation rail) ---
@@ -1487,6 +1491,8 @@ export default function RoomPage() {
+ setManualLink(null)} />
+
{/* Host leave confirmation — non-hosts leave immediately (no dialog) */}