feat: add inline lead form to homepage
E2E Test Suite / Form Interaction Tests (push) Failing after 10m19s
E2E Test Suite / Security Header Tests (push) Failing after 10m5s
E2E Test Suite / Mobile Device Tests (push) Failing after 10m58s
E2E Test Suite / Cross-Browser Regression (webkit) (push) Failing after 11m50s
E2E Test Suite / Cross-Browser Regression (firefox) (push) Failing after 12m43s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Failing after 13m35s
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 14m27s
Ping Search Engines / Notify Search Engines (push) Failing after 14m11s
E2E Test Suite / Smoke Tests (P0) (push) Failing after 21m12s
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
E2E Test Suite / Test Report Summary (push) Has been cancelled
Deploy to Production / Build & Verify (push) Blocked by required conditions
Deploy to Production / Pre-Deploy Tests (push) Blocked by required conditions
Deploy to Production / Deploy to Railway (push) Blocked by required conditions
Deploy to Production / Deploy to Render (push) Blocked by required conditions
Deploy to Production / Deploy to VPS (PM2) (push) Blocked by required conditions
Deploy to Production / Deploy to Fly.io (push) Blocked by required conditions
Deploy to Production / Post-Deploy Verification (push) Blocked by required conditions
Deploy to Production / Notify on Failure (push) Has been cancelled

Mounts a 3-field lead-capture form (name, email-or-phone, project type)
between the FAQ/tech-stack block and the final CTA band so visitors who
are ready to start a project can submit without leaving the homepage.
Cuts the friction step of routing every CTA through /contact.

- New src/components/InlineLeadForm.astro:
  * Heading, micro-trust subhead, honeypot, client-side validation
  * Smart contact-field routing: detects email vs phone and synthesises
    a placeholder email for phone-only leads so the existing /api/contact
    validator accepts them (the real phone is preserved and surfaced in
    the admin notification)
  * In-place success state with phone + WhatsApp fallbacks; no navigation
  * 48px touch targets, dark-mode safe, autocomplete + inputmode attrs
- src/pages/api/contact.ts: accept and surface `source` / `projectType`
  metadata (allowlisted source values, free-text projectType capped at
  80 chars), add `erp-solutions` and `government-systems` subject codes,
  and include the source/project info in both the admin email and logs.
- src/pages/index.astro: import + mount InlineLeadForm above the final
  CTA section.
- tests/inline-lead-form.spec.ts: 8 specs covering UI render, mobile
  attributes, blank-field validation, email-submit + phone-submit
  payload routing, and server-side acceptance of the new payload shape,
  new subjects, and unknown-source fallback.
