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
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.
476 lines
20 KiB
Plaintext
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 · NDA on request · 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 — 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>
|