feat(homepage): expand inline lead form to name/email/phone/type/message
Ping Search Engines / Notify Search Engines (push) Successful in 3s
E2E Test Suite / Smoke Tests (P0) (push) Has been cancelled
E2E Test Suite / Critical User Journeys (push) Has been cancelled
E2E Test Suite / API Integration Tests (push) Has been cancelled
E2E Test Suite / Form Interaction Tests (push) Has been cancelled
E2E Test Suite / Destructive & Chaos Tests (push) Has been cancelled
E2E Test Suite / Cross-Browser Regression (chromium) (push) Has been cancelled
E2E Test Suite / Cross-Browser Regression (firefox) (push) Has been cancelled
E2E Test Suite / Cross-Browser Regression (webkit) (push) Has been cancelled
E2E Test Suite / Mobile Device Tests (push) Has been cancelled
E2E Test Suite / Security Header Tests (push) Has been cancelled
E2E Test Suite / Test Report Summary (push) Has been cancelled
Deploy to Production / Pre-Deploy Tests (push) Failing after 10m58s
Deploy to Production / Build & Verify (push) Failing after 16m37s
Deploy to Production / Deploy to Fly.io (push) Failing after 14m3s
Deploy to Production / Deploy to VPS (PM2) (push) Failing after 14m3s
Deploy to Production / Deploy to Render (push) Failing after 14m55s
Deploy to Production / Deploy to Railway (push) Failing after 14m55s
Deploy to Production / Post-Deploy Verification (push) Failing after 13m32s
Deploy to Production / Notify on Failure (push) Failing after 13m51s

Rework the homepage InlineLeadForm from a single combined "email or phone"
contact field to an explicit 5-field layout — name, email (type=email),
phone (type=tel), project type and an optional message — so each field gets
the correct mobile keyboard and leads carry a real email + phone separately.

- Required fields kept to name + email + project type for a low-friction
  mobile flow; phone and message are optional.
- Email uses type=email + inputmode=email; phone uses type=tel + inputmode=tel
  for the numeric keypad. Message is an optional textarea; when blank the
  client synthesizes a >=10 char summary so the /api/contact validator passes.
- Still posts to the same /api/contact endpoint tagged source=homepage-inline,
  delivering leads exactly the same way the /contact page does (no page nav).
