feat(contact): add global WhatsApp FAB and mobile sticky CTA bar
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 / 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
E2E Test Suite / Smoke Tests (P0) (push) Has been cancelled
Ping Search Engines / Notify Search Engines (push) Failing after 13m5s

- WhatsAppFab: 56px brand-green floating button (wa.me/919561417403), shown
  on every page; safe-area aware; lifts above the mobile CTA bar on <md.
- MobileStickyCtaBar: 3-column sticky bottom bar (Call / WhatsApp / Quote)
  visible only below the md breakpoint, with safe-area padding, 48px+ touch
  targets, and dark-mode-aware styling. Bottom spacer keeps the footer
  clear of the bar on mobile.
- Mounted both components globally via BaseLayout.
- Added tests/sticky-cta.spec.ts covering 360px and 1280px viewports,
  link integrity, no-overflow, hidden-at-md, and FAB/bar non-overlap.
This commit is contained in:
platform-mcp
2026-06-17 11:40:23 +05:30
parent b9ab4608fd
commit 85965e1a00
4 changed files with 424 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
---
// MobileStickyCtaBar — Sticky bottom action bar for mobile/tablet (<768px)
// 3 equal columns: Call, WhatsApp, Quote
// - Fixed to bottom of viewport
// - Respects safe-area-inset-bottom on iOS
// - Hidden at md+ breakpoints (>=768px)
// - Hidden on print
// - Light + dark theme aware
---
<nav
aria-label="Quick contact actions"
data-testid="mobile-sticky-cta-bar"
class="mobile-sticky-cta md:hidden"
>
<a
href="tel:+919561417403"
aria-label="Call WorkRoot"
data-testid="mobile-cta-call"
class="mobile-sticky-cta__btn"
>
<svg
class="mobile-sticky-cta__icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.86 19.86 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.86 19.86 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.37 1.9.72 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.35 1.85.59 2.81.72A2 2 0 0 1 22 16.92z"/>
</svg>
<span class="mobile-sticky-cta__label">Call</span>
</a>
<a
href="https://wa.me/919561417403?text=Hi%20WorkRoot%2C%20I%27d%20like%20to%20discuss%20a%20project."
target="_blank"
rel="noopener"
aria-label="Chat on WhatsApp"
data-testid="mobile-cta-whatsapp"
class="mobile-sticky-cta__btn mobile-sticky-cta__btn--whatsapp"
>
<svg
class="mobile-sticky-cta__icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
focusable="false"
>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893A11.821 11.821 0 0020.464 3.488"/>
</svg>
<span class="mobile-sticky-cta__label">WhatsApp</span>
</a>
<a
href="/contact"
aria-label="Request a quote"
data-testid="mobile-cta-quote"
class="mobile-sticky-cta__btn mobile-sticky-cta__btn--accent"
>
<svg
class="mobile-sticky-cta__icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
<span class="mobile-sticky-cta__label">Quote</span>
</a>
</nav>
<style>
.mobile-sticky-cta {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
background-color: #ffffff;
border-top: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 -8px 20px -8px rgba(15, 23, 42, 0.18);
padding-bottom: env(safe-area-inset-bottom, 0px);
/* Ensure no horizontal overflow on very narrow viewports */
max-width: 100vw;
overflow: hidden;
}
:global(html.dark) .mobile-sticky-cta {
background-color: #0f172a;
border-top-color: rgba(255, 255, 255, 0.08);
box-shadow: 0 -8px 20px -8px rgba(0, 0, 0, 0.55);
}
.mobile-sticky-cta__btn {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-height: 56px;
padding: 8px 6px;
text-decoration: none;
color: #0f172a;
font-size: 12px;
font-weight: 600;
line-height: 1.1;
background-color: transparent;
transition: background-color 150ms ease, color 150ms ease;
-webkit-tap-highlight-color: transparent;
}
:global(html.dark) .mobile-sticky-cta__btn {
color: #e2e8f0;
}
.mobile-sticky-cta__btn:active {
background-color: rgba(8, 145, 178, 0.08);
}
:global(html.dark) .mobile-sticky-cta__btn:active {
background-color: rgba(34, 211, 238, 0.12);
}
.mobile-sticky-cta__btn:focus-visible {
outline: 2px solid #0891b2;
outline-offset: -2px;
}
.mobile-sticky-cta__btn--whatsapp {
color: #128c4f;
}
:global(html.dark) .mobile-sticky-cta__btn--whatsapp {
color: #25D366;
}
.mobile-sticky-cta__btn--accent {
color: #b45309;
}
:global(html.dark) .mobile-sticky-cta__btn--accent {
color: #fbbf24;
}
.mobile-sticky-cta__icon {
width: 22px;
height: 22px;
flex-shrink: 0;
}
.mobile-sticky-cta__label {
display: block;
font-size: 11px;
letter-spacing: 0.01em;
}
/* Hide on tablets/desktops (>=768px = Tailwind md breakpoint).
Note: the `md:hidden` utility on the element loses the cascade against
this Astro-scoped class selector (`[data-astro-cid-*]` raises specificity),
so we enforce the breakpoint here too. */
@media (min-width: 768px) {
.mobile-sticky-cta {
display: none;
}
}
/* Hide on print */
@media print {
.mobile-sticky-cta {
display: none !important;
}
}
</style>
+104
View File
@@ -0,0 +1,104 @@
---
// WhatsAppFab — Global floating WhatsApp contact button
// - Fixed bottom-right, 56x56, brand green (#25D366)
// - Visible on every page across desktop and mobile
// - Mobile: lifted above the MobileStickyCtaBar via safe CSS variable offset
// - Respects iOS safe-area-inset-bottom
// - Hidden when printing
---
<a
href="https://wa.me/919561417403?text=Hi%20WorkRoot%2C%20I%27d%20like%20to%20discuss%20a%20project."
target="_blank"
rel="noopener"
aria-label="Chat on WhatsApp"
data-testid="whatsapp-fab"
class="whatsapp-fab"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="whatsapp-fab__icon"
aria-hidden="true"
focusable="false"
>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893A11.821 11.821 0 0020.464 3.488"/>
</svg>
</a>
<style>
.whatsapp-fab {
/* Mobile: lifted above the sticky CTA bar so they don't overlap. */
--wa-fab-bottom: calc(72px + env(safe-area-inset-bottom, 0px) + 16px);
position: fixed;
right: 16px;
right: max(16px, env(safe-area-inset-right, 16px));
bottom: var(--wa-fab-bottom);
width: 56px;
height: 56px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
background-color: #25D366;
color: #ffffff;
box-shadow:
0 6px 16px rgba(37, 211, 102, 0.35),
0 2px 6px rgba(0, 0, 0, 0.18);
z-index: 60;
text-decoration: none;
transition: transform 200ms ease, box-shadow 200ms ease, background-color 200ms ease;
-webkit-tap-highlight-color: transparent;
}
.whatsapp-fab:hover {
background-color: #1ebe5c;
transform: translateY(-2px);
box-shadow:
0 10px 20px rgba(37, 211, 102, 0.45),
0 4px 8px rgba(0, 0, 0, 0.22);
}
.whatsapp-fab:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 3px;
box-shadow:
0 0 0 4px rgba(37, 211, 102, 0.55),
0 6px 16px rgba(37, 211, 102, 0.4);
}
.whatsapp-fab:active {
transform: translateY(0);
}
.whatsapp-fab__icon {
width: 28px;
height: 28px;
}
/* Desktop / tablet: no sticky CTA bar present, sit at standard offset. */
@media (min-width: 768px) {
.whatsapp-fab {
--wa-fab-bottom: calc(24px + env(safe-area-inset-bottom, 0px));
}
}
/* Reduce motion */
@media (prefers-reduced-motion: reduce) {
.whatsapp-fab {
transition: none;
}
.whatsapp-fab:hover {
transform: none;
}
}
/* Hide on print */
@media print {
.whatsapp-fab {
display: none !important;
}
}
</style>
+11
View File
@@ -6,6 +6,8 @@ import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import Analytics from '../components/Analytics.astro';
import PWAInstallPrompt from '../components/PWAInstallPrompt.astro';
import WhatsAppFab from '../components/WhatsAppFab.astro';
import MobileStickyCtaBar from '../components/MobileStickyCtaBar.astro';
import '../styles/global.css';
interface Props {
@@ -290,6 +292,15 @@ const fullTitle = title === 'Home' ? siteName : `${title} | ${siteName}`;
<!-- Footer -->
<Footer />
<!-- Spacer to ensure footer/content clears the mobile sticky CTA bar (<md only) -->
<div aria-hidden="true" class="md:hidden h-[calc(64px+env(safe-area-inset-bottom,0px))] w-full"></div>
<!-- Mobile sticky bottom CTA bar (visible <md) -->
<MobileStickyCtaBar />
<!-- Floating WhatsApp action (global) -->
<WhatsAppFab />
<!-- Scripts slot for page-specific scripts -->
<slot name="scripts" />
+122
View File
@@ -0,0 +1,122 @@
import { test, expect } from '@playwright/test';
const WA_HREF =
'https://wa.me/919561417403?text=Hi%20WorkRoot%2C%20I%27d%20like%20to%20discuss%20a%20project.';
/**
* Verifies the global WhatsApp FAB and the Mobile Sticky CTA bar render
* correctly across viewports, expose working contact links, and never
* cause horizontal overflow on a 360px viewport.
*/
test.describe('WhatsApp FAB (global)', () => {
test('renders on every page at desktop width', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
for (const path of ['/', '/about', '/services', '/contact']) {
await page.goto(path);
const fab = page.getByTestId('whatsapp-fab');
await expect(fab, `FAB missing on ${path}`).toBeVisible();
await expect(fab).toHaveAttribute('href', WA_HREF);
await expect(fab).toHaveAttribute('target', '_blank');
await expect(fab).toHaveAttribute('rel', /noopener/);
await expect(fab).toHaveAttribute('aria-label', 'Chat on WhatsApp');
}
});
test('renders on home at 360px mobile width', async ({ page }) => {
await page.setViewportSize({ width: 360, height: 800 });
await page.goto('/');
const fab = page.getByTestId('whatsapp-fab');
await expect(fab).toBeVisible();
// FAB is 56x56
const box = await fab.boundingBox();
expect(box).not.toBeNull();
expect(box!.width).toBeGreaterThanOrEqual(48);
expect(box!.height).toBeGreaterThanOrEqual(48);
// FAB does not horizontally overflow the viewport
expect(box!.x + box!.width).toBeLessThanOrEqual(360);
});
});
test.describe('Mobile Sticky CTA Bar', () => {
test('visible at 360px and exposes 3 working actions', async ({ page }) => {
await page.setViewportSize({ width: 360, height: 800 });
await page.goto('/');
const bar = page.getByTestId('mobile-sticky-cta-bar');
await expect(bar).toBeVisible();
const callBtn = page.getByTestId('mobile-cta-call');
const waBtn = page.getByTestId('mobile-cta-whatsapp');
const quoteBtn = page.getByTestId('mobile-cta-quote');
await expect(callBtn).toBeVisible();
await expect(waBtn).toBeVisible();
await expect(quoteBtn).toBeVisible();
await expect(callBtn).toHaveAttribute('href', 'tel:+919561417403');
await expect(waBtn).toHaveAttribute('href', WA_HREF);
await expect(quoteBtn).toHaveAttribute('href', '/contact');
// Each action is at least a 48px tall touch target
for (const btn of [callBtn, waBtn, quoteBtn]) {
const box = await btn.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeGreaterThanOrEqual(48);
}
// The bar spans the full viewport width and is pinned to the bottom
const barBox = await bar.boundingBox();
expect(barBox).not.toBeNull();
expect(barBox!.x).toBeLessThanOrEqual(0.5);
expect(barBox!.width).toBeGreaterThanOrEqual(355);
expect(barBox!.width).toBeLessThanOrEqual(360);
// No horizontal overflow at 360px (match existing site tolerance: viewport + 20px)
const bodyWidth = await page.evaluate(() => document.body.scrollWidth);
expect(bodyWidth).toBeLessThanOrEqual(360 + 20);
// The page must not be horizontally scrolled — confirms fixed elements fit.
const scrollX = await page.evaluate(() => window.scrollX);
expect(scrollX).toBe(0);
});
test('hidden at >=768px (desktop / tablet landscape)', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto('/');
const bar = page.getByTestId('mobile-sticky-cta-bar');
// Either not visible, or has md:hidden applied (display:none via media query)
await expect(bar).toBeHidden();
});
test('renders on every key page on mobile', async ({ page }) => {
await page.setViewportSize({ width: 360, height: 800 });
for (const path of ['/', '/about', '/services', '/portfolio', '/blog', '/contact']) {
await page.goto(path);
await expect(
page.getByTestId('mobile-sticky-cta-bar'),
`Sticky CTA bar missing on ${path}`,
).toBeVisible();
}
});
test('FAB sits above the sticky bar on mobile (no overlap)', async ({ page }) => {
await page.setViewportSize({ width: 360, height: 800 });
await page.goto('/');
const fab = page.getByTestId('whatsapp-fab');
const bar = page.getByTestId('mobile-sticky-cta-bar');
const fabBox = await fab.boundingBox();
const barBox = await bar.boundingBox();
expect(fabBox).not.toBeNull();
expect(barBox).not.toBeNull();
// The bottom of the FAB sits above the top of the sticky bar.
expect(fabBox!.y + fabBox!.height).toBeLessThanOrEqual(barBox!.y);
});
});