This commit is contained in:
platform-mcp
2026-06-17 11:49:47 +05:30
parent 64be202f75
commit a4505635e1
4 changed files with 746 additions and 0 deletions
+464
View File
@@ -0,0 +1,464 @@
---
// InlineLeadForm — Three-field 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.
//
// 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.
---
<section
id="inline-lead-form"
class="py-20 sm:py-24 bg-gradient-to-b from-white via-secondary-50 to-secondary-100 dark:from-secondary-900 dark:via-secondary-900 dark:to-secondary-950"
aria-labelledby="inline-lead-form-heading"
>
<div class="container-wrapper">
<div class="max-w-2xl mx-auto">
<div
class="relative bg-white dark:bg-secondary-800 rounded-3xl shadow-xl border border-secondary-100 dark:border-secondary-700 p-6 sm:p-10 overflow-hidden"
>
<!-- Decorative gradient corner -->
<div
class="absolute -top-32 -right-32 w-72 h-72 bg-gradient-to-br from-primary/20 to-accent/10 rounded-full blur-3xl pointer-events-none"
aria-hidden="true"
></div>
<div class="relative z-10">
<!-- Heading -->
<h2
id="inline-lead-form-heading"
class="text-2xl sm:text-3xl lg:text-4xl font-bold text-secondary-900 dark:text-secondary-100 leading-tight"
>
Tell us about your project — get a free 1-page proposal in 24 hours.
</h2>
<!-- Subhead micro-trust line -->
<p class="mt-3 text-sm sm:text-base text-secondary-600 dark:text-secondary-300">
Free 30-min call &middot; NDA on request &middot; No obligation
</p>
<!-- Form -->
<form
id="inline-lead-form-element"
class="mt-8 space-y-5"
data-testid="inline-lead-form"
novalidate
>
<!-- Honeypot (visually & SR-hidden — bots fill it, real users do not). -->
<div class="absolute -left-[9999px] top-auto w-px h-px overflow-hidden" aria-hidden="true">
<label for="inline-lead-website">Website</label>
<input
type="text"
id="inline-lead-website"
name="website"
tabindex="-1"
autocomplete="off"
/>
</div>
<!-- Name -->
<div>
<label
for="inline-lead-name"
class="block text-sm font-semibold text-secondary-700 dark:text-secondary-200 mb-1.5"
>
Your name <span class="text-red-500" aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<input
type="text"
id="inline-lead-name"
name="name"
required
minlength="2"
maxlength="100"
autocomplete="name"
aria-required="true"
aria-describedby="inline-lead-name-error"
placeholder="Jane Doe"
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-name-error"
class="mt-1.5 text-xs text-red-600 dark:text-red-400 hidden"
role="alert"
>
Please enter your name (at least 2 characters).
</p>
</div>
<!-- Contact (email or phone) -->
<div>
<label
for="inline-lead-contact"
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>
<span class="sr-only">(required)</span>
</label>
<input
type="text"
id="inline-lead-contact"
name="contact"
required
autocomplete="email tel"
inputmode="email"
aria-required="true"
aria-describedby="inline-lead-contact-hint inline-lead-contact-error"
placeholder="jane@company.com or +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-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"
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.
</p>
</div>
<!-- Project type -->
<div>
<label
for="inline-lead-project-type"
class="block text-sm font-semibold text-secondary-700 dark:text-secondary-200 mb-1.5"
>
Project type <span class="text-red-500" aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<div class="relative">
<select
id="inline-lead-project-type"
name="projectType"
required
aria-required="true"
aria-describedby="inline-lead-project-type-error"
class="block w-full min-h-[48px] appearance-none px-4 py-3 pr-12 rounded-xl border border-secondary-200 dark:border-secondary-600 bg-secondary-50 dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 text-base focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/30 transition-colors cursor-pointer"
>
<option value="" disabled selected>Choose a project type…</option>
<option value="Web App">Web App</option>
<option value="Mobile App">Mobile App</option>
<option value="ERP / Custom Software">ERP / Custom Software</option>
<option value="Government / GeM">Government / GeM</option>
<option value="Other">Other</option>
</select>
<span class="absolute inset-y-0 right-4 flex items-center pointer-events-none text-secondary-400" aria-hidden="true">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</span>
</div>
<p
id="inline-lead-project-type-error"
class="mt-1.5 text-xs text-red-600 dark:text-red-400 hidden"
role="alert"
>
Please pick a project type.
</p>
</div>
<!-- General form error (server-side / network) -->
<div
id="inline-lead-form-error"
class="hidden rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-200"
role="alert"
></div>
<!-- Submit -->
<div>
<button
type="submit"
id="inline-lead-submit"
data-cta="homepage-inline-submit"
class="group w-full min-h-[48px] inline-flex items-center justify-center rounded-xl bg-gradient-to-r from-primary-500 to-primary-600 px-8 py-4 text-base font-bold text-white shadow-lg hover:from-primary-600 hover:to-primary-700 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-2 dark:focus:ring-offset-secondary-800 transition-all duration-200 disabled:opacity-60 disabled:cursor-not-allowed"
>
<span id="inline-lead-submit-text">Get my proposal</span>
<svg
id="inline-lead-submit-arrow"
class="ml-2 w-5 h-5 group-hover:translate-x-1 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
<svg
id="inline-lead-submit-spinner"
class="ml-2 w-5 h-5 hidden animate-spin"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
<p class="mt-3 text-center text-xs text-secondary-500 dark:text-secondary-400">
We respect your inbox &mdash; one reply, no marketing list.
</p>
</div>
</form>
<!-- Success state (revealed in place after submit) -->
<div
id="inline-lead-success"
class="hidden text-center py-6"
role="status"
aria-live="polite"
data-testid="inline-lead-success"
>
<div class="w-16 h-16 mx-auto mb-5 rounded-full bg-emerald-100 dark:bg-emerald-900/40 flex items-center justify-center">
<svg class="w-8 h-8 text-emerald-600 dark:text-emerald-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 class="text-xl sm:text-2xl font-bold text-secondary-900 dark:text-secondary-100 mb-2">
Thanks! We'll reply within 24 hours.
</h3>
<p class="text-secondary-600 dark:text-secondary-300 mb-6">
Prefer to talk now? Call
<a href="tel:+919561417403" class="font-semibold text-primary-600 dark:text-primary-300 hover:underline">+91 95614 17403</a>
or
<a
href="https://wa.me/919561417403?text=Hi%20WorkRoot%2C%20I%20just%20submitted%20a%20project%20enquiry."
target="_blank"
rel="noopener"
class="font-semibold text-primary-600 dark:text-primary-300 hover:underline"
>WhatsApp us</a>.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<script is:inline>
(function () {
var form = document.getElementById('inline-lead-form-element');
if (!form) return;
var nameInput = document.getElementById('inline-lead-name');
var contactInput = document.getElementById('inline-lead-contact');
var projectTypeInput = document.getElementById('inline-lead-project-type');
var websiteInput = document.getElementById('inline-lead-website');
var nameError = document.getElementById('inline-lead-name-error');
var contactError = document.getElementById('inline-lead-contact-error');
var projectTypeError = document.getElementById('inline-lead-project-type-error');
var formError = document.getElementById('inline-lead-form-error');
var submitBtn = document.getElementById('inline-lead-submit');
var submitText = document.getElementById('inline-lead-submit-text');
var submitArrow = document.getElementById('inline-lead-submit-arrow');
var submitSpinner = document.getElementById('inline-lead-submit-spinner');
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.
var PHONE_REGEX = /^[+]?[()\d\s.\-]{7,}$/;
var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function digitsOnly(str) {
return (str || '').replace(/\D/g, '');
}
function showError(el, show) {
if (!el) return;
if (show) {
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
}
}
function setFormError(message) {
if (!formError) return;
if (message) {
formError.textContent = message;
formError.classList.remove('hidden');
} else {
formError.textContent = '';
formError.classList.add('hidden');
}
}
function validate() {
var ok = true;
var nameVal = (nameInput.value || '').trim();
if (nameVal.length < 2) {
showError(nameError, true);
ok = false;
} else {
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);
ok = false;
} else {
showError(contactError, false);
}
var projectVal = projectTypeInput.value || '';
if (!projectVal) {
showError(projectTypeError, true);
ok = false;
} else {
showError(projectTypeError, false);
}
return ok;
}
// Live-validate on input/change so the error UI doesn't linger after fix.
nameInput.addEventListener('blur', validate);
contactInput.addEventListener('blur', validate);
projectTypeInput.addEventListener('change', validate);
// Map the user-friendly project type label to the backend subject code so
// the existing /api/contact validator passes. Anything unmapped → 'other'.
var SUBJECT_BY_PROJECT_TYPE = {
'Web App': 'web-development',
'Mobile App': 'mobile-development',
'ERP / Custom Software': 'erp-solutions',
'Government / GeM': 'government-systems',
'Other': 'other',
};
function buildPayload() {
var contactVal = (contactInput.value || '').trim();
var isEmail = EMAIL_REGEX.test(contactVal);
var nameVal = (nameInput.value || '').trim();
var projectType = projectTypeInput.value || 'Other';
var subject = SUBJECT_BY_PROJECT_TYPE[projectType] || 'other';
var email, phone;
if (isEmail) {
email = contactVal;
phone = undefined;
} 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' +
'Project type: ' + projectType + '\n' +
'Preferred contact: ' + contactVal;
var payload = {
name: nameVal,
email: email,
subject: subject,
message: message,
source: 'homepage-inline',
projectType: projectType,
website: (websiteInput && websiteInput.value) || '',
};
if (phone) payload.phone = phone;
return payload;
}
function setLoading(loading) {
if (!submitBtn) return;
submitBtn.disabled = loading;
if (loading) {
if (submitText) submitText.textContent = 'Sending…';
if (submitArrow) submitArrow.classList.add('hidden');
if (submitSpinner) submitSpinner.classList.remove('hidden');
} else {
if (submitText) submitText.textContent = 'Get my proposal';
if (submitArrow) submitArrow.classList.remove('hidden');
if (submitSpinner) submitSpinner.classList.add('hidden');
}
}
function showSuccess() {
form.classList.add('hidden');
if (successPanel) {
successPanel.classList.remove('hidden');
// Move focus into the success panel so screen-reader users hear the
// confirmation without having to re-orient.
successPanel.setAttribute('tabindex', '-1');
try { successPanel.focus({ preventScroll: true }); } catch (e) { /* older browsers */ }
}
// Optional analytics hook — only fires if a tracker is already loaded.
try {
if (typeof window.trackFormSubmit === 'function') {
window.trackFormSubmit('homepage-inline');
} else if (typeof window.gtag === 'function') {
window.gtag('event', 'form_submit', {
event_category: 'lead',
event_label: 'homepage-inline',
});
}
} catch (e) { /* analytics is best-effort */ }
}
form.addEventListener('submit', async function (event) {
event.preventDefault();
setFormError('');
if (!validate()) return;
setLoading(true);
try {
var payload = buildPayload();
var response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
var data = null;
try { data = await response.json(); } catch (_) { /* tolerate empty body */ }
if (response.ok && data && data.success) {
showSuccess();
return;
}
if (response.status === 429) {
setFormError("You've sent several messages already — please try again in a little while.");
} else if (response.status === 422 && data && data.error) {
setFormError(data.error);
} else {
setFormError("We couldn't send your message right now. Please try again, or email admin@workroot.in.");
}
} catch (err) {
setFormError("Network error — please check your connection and try again.");
} finally {
setLoading(false);
}
});
})();
</script>
+45
View File
@@ -50,9 +50,25 @@ const VALID_SUBJECTS = [
'ai-ml',
'consulting',
'support',
'erp-solutions',
'government-systems',
'other',
];
// Allowed lead-source tags. Used so leads can be segmented downstream
// (e.g. "homepage-inline" vs the regular /contact page submissions).
// Anything outside this allowlist is dropped to "unknown" to keep the
// downstream analytics tidy and prevent free-text injection into logs.
const VALID_SOURCES = [
'contact-page',
'homepage-inline',
'homepage-hero',
'homepage-final-cta',
'mobile-sticky',
'whatsapp-fab',
'unknown',
];
interface ContactFormData {
name: string;
email: string;
@@ -60,6 +76,8 @@ interface ContactFormData {
subject: string;
message: string;
website?: string; // honeypot
source?: string;
projectType?: string;
}
function validateContactForm(data: ContactFormData): string | null {
@@ -161,6 +179,8 @@ async function sendContactEmail(data: ContactFormData): Promise<void> {
email: data.email,
subject: data.subject,
name: data.name,
source: data.source,
projectType: data.projectType,
});
return;
}
@@ -189,9 +209,13 @@ async function sendContactEmail(data: ContactFormData): Promise<void> {
'ai-ml': 'AI & Machine Learning',
'consulting': 'IT Consulting',
'support': 'Technical Support',
'erp-solutions': 'ERP / Custom Software',
'government-systems': 'Government / GeM',
'other': 'Other',
};
const sourceLabel = data.source ? data.source : 'contact-page';
const fromAddress = smtpFrom || smtpUser;
// 1) Admin notification email with BCC to SMTP_USER for delivery confirmation
@@ -204,10 +228,12 @@ async function sendContactEmail(data: ContactFormData): Promise<void> {
text: [
`New contact form submission`,
``,
`Source: ${sourceLabel}`,
`Name: ${data.name}`,
`Email: ${data.email}`,
`Phone: ${data.phone || 'Not provided'}`,
`Subject: ${subjectLabels[data.subject] ?? data.subject}`,
...(data.projectType ? [`Project: ${data.projectType}`] : []),
``,
`Message:`,
data.message,
@@ -218,10 +244,12 @@ async function sendContactEmail(data: ContactFormData): Promise<void> {
html: `
<h2>New Contact Form Submission</h2>
<table style="border-collapse:collapse;width:100%;max-width:600px">
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee">Source</td><td style="padding:8px;border-bottom:1px solid #eee">${escapeHtml(sourceLabel)}</td></tr>
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee">Name</td><td style="padding:8px;border-bottom:1px solid #eee">${escapeHtml(data.name)}</td></tr>
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee">Email</td><td style="padding:8px;border-bottom:1px solid #eee"><a href="mailto:${escapeHtml(data.email)}">${escapeHtml(data.email)}</a></td></tr>
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee">Phone</td><td style="padding:8px;border-bottom:1px solid #eee">${escapeHtml(data.phone || 'Not provided')}</td></tr>
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee">Subject</td><td style="padding:8px;border-bottom:1px solid #eee">${escapeHtml(subjectLabels[data.subject] ?? data.subject)}</td></tr>
${data.projectType ? `<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee">Project</td><td style="padding:8px;border-bottom:1px solid #eee">${escapeHtml(data.projectType)}</td></tr>` : ''}
<tr><td style="padding:8px;font-weight:bold;vertical-align:top">Message</td><td style="padding:8px;white-space:pre-wrap">${escapeHtml(data.message)}</td></tr>
</table>
<p style="color:#888;font-size:12px;margin-top:16px">Submitted at: ${new Date().toISOString()}</p>
@@ -392,6 +420,19 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
);
}
// Normalize the lead source. Free-text is rejected to keep the analytics
// taxonomy clean and prevent log-injection style abuse.
const rawSource = body.source ? String(body.source).trim().toLowerCase() : '';
const source = VALID_SOURCES.includes(rawSource) ? rawSource : 'contact-page';
// Project type is free-text from a controlled <select>. We only echo it
// into the admin email and logs — never used in routing — so allow any
// short reasonable label and just trim/cap it.
const rawProjectType = body.projectType ? String(body.projectType).trim() : '';
const projectType = rawProjectType.length > 0 && rawProjectType.length <= 80
? rawProjectType
: undefined;
// Build and validate form data
const formData: ContactFormData = {
name: String(body.name ?? '').trim(),
@@ -399,6 +440,8 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
phone: body.phone ? String(body.phone).trim() : undefined,
subject: String(body.subject ?? '').trim(),
message: String(body.message ?? '').trim(),
source,
projectType,
};
const validationError = validateContactForm(formData);
@@ -428,6 +471,8 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
userRecipient: formData.email,
userMessageId: result?.userConfirmInfo?.messageId,
subject: formData.subject,
source: formData.source,
projectType: formData.projectType,
});
})
.catch(async (err: any) => {
+8
View File
@@ -3,6 +3,7 @@
import BaseLayout from '../layouts/BaseLayout.astro';
import SEO from '../components/SEO.astro';
import ClientLogos from '../components/ClientLogos.astro';
import InlineLeadForm from '../components/InlineLeadForm.astro';
// Benefits/Features data
const benefits = [
@@ -708,6 +709,13 @@ const faqs = [
</div>
</section>
<!-- Inline Lead Form — sits between the FAQ/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. -->
<InlineLeadForm />
<!-- Final CTA Section - Enhanced -->
<section class="py-24 bg-gradient-to-br from-secondary-900 via-primary-900 to-primary-800 relative overflow-hidden">
<!-- Background decorations -->
+229
View File
@@ -0,0 +1,229 @@
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.
* - Filling all three 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 and has the three required fields', 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-contact')).toBeVisible();
await expect(page.locator('#inline-lead-project-type')).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-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');
});
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-contact-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 }) => {
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-contact').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');
// 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 }) => {
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-contact').fill('+91 95614 17403');
await page.locator('#inline-lead-project-type').selectOption('Government / GeM');
await page.locator('#inline-lead-submit').click();
await expect(page.getByTestId('inline-lead-success')).toBeVisible();
expect(captured).toBeTruthy();
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.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());
});
});