Files
CompanySite/src/components/InlineLeadForm.astro
T
platform-mcp 462d2058e1
Deploy to Production / Build & Verify (push) Has been cancelled
Deploy to Production / Pre-Deploy Tests (push) Has been cancelled
Deploy to Production / Deploy to Railway (push) Has been cancelled
Deploy to Production / Deploy to Render (push) Has been cancelled
Deploy to Production / Deploy to VPS (PM2) (push) Has been cancelled
Deploy to Production / Deploy to Fly.io (push) Has been cancelled
Deploy to Production / Post-Deploy Verification (push) Has been cancelled
Deploy to Production / Notify on Failure (push) Has been cancelled
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
Ping Search Engines / Notify Search Engines (push) Failing after 12m31s
fix(forms): harden honeypot hiding on contact + inline lead forms
Both honeypots now use off-screen positioning (position:absolute; left:-9999px;
width:1px; height:1px; overflow:hidden) instead of relying on a parent's
aria-hidden alone, and the input itself carries tabindex="-1",
autocomplete="off", and aria-hidden="true". Labels are sr-only and sit as
siblings of the input (no nested label/input). Avoid display:none and
visibility:hidden so bots that skip those still trip the trap.

Verified: rendered honeypot HTML matches spec on both /contact and the
homepage inline lead form, and /api/contact still 200s a populated `website`
silently (bot path) while accepting empty `website` payloads as real leads.
2026-06-17 12:57:47 +05:30

476 lines
20 KiB
Plaintext

---
// 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 — off-screen positioned, NOT display:none / visibility:hidden
(some bots skip those entirely). aria-hidden + sr-only label keep it
silent for screen readers; tabindex="-1" + autocomplete="off" keep it
out of the tab order and browser autofill. Bots that blindly fill
every field will populate `website`, and /api/contact silently drops
the submission.
-->
<div
class="absolute -left-[9999px] top-auto w-px h-px overflow-hidden"
aria-hidden="true"
>
<label for="inline-lead-website" class="sr-only">Website</label>
<input
type="text"
id="inline-lead-website"
name="website"
tabindex="-1"
autocomplete="off"
aria-hidden="true"
/>
</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>