- Update inline-lead-form.spec.ts to the new field set and keyboards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
WorkRoot Agent
2026-06-17 20:33:26 +05:30
co-authored by Claude Opus 4.8
parent 870075b33f
commit 5a6db9c20f
3 changed files with 200 additions and 96 deletions
+146 -62
View File
@@ -1,20 +1,25 @@
---
// InlineLeadForm — Three-field lead capture rendered inline on the homepage.
// InlineLeadForm — Compact lead capture rendered inline on the homepage.
//
// The goal is to cut the friction step of routing every CTA through /contact.
// Visitors who reach the bottom of the homepage can submit name + contact
// (email OR phone) + project type without a navigation. The submission reuses
// /api/contact under the hood and is tagged `source: 'homepage-inline'` so the
// backend / analytics layer can segment these leads cleanly.
// Goal: let visitors who reach the bottom of the homepage submit a project
// enquiry without the friction of navigating to /contact. The submission
// reuses the SAME /api/contact endpoint as the full contact page and is tagged
// `source: 'homepage-inline'` so the backend / analytics layer can segment
// these leads cleanly (it also routes them to the `[Project Inquiry]` admin
// subject prefix).
//
// Why a single "contact" field instead of separate email + phone:
// - 3 fields keeps the form psychologically tiny and dramatically lifts
// completion. Real-world conversion testing repeatedly shows 3 → 5+ fields
// is the biggest single-step drop-off on B2B lead forms.
// - We sniff the value on submit. If it has an "@" we treat it as email.
// Otherwise we treat it as a phone and synthesize a placeholder email to
// satisfy the backend's strict email validation. The phone is also sent
// through, so the admin email body shows the real reachable value.
// Fields (kept to 5, mobile-first):
// 1. Full name — required
// 2. Email — required, type="email" (email keyboard on mobile)
// 3. Phone — optional, type="tel" (numeric keypad on mobile)
// 4. Project type — required, <select> mapped to the backend `subject`
// 5. Message — optional <textarea>; synthesized when left blank so
// the backend's message>=10 char rule always passes
//
// Only name + email + project type are *required* — phone and message stay
// optional to keep completion friction low on mobile, which is where most of
// these submissions originate. The backend already treats `phone` as optional
// and only validates its format when present.
---
<section
@@ -108,42 +113,70 @@
</p>
</div>
<!-- Contact (email or phone) -->
<!-- Email + Phone row -->
<div class="grid sm:grid-cols-2 gap-5">
<!-- Email (required) -->
<div>
<label
for="inline-lead-contact"
for="inline-lead-email"
class="block text-sm font-semibold text-secondary-700 dark:text-secondary-200 mb-1.5"
>
Email or phone <span class="text-red-500" aria-hidden="true">*</span>
Email <span class="text-red-500" aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<input
type="text"
id="inline-lead-contact"
name="contact"
type="email"
id="inline-lead-email"
name="email"
required
autocomplete="email tel"
maxlength="254"
autocomplete="email"
inputmode="email"
aria-required="true"
aria-describedby="inline-lead-contact-hint inline-lead-contact-error"
placeholder="jane@company.com or +91 95614 17403"
aria-describedby="inline-lead-email-error"
placeholder="jane@company.com"
class="block w-full min-h-[48px] px-4 py-3 rounded-xl border border-secondary-200 dark:border-secondary-600 bg-secondary-50 dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 placeholder-secondary-400 dark:placeholder-secondary-500 text-base focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/30 transition-colors"
/>
<p
id="inline-lead-contact-hint"
class="mt-1.5 text-xs text-secondary-500 dark:text-secondary-400"
>
Drop your best email or phone number — whichever is easiest.
</p>
<p
id="inline-lead-contact-error"
id="inline-lead-email-error"
class="mt-1.5 text-xs text-red-600 dark:text-red-400 hidden"
role="alert"
>
Please enter a valid email address or phone number.
Please enter a valid email address.
</p>
</div>
<!-- Phone (optional) -->
<div>
<label
for="inline-lead-phone"
class="block text-sm font-semibold text-secondary-700 dark:text-secondary-200 mb-1.5"
>
Phone
<span class="font-normal text-secondary-400 dark:text-secondary-500">(optional)</span>
</label>
<input
type="tel"
id="inline-lead-phone"
name="phone"
autocomplete="tel"
inputmode="tel"
pattern="[+()0-9.\s\-]{7,}"
maxlength="32"
aria-describedby="inline-lead-phone-error"
placeholder="+91 95614 17403"
class="block w-full min-h-[48px] px-4 py-3 rounded-xl border border-secondary-200 dark:border-secondary-600 bg-secondary-50 dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 placeholder-secondary-400 dark:placeholder-secondary-500 text-base focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/30 transition-colors"
/>
<p
id="inline-lead-phone-error"
class="mt-1.5 text-xs text-red-600 dark:text-red-400 hidden"
role="alert"
>
Please enter a valid phone number, or leave it blank.
</p>
</div>
</div>
<!-- Project type -->
<div>
<label
@@ -184,6 +217,33 @@
</p>
</div>
<!-- Message (optional) -->
<div>
<label
for="inline-lead-message"
class="block text-sm font-semibold text-secondary-700 dark:text-secondary-200 mb-1.5"
>
Project details
<span class="font-normal text-secondary-400 dark:text-secondary-500">(optional)</span>
</label>
<textarea
id="inline-lead-message"
name="message"
rows="3"
maxlength="5000"
aria-describedby="inline-lead-message-error"
placeholder="A sentence or two about what you want to build, your timeline, or budget…"
class="block w-full min-h-[96px] px-4 py-3 rounded-xl border border-secondary-200 dark:border-secondary-600 bg-secondary-50 dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 placeholder-secondary-400 dark:placeholder-secondary-500 text-base focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/30 transition-colors resize-y"
></textarea>
<p
id="inline-lead-message-error"
class="mt-1.5 text-xs text-red-600 dark:text-red-400 hidden"
role="alert"
>
Please add a little more detail (at least 10 characters), or leave it blank.
</p>
</div>
<!-- General form error (server-side / network) -->
<div
id="inline-lead-form-error"
@@ -267,13 +327,17 @@
if (!form) return;
var nameInput = document.getElementById('inline-lead-name');
var contactInput = document.getElementById('inline-lead-contact');
var emailInput = document.getElementById('inline-lead-email');
var phoneInput = document.getElementById('inline-lead-phone');
var projectTypeInput = document.getElementById('inline-lead-project-type');
var messageInput = document.getElementById('inline-lead-message');
var websiteInput = document.getElementById('inline-lead-website');
var nameError = document.getElementById('inline-lead-name-error');
var contactError = document.getElementById('inline-lead-contact-error');
var emailError = document.getElementById('inline-lead-email-error');
var phoneError = document.getElementById('inline-lead-phone-error');
var projectTypeError = document.getElementById('inline-lead-project-type-error');
var messageError = document.getElementById('inline-lead-message-error');
var formError = document.getElementById('inline-lead-form-error');
var submitBtn = document.getElementById('inline-lead-submit');
@@ -283,7 +347,7 @@
var successPanel = document.getElementById('inline-lead-success');
// Tolerant phone test: allow +, digits, spaces, dashes, parens, dots.
// We require at least 7 digits so single-digit / accidental presses fail.
// Require at least 7 digits so single-digit / accidental presses fail.
var PHONE_REGEX = /^[+]?[()\d\s.\-]{7,}$/;
var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -311,6 +375,10 @@
}
}
function phoneIsValid(val) {
return PHONE_REGEX.test(val) && digitsOnly(val).length >= 7;
}
function validate() {
var ok = true;
@@ -322,14 +390,21 @@
showError(nameError, false);
}
var contactVal = (contactInput.value || '').trim();
var isEmail = EMAIL_REGEX.test(contactVal);
var isPhone = PHONE_REGEX.test(contactVal) && digitsOnly(contactVal).length >= 7;
if (!contactVal || (!isEmail && !isPhone)) {
showError(contactError, true);
var emailVal = (emailInput.value || '').trim();
if (!EMAIL_REGEX.test(emailVal)) {
showError(emailError, true);
ok = false;
} else {
showError(contactError, false);
showError(emailError, false);
}
// Phone is optional — only validate format when something was typed.
var phoneVal = (phoneInput.value || '').trim();
if (phoneVal && !phoneIsValid(phoneVal)) {
showError(phoneError, true);
ok = false;
} else {
showError(phoneError, false);
}
var projectVal = projectTypeInput.value || '';
@@ -340,13 +415,24 @@
showError(projectTypeError, false);
}
// Message is optional — only enforce the min length when something was typed.
var messageVal = (messageInput.value || '').trim();
if (messageVal && messageVal.length < 10) {
showError(messageError, true);
ok = false;
} else {
showError(messageError, false);
}
return ok;
}
// Live-validate on input/change so the error UI doesn't linger after fix.
// Live-validate on blur/change so the error UI clears once a field is fixed.
nameInput.addEventListener('blur', validate);
contactInput.addEventListener('blur', validate);
emailInput.addEventListener('blur', validate);
phoneInput.addEventListener('blur', validate);
projectTypeInput.addEventListener('change', validate);
messageInput.addEventListener('blur', validate);
// Map the user-friendly project type label to the backend subject code so
// the existing /api/contact validator passes. Anything unmapped → 'other'.
@@ -359,41 +445,39 @@
};
function buildPayload() {
var contactVal = (contactInput.value || '').trim();
var isEmail = EMAIL_REGEX.test(contactVal);
var nameVal = (nameInput.value || '').trim();
var emailVal = (emailInput.value || '').trim();
var phoneVal = (phoneInput.value || '').trim();
var projectType = projectTypeInput.value || 'Other';
var subject = SUBJECT_BY_PROJECT_TYPE[projectType] || 'other';
var messageVal = (messageInput.value || '').trim();
var email, phone;
if (isEmail) {
email = contactVal;
phone = undefined;
// The backend requires a message of at least 10 characters. When the
// visitor leaves the (optional) details blank, synthesize a concise
// summary so the submission still goes through and the admin email
// carries the useful context.
var message;
if (messageVal.length >= 10) {
message = messageVal;
} else {
// Treat as phone. Synthesize a stable placeholder email so the
// backend's strict email validator passes — the admin notification
// surfaces the real phone in both Phone: and Message: fields so the
// team always has a real way to reach the lead.
var digits = digitsOnly(contactVal) || 'unknown';
email = 'lead-' + digits + '@homepage.workroot.in';
phone = contactVal;
}
var message =
'New inline lead from the homepage.\n\n' +
message =
'New project enquiry from the homepage form.\n\n' +
'Project type: ' + projectType + '\n' +
'Preferred contact: ' + contactVal;
'Name: ' + nameVal +
(phoneVal ? '\nPhone: ' + phoneVal : '') +
(messageVal ? '\n\nNote: ' + messageVal : '');
}
var payload = {
name: nameVal,
email: email,
email: emailVal,
subject: subject,
message: message,
source: 'homepage-inline',
projectType: projectType,
website: (websiteInput && websiteInput.value) || '',
};
if (phone) payload.phone = phone;
if (phoneVal) payload.phone = phoneVal;
return payload;
}
+5 -3
View File
@@ -744,9 +744,11 @@ const techStack = [
<!-- Inline Lead Form — sits between the tech-stack social-proof block
and the final CTA band so visitors who are ready to start a project can
submit a 3-field enquiry without leaving the homepage. Submits to the
same /api/contact endpoint and is tagged `source: 'homepage-inline'` so
the lead can be segmented downstream. -->
submit a compact enquiry (name, email, phone, project type, message)
without leaving the homepage. Required fields are kept to name + email +
project type for a low-friction mobile flow. Submits to the same
/api/contact endpoint and is tagged `source: 'homepage-inline'` so the
lead can be segmented downstream. -->
<InlineLeadForm />
<!-- Homepage FAQ — single canonical FAQ section for /. Covers the cost /
+32 -14
View File
@@ -6,7 +6,10 @@ import { test, expect } from '@playwright/test';
* Covers:
* - The form is mounted on the homepage between FAQ/tech-stack and the
* final CTA band.
* - Filling all three fields and submitting calls /api/contact and shows
* - 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.
@@ -15,7 +18,7 @@ import { test, expect } from '@playwright/test';
*/
test.describe('Inline Lead Form (homepage)', () => {
test('form is visible on the homepage and has the three required fields', async ({ page }) => {
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');
@@ -23,8 +26,10 @@ test.describe('Inline Lead Form (homepage)', () => {
await expect(form).toBeVisible();
await expect(page.locator('#inline-lead-name')).toBeVisible();
await expect(page.locator('#inline-lead-contact')).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');
@@ -32,9 +37,17 @@ test.describe('Inline Lead Form (homepage)', () => {
expect(submitBox).not.toBeNull();
expect(submitBox!.height).toBeGreaterThanOrEqual(48);
// Correct mobile-friendly attributes on the contact field.
await expect(page.locator('#inline-lead-contact')).toHaveAttribute('autocomplete', 'email tel');
await expect(page.locator('#inline-lead-contact')).toHaveAttribute('inputmode', 'email');
// 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 }) => {
@@ -57,12 +70,12 @@ test.describe('Inline Lead Form (homepage)', () => {
// 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-contact-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 and submits the form successfully with an email contact', async ({ page }) => {
test('fills the required fields and submits successfully', async ({ page }) => {
await page.goto('/');
const form = page.getByTestId('inline-lead-form');
await form.scrollIntoViewIfNeeded();
@@ -85,7 +98,7 @@ test.describe('Inline Lead Form (homepage)', () => {
});
await page.locator('#inline-lead-name').fill('Inline Lead Tester');
await page.locator('#inline-lead-contact').fill('inline-lead@example.com');
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();
@@ -103,11 +116,14 @@ test.describe('Inline Lead Form (homepage)', () => {
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('routes a phone-shaped contact value into the phone field', async ({ page }) => {
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();
@@ -127,17 +143,19 @@ test.describe('Inline Lead Form (homepage)', () => {
});
await page.locator('#inline-lead-name').fill('Phone Lead');
await page.locator('#inline-lead-contact').fill('+91 95614 17403');
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');
// The client synthesizes a placeholder email so the backend's strict email
// validator accepts phone-only contacts. The real phone is what matters.
expect(captured.email).toMatch(/^lead-\d+@homepage\.workroot\.in$/);
expect(captured.message).toBe('We need a GeM-ready procurement portal.');
expect(captured.subject).toBe('government-systems');
expect(captured.source).toBe('homepage-inline');
});