import { test, expect } from '@playwright/test'; /** * Inline Lead Form — Homepage * * Covers: * - The form is mounted on the homepage between FAQ/tech-stack and the * final CTA band. * - It exposes the compact 5-field layout (name, email, phone, project type, * message) with the correct mobile keyboard hints per field. Only name, * email and project type are required; phone and message are optional. * - Filling the required fields and submitting calls /api/contact and shows * an in-place thank-you state without a full-page navigation. * - The submission is tagged `source: 'homepage-inline'` so leads can be * segmented downstream. * - The backend itself accepts the new inline payload shape * (server-side validation check). */ test.describe('Inline Lead Form (homepage)', () => { test('form is visible on the homepage with the compact field set and mobile keyboards', async ({ page }) => { await page.goto('/'); const form = page.getByTestId('inline-lead-form'); await form.scrollIntoViewIfNeeded(); await expect(form).toBeVisible(); await expect(page.locator('#inline-lead-name')).toBeVisible(); await expect(page.locator('#inline-lead-email')).toBeVisible(); await expect(page.locator('#inline-lead-phone')).toBeVisible(); await expect(page.locator('#inline-lead-project-type')).toBeVisible(); await expect(page.locator('#inline-lead-message')).toBeVisible(); // Touch-target sanity: the submit button must be at least 48px tall. const submit = page.locator('#inline-lead-submit'); const submitBox = await submit.boundingBox(); expect(submitBox).not.toBeNull(); expect(submitBox!.height).toBeGreaterThanOrEqual(48); // Correct mobile keyboard per field. await expect(page.locator('#inline-lead-email')).toHaveAttribute('type', 'email'); await expect(page.locator('#inline-lead-email')).toHaveAttribute('inputmode', 'email'); await expect(page.locator('#inline-lead-email')).toHaveAttribute('autocomplete', 'email'); await expect(page.locator('#inline-lead-phone')).toHaveAttribute('type', 'tel'); await expect(page.locator('#inline-lead-phone')).toHaveAttribute('inputmode', 'tel'); await expect(page.locator('#inline-lead-phone')).toHaveAttribute('autocomplete', 'tel'); // Phone + message are optional (no `required` attribute). await expect(page.locator('#inline-lead-phone')).not.toHaveAttribute('required', /.*/); await expect(page.locator('#inline-lead-message')).not.toHaveAttribute('required', /.*/); }); test('blocks submit when required fields are empty', async ({ page }) => { await page.goto('/'); const form = page.getByTestId('inline-lead-form'); await form.scrollIntoViewIfNeeded(); // Intercept so a real submit can't slip through if the validator fails. let apiCalled = false; await page.route('**/api/contact', async (route) => { apiCalled = true; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, message: 'ok' }), }); }); await page.locator('#inline-lead-submit').click(); // The blank-field errors should surface and the network should NOT be hit. await expect(page.locator('#inline-lead-name-error')).toBeVisible(); await expect(page.locator('#inline-lead-email-error')).toBeVisible(); await expect(page.locator('#inline-lead-project-type-error')).toBeVisible(); expect(apiCalled).toBe(false); }); test('fills the required fields and submits successfully', async ({ page }) => { await page.goto('/'); const form = page.getByTestId('inline-lead-form'); await form.scrollIntoViewIfNeeded(); // Capture the outgoing payload so we can assert source/projectType tagging. let captured: any = null; await page.route('**/api/contact', async (route) => { const req = route.request(); try { captured = req.postDataJSON(); } catch { captured = null; } await route.fulfill({ status: 200, contentType: 'application/json', headers: { 'Access-Control-Allow-Origin': '*' }, body: JSON.stringify({ success: true, message: 'Received.' }), }); }); await page.locator('#inline-lead-name').fill('Inline Lead Tester'); await page.locator('#inline-lead-email').fill('inline-lead@example.com'); await page.locator('#inline-lead-project-type').selectOption('Web App'); await page.locator('#inline-lead-submit').click(); // Success state should replace the form in-place, no navigation. await expect(page.getByTestId('inline-lead-success')).toBeVisible(); await expect(page.getByTestId('inline-lead-success')).toContainText( "Thanks! We'll reply within 24 hours." ); await expect(page).toHaveURL(/\/$|\/#?$/); // Confirm the payload is tagged correctly. expect(captured).toBeTruthy(); expect(captured.source).toBe('homepage-inline'); expect(captured.projectType).toBe('Web App'); expect(captured.name).toBe('Inline Lead Tester'); expect(captured.email).toBe('inline-lead@example.com'); expect(captured.subject).toBe('web-development'); // Message is synthesized when left blank so the backend's >=10 char rule passes. expect(typeof captured.message).toBe('string'); expect(captured.message.length).toBeGreaterThanOrEqual(10); // Honeypot must be empty for a real user submission. expect(captured.website ?? '').toBe(''); }); test('sends the phone and a typed message through their own fields', async ({ page }) => { await page.goto('/'); const form = page.getByTestId('inline-lead-form'); await form.scrollIntoViewIfNeeded(); let captured: any = null; await page.route('**/api/contact', async (route) => { try { captured = route.request().postDataJSON(); } catch { captured = null; } await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, message: 'Received.' }), }); }); await page.locator('#inline-lead-name').fill('Phone Lead'); await page.locator('#inline-lead-email').fill('phone-lead@example.com'); await page.locator('#inline-lead-phone').fill('+91 95614 17403'); await page.locator('#inline-lead-project-type').selectOption('Government / GeM'); await page.locator('#inline-lead-message').fill('We need a GeM-ready procurement portal.'); await page.locator('#inline-lead-submit').click(); await expect(page.getByTestId('inline-lead-success')).toBeVisible(); expect(captured).toBeTruthy(); // The real email and phone are sent as-is in their own fields. expect(captured.email).toBe('phone-lead@example.com'); expect(captured.phone).toBe('+91 95614 17403'); expect(captured.message).toBe('We need a GeM-ready procurement portal.'); expect(captured.subject).toBe('government-systems'); expect(captured.source).toBe('homepage-inline'); }); }); // ============================================================ // Server-side: /api/contact accepts the inline lead payload // ============================================================ test.describe('API: inline-lead payload (/api/contact)', () => { test('accepts a payload tagged source=homepage-inline', async ({ request }) => { const response = await request.post('/api/contact', { headers: { 'Content-Type': 'application/json' }, data: { name: 'Inline API Test', email: 'inline-api@example.com', subject: 'web-development', message: 'New inline lead from the homepage.\n\nProject type: Web App\nPreferred contact: inline-api@example.com', source: 'homepage-inline', projectType: 'Web App', website: '', }, }); // 200 success or 429 rate-limited (both acceptable in CI re-runs). expect([200, 429]).toContain(response.status()); if (response.status() === 200) { const body = (await response.json()) as { success: boolean }; expect(body.success).toBe(true); } }); test('accepts the new erp-solutions and government-systems subjects', async ({ request }) => { for (const subject of ['erp-solutions', 'government-systems']) { const response = await request.post('/api/contact', { headers: { 'Content-Type': 'application/json' }, data: { name: 'Inline API Subject Test', email: `inline-${subject}@example.com`, subject, message: `Inline lead for ${subject}. Project type test.`, source: 'homepage-inline', projectType: subject === 'erp-solutions' ? 'ERP / Custom Software' : 'Government / GeM', website: '', }, }); // Either accepted or rate-limited — both prove the subject is recognized // (a rejected subject would 422 instead). expect([200, 429]).toContain(response.status()); } }); test('still rejects payloads missing required fields', async ({ request }) => { const response = await request.post('/api/contact', { headers: { 'Content-Type': 'application/json' }, data: { name: '', email: 'inline-missing@example.com', subject: 'web-development', message: 'A message long enough to pass validation.', source: 'homepage-inline', website: '', }, }); expect(response.status()).toBe(422); const body = (await response.json()) as { success: boolean; error: string }; expect(body.success).toBe(false); expect(body.error).toBeTruthy(); }); test('treats unknown source values as the safe default and still accepts the lead', async ({ request }) => { const response = await request.post('/api/contact', { headers: { 'Content-Type': 'application/json' }, data: { name: 'Unknown Source Test', email: 'inline-unknown@example.com', subject: 'other', message: 'Inline lead from an unrecognised source string.', source: 'totally-made-up-source', projectType: 'Other', website: '', }, }); expect([200, 429]).toContain(response.status()); }); });