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
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:
@@ -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 · 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 (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 — 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>
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user