First Init
Deploy to Production / Build & Verify (push) Failing after 5m56s
Ping Search Engines / Notify Search Engines (push) Successful in 2s
Deploy to Production / Pre-Deploy Tests (push) Has been skipped
Deploy to Production / Deploy to Railway (push) Has been skipped
Deploy to Production / Deploy to Render (push) Has been skipped
Deploy to Production / Deploy to VPS (PM2) (push) Has been skipped
Deploy to Production / Deploy to Fly.io (push) Has been skipped
Deploy to Production / Post-Deploy Verification (push) Has been skipped
Deploy to Production / Notify on Failure (push) Successful in 2s
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
E2E Test Suite / Smoke Tests (P0) (push) Failing after 11m26s
E2E Test Suite / Form Interaction Tests (push) Failing after 11m42s
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 12m2s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Failing after 16m14s
E2E Test Suite / Cross-Browser Regression (webkit) (push) Failing after 17m45s
E2E Test Suite / Cross-Browser Regression (firefox) (push) Failing after 25m23s
E2E Test Suite / Security Header Tests (push) Failing after 7m55s
E2E Test Suite / Test Report Summary (push) Failing after 20s
E2E Test Suite / Mobile Device Tests (push) Failing after 2h49m9s
Uptime Monitor / Health & Response Time (push) Failing after 2s
Uptime Monitor / SSL Certificate (push) Successful in 2s
Uptime Monitor / Send Alerts (push) Failing after 3s
Uptime Monitor / Record Uptime Success (push) Has been skipped

This commit is contained in:
2026-03-21 16:46:46 +05:30
commit d402256547
216 changed files with 48375 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
---
/**
* Analytics Component
*
* Supports two analytics providers (configure via environment variables):
* - Google Analytics 4: Set GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX
* - Plausible Analytics: Set PLAUSIBLE_DOMAIN=workroot.in
*
* If neither is set, no analytics scripts are injected.
* The component respects Do Not Track (DNT) browser settings.
*/
const gaId = import.meta.env.GOOGLE_ANALYTICS_ID;
const plausibleDomain = import.meta.env.PLAUSIBLE_DOMAIN;
const hasGA4 = gaId && gaId.startsWith('G-');
const hasPlausible = !!plausibleDomain;
---
{hasGA4 && (
<>
<!-- Google Analytics 4 -->
<link rel="preconnect" href="https://www.googletagmanager.com" />
<link rel="dns-prefetch" href="https://www.google-analytics.com" />
<script
async
src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}
></script>
<script define:vars={{ gaId }}>
// Respect Do Not Track
if (navigator.doNotTrack !== '1' && window.doNotTrack !== '1') {
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', gaId, {
// Anonymize IP for GDPR compliance
anonymize_ip: true,
// Disable ad personalization signals
allow_google_signals: false,
allow_ad_personalization_signals: false,
// Send page views automatically
send_page_view: true,
});
// Expose gtag globally for event tracking
window.gtag = gtag;
}
</script>
</>
)}
{hasPlausible && (
<>
<!-- Plausible Analytics (privacy-focused) -->
<link rel="preconnect" href="https://plausible.io" />
<script
defer
data-domain={plausibleDomain}
src="https://plausible.io/js/script.tagged-events.js"
></script>
<script>
// Expose plausible event helper globally
window.plausible = window.plausible || function() {
(window.plausible.q = window.plausible.q || []).push(arguments);
};
</script>
</>
)}
{!hasGA4 && !hasPlausible && (
<!-- Analytics not configured. Set GOOGLE_ANALYTICS_ID or PLAUSIBLE_DOMAIN in .env -->
)}
+265
View File
@@ -0,0 +1,265 @@
---
// Footer Component for WorkRoot IT Solutions
// Features: Company info, quick links, services, social icons, newsletter subscription
interface Props {
class?: string;
}
const { class: className } = Astro.props;
const quickLinks = [
{ name: 'Home', href: '/' },
{ name: 'About Us', href: '/about' },
{ name: 'Services', href: '/services' },
{ name: 'Portfolio', href: '/portfolio' },
{ name: 'Blog', href: '/blog' },
{ name: 'Contact', href: '/contact' },
];
const services = [
{ name: 'Web Development', href: '/services/web-development' },
{ name: 'Mobile Apps', href: '/services/mobile-apps' },
{ name: 'Cloud Solutions', href: '/services/cloud-solutions' },
{ name: 'IT Consulting', href: '/services/it-consulting' },
{ name: 'Cybersecurity', href: '/services/cybersecurity' },
{ name: 'DevOps', href: '/services/devops' },
];
const socialLinks = [
{ name: 'LinkedIn', href: 'https://linkedin.com/company/workroot', icon: 'linkedin' },
{ name: 'Twitter', href: 'https://twitter.com/workroot', icon: 'twitter' },
{ name: 'GitHub', href: 'https://github.com/workroot', icon: 'github' },
{ name: 'Facebook', href: 'https://facebook.com/workroot', icon: 'facebook' },
];
const currentYear = new Date().getFullYear();
---
<footer class:list={['bg-secondary-900 text-secondary-200', className]}>
<!-- Main Footer Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 lg:py-16">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12">
<!-- Company Info -->
<div class="lg:col-span-1">
<a href="/" class="inline-flex items-center gap-2 mb-4 group" aria-label="WorkRoot IT Solutions - Home">
<div class="w-10 h-10 bg-primary rounded-lg flex items-center justify-center transition-transform group-hover:scale-105" aria-hidden="true">
<span class="text-white font-bold text-lg">W</span>
</div>
<span class="text-xl font-bold text-white">WorkRoot</span>
</a>
<p class="text-secondary-400 text-sm leading-relaxed mb-6">
Empowering businesses with innovative IT solutions. We build scalable,
secure, and future-ready digital experiences.
</p>
<!-- Social Icons -->
<div class="flex items-center gap-3">
{socialLinks.map((social) => (
<a
href={social.href}
target="_blank"
rel="noopener noreferrer"
aria-label={social.name}
class="w-10 h-10 rounded-lg bg-secondary-800 flex items-center justify-center text-secondary-400 hover:bg-primary hover:text-white transition-all duration-200 hover:scale-105"
>
{social.icon === 'linkedin' && (
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
)}
{social.icon === 'twitter' && (
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
)}
{social.icon === 'github' && (
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/>
</svg>
)}
{social.icon === 'facebook' && (
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fill-rule="evenodd" d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z" clip-rule="evenodd"/>
</svg>
)}
</a>
))}
</div>
</div>
<!-- Quick Links -->
<div>
<h3 class="text-white font-semibold text-lg mb-4">Quick Links</h3>
<ul class="space-y-3">
{quickLinks.map((link) => (
<li>
<a
href={link.href}
class="text-secondary-400 hover:text-primary transition-colors duration-200 text-sm inline-flex items-center gap-1 group"
>
<svg class="w-3 h-3 opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<span>{link.name}</span>
</a>
</li>
))}
</ul>
</div>
<!-- Services -->
<div>
<h3 class="text-white font-semibold text-lg mb-4">Our Services</h3>
<ul class="space-y-3">
{services.map((service) => (
<li>
<a
href={service.href}
class="text-secondary-400 hover:text-primary transition-colors duration-200 text-sm inline-flex items-center gap-1 group"
>
<svg class="w-3 h-3 opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<span>{service.name}</span>
</a>
</li>
))}
</ul>
</div>
<!-- Newsletter Subscription -->
<div>
<h3 class="text-white font-semibold text-lg mb-4">Stay Updated</h3>
<p class="text-secondary-400 text-sm mb-4">
Subscribe to our newsletter for the latest tech insights and company updates.
</p>
<form id="newsletter-form" class="space-y-3">
<div class="relative">
<label for="newsletter-email" class="sr-only">Email address</label>
<input
type="email"
name="email"
id="newsletter-email"
placeholder="Enter your email"
required
autocomplete="email"
aria-required="true"
aria-describedby="newsletter-message"
class="w-full px-4 py-3 bg-secondary-800 border border-secondary-700 rounded-lg text-white placeholder-secondary-500 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all duration-200"
/>
</div>
<button
type="submit"
class="w-full px-4 py-3 bg-primary text-white font-medium rounded-lg hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-secondary-900 transition-all duration-200 text-sm flex items-center justify-center gap-2 group"
>
<span>Subscribe</span>
<svg class="w-4 h-4 group-hover:translate-x-1 transition-transform duration-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
</svg>
</button>
<!-- Success/Error Messages -->
<div id="newsletter-message" class="hidden text-sm py-2 px-3 rounded-lg" role="status" aria-live="polite"></div>
</form>
<p class="text-secondary-500 text-xs mt-3">
By subscribing, you agree to our
<a href="/privacy" class="text-primary hover:underline">Privacy Policy</a>.
</p>
</div>
</div>
</div>
<!-- Bottom Bar -->
<div class="border-t border-secondary-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
<p class="text-secondary-500 text-sm text-center md:text-left">
&copy; {currentYear} WorkRoot IT Solutions. All rights reserved.
</p>
<div class="flex items-center gap-6">
<a href="/privacy" class="text-secondary-500 hover:text-primary text-sm transition-colors duration-200">
Privacy Policy
</a>
<a href="/terms" class="text-secondary-500 hover:text-primary text-sm transition-colors duration-200">
Terms of Service
</a>
<a href="/cookies" class="text-secondary-500 hover:text-primary text-sm transition-colors duration-200">
Cookie Policy
</a>
</div>
</div>
</div>
</div>
</footer>
<script>
import { trackNewsletterSignup } from '../utils/analytics';
// Newsletter form handling
const form = document.getElementById('newsletter-form') as HTMLFormElement;
const messageEl = document.getElementById('newsletter-message') as HTMLDivElement;
form?.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const email = formData.get('email') as string;
// Show loading state
const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement;
const originalContent = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = `
<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<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>
<span>Subscribing...</span>
`;
try {
const response = await fetch('/api/newsletter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await response.json() as { success: boolean; message?: string; error?: string };
if (response.ok && data.success) {
trackNewsletterSignup(true);
// Show success message
messageEl.classList.remove('hidden', 'bg-red-900/50', 'text-red-300');
messageEl.classList.add('bg-green-900/50', 'text-green-300');
messageEl.textContent = data.message ?? 'Thanks for subscribing! Check your inbox to confirm.';
// Reset form on success only
form.reset();
} else {
trackNewsletterSignup(false);
// Show error message from server
messageEl.classList.remove('hidden', 'bg-green-900/50', 'text-green-300');
messageEl.classList.add('bg-red-900/50', 'text-red-300');
messageEl.textContent = data.error ?? 'Something went wrong. Please try again.';
}
} catch {
trackNewsletterSignup(false);
// Network error
messageEl.classList.remove('hidden', 'bg-green-900/50', 'text-green-300');
messageEl.classList.add('bg-red-900/50', 'text-red-300');
messageEl.textContent = 'Network error. Please check your connection and try again.';
} finally {
// Restore button
submitBtn.disabled = false;
submitBtn.innerHTML = originalContent;
// Hide message after 5 seconds
setTimeout(() => {
messageEl.classList.add('hidden');
}, 5000);
}
});
</script>
+325
View File
@@ -0,0 +1,325 @@
---
// Header Component for WorkRoot IT Solutions
// Features: Sticky header, responsive nav, mobile hamburger menu, active link highlighting
interface Props {
class?: string;
}
const { class: className } = Astro.props;
const navLinks = [
{ name: 'Home', href: '/' },
{ name: 'About', href: '/about' },
{ name: 'Services', href: '/services' },
{ name: 'Portfolio', href: '/portfolio' },
{ name: 'Blog', href: '/blog' },
{ name: 'Contact', href: '/contact' },
];
const currentPath = Astro.url.pathname;
---
<header
class:list={[
'fixed top-0 left-0 right-0 z-50 bg-white/95 backdrop-blur-sm border-b border-secondary-100 transition-shadow duration-300',
className,
]}
id="main-header"
>
<div class="container-wrapper">
<nav class="flex items-center justify-between h-16 lg:h-20" aria-label="Main navigation">
<!-- Logo -->
<a href="/" class="flex items-center gap-2 group" aria-label="WorkRoot IT Solutions - Home">
<div class="w-10 h-10 bg-primary rounded-lg flex items-center justify-center transition-transform group-hover:scale-105">
<span class="text-white font-bold text-xl">W</span>
</div>
<div class="hidden sm:block">
<span class="font-bold text-xl text-secondary">WorkRoot</span>
<span class="text-primary font-semibold text-sm block -mt-1">IT Solutions</span>
</div>
</a>
<!-- Desktop Navigation -->
<ul class="hidden lg:flex items-center gap-1" role="menubar">
{navLinks.map((link) => {
const isActive = currentPath === link.href ||
(link.href !== '/' && currentPath.startsWith(link.href));
return (
<li role="none">
<a
href={link.href}
role="menuitem"
data-astro-prefetch="hover"
class:list={[
'relative px-4 py-2 text-sm font-medium transition-colors rounded-lg',
isActive
? 'text-primary bg-primary-50'
: 'text-secondary-600 hover:text-primary hover:bg-secondary-50',
]}
aria-current={isActive ? 'page' : undefined}
>
{link.name}
{isActive && (
<span class="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full" />
)}
</a>
</li>
);
})}
</ul>
<!-- Desktop CTA -->
<div class="hidden lg:flex items-center gap-4">
<a
href="/contact"
class="btn-primary text-sm"
>
Get Started
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</a>
</div>
<!-- Mobile Menu Button -->
<button
type="button"
class="lg:hidden relative w-10 h-10 flex items-center justify-center text-secondary-600 hover:text-primary transition-colors"
id="mobile-menu-toggle"
aria-label="Toggle mobile menu"
aria-expanded="false"
aria-controls="mobile-menu"
>
<span class="sr-only">Open main menu</span>
<!-- Hamburger Icon -->
<div class="hamburger-icon" aria-hidden="true">
<span class="hamburger-line hamburger-line-1"></span>
<span class="hamburger-line hamburger-line-2"></span>
<span class="hamburger-line hamburger-line-3"></span>
</div>
</button>
</nav>
</div>
<!-- Mobile Menu Overlay -->
<div
id="mobile-menu-overlay"
class="fixed inset-0 bg-secondary-900/50 backdrop-blur-sm opacity-0 pointer-events-none transition-opacity duration-300 lg:hidden"
aria-hidden="true"
></div>
<!-- Mobile Menu Panel -->
<div
id="mobile-menu"
class="fixed top-0 right-0 h-full w-80 max-w-[85vw] bg-white shadow-2xl transform translate-x-full transition-transform duration-300 ease-out lg:hidden"
role="dialog"
aria-modal="true"
aria-label="Mobile navigation menu"
>
<div class="flex flex-col h-full">
<!-- Mobile Menu Header -->
<div class="flex items-center justify-between p-4 border-b border-secondary-100">
<a href="/" class="flex items-center gap-2" aria-label="WorkRoot IT Solutions - Home">
<div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center" aria-hidden="true">
<span class="text-white font-bold text-lg">W</span>
</div>
<span class="font-bold text-lg text-secondary">WorkRoot</span>
</a>
<button
type="button"
class="w-10 h-10 flex items-center justify-center text-secondary-500 hover:text-secondary-700 transition-colors"
id="mobile-menu-close"
aria-label="Close menu"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Mobile Navigation Links -->
<nav class="flex-1 overflow-y-auto py-4">
<ul class="space-y-1 px-3" role="menu">
{navLinks.map((link, index) => {
const isActive = currentPath === link.href ||
(link.href !== '/' && currentPath.startsWith(link.href));
return (
<li role="none" style={`--delay: ${index * 50}ms`}>
<a
href={link.href}
role="menuitem"
data-astro-prefetch="hover"
class:list={[
'mobile-nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200',
isActive
? 'text-primary bg-primary-50'
: 'text-secondary-700 hover:text-primary hover:bg-secondary-50',
]}
aria-current={isActive ? 'page' : undefined}
>
{link.name}
{isActive && (
<span class="ml-auto w-2 h-2 bg-primary rounded-full" />
)}
</a>
</li>
);
})}
</ul>
</nav>
<!-- Mobile Menu Footer -->
<div class="p-4 border-t border-secondary-100">
<a
href="/contact"
class="btn-primary w-full justify-center"
>
Get Started
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</a>
<p class="text-center text-sm text-secondary-500 mt-4">
Need help? <a href="tel:+1234567890" class="text-primary hover:underline">Call us</a>
</p>
</div>
</div>
</div>
</header>
<!-- Spacer for fixed header -->
<div class="h-16 lg:h-20" aria-hidden="true"></div>
<style>
/* Hamburger Icon Styles */
.hamburger-icon {
width: 24px;
height: 18px;
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.hamburger-line {
display: block;
width: 100%;
height: 2px;
background-color: currentColor;
border-radius: 1px;
transition: all 0.3s ease;
transform-origin: center;
}
/* Hamburger to X animation */
.menu-open .hamburger-line-1 {
transform: translateY(8px) rotate(45deg);
}
.menu-open .hamburger-line-2 {
opacity: 0;
transform: scaleX(0);
}
.menu-open .hamburger-line-3 {
transform: translateY(-8px) rotate(-45deg);
}
/* Mobile nav link animation */
.mobile-nav-link {
opacity: 0;
transform: translateX(20px);
transition: opacity 0.3s ease, transform 0.3s ease;
transition-delay: var(--delay, 0ms);
}
.menu-open .mobile-nav-link {
opacity: 1;
transform: translateX(0);
}
/* Header shadow on scroll */
.header-scrolled {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
</style>
<script>
// Mobile Menu Toggle
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
const mobileMenuClose = document.getElementById('mobile-menu-close');
const mobileMenu = document.getElementById('mobile-menu');
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay');
const header = document.getElementById('main-header');
let isMenuOpen = false;
function openMenu() {
isMenuOpen = true;
mobileMenu?.classList.remove('translate-x-full');
mobileMenuOverlay?.classList.remove('opacity-0', 'pointer-events-none');
mobileMenuToggle?.classList.add('menu-open');
mobileMenuToggle?.setAttribute('aria-expanded', 'true');
document.body.style.overflow = 'hidden';
// Trigger link animations
document.querySelectorAll('.mobile-nav-link').forEach((link) => {
link.closest('li')?.classList.add('menu-open');
});
}
function closeMenu() {
isMenuOpen = false;
mobileMenu?.classList.add('translate-x-full');
mobileMenuOverlay?.classList.add('opacity-0', 'pointer-events-none');
mobileMenuToggle?.classList.remove('menu-open');
mobileMenuToggle?.setAttribute('aria-expanded', 'false');
document.body.style.overflow = '';
// Reset link animations
document.querySelectorAll('.mobile-nav-link').forEach((link) => {
link.closest('li')?.classList.remove('menu-open');
});
}
mobileMenuToggle?.addEventListener('click', () => {
if (isMenuOpen) {
closeMenu();
} else {
openMenu();
}
});
mobileMenuClose?.addEventListener('click', closeMenu);
mobileMenuOverlay?.addEventListener('click', closeMenu);
// Close menu on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isMenuOpen) {
closeMenu();
}
});
// Close menu on window resize (if transitioning to desktop)
window.addEventListener('resize', () => {
if (window.innerWidth >= 1024 && isMenuOpen) {
closeMenu();
}
});
// Header shadow on scroll
let lastScrollY = 0;
window.addEventListener('scroll', () => {
const currentScrollY = window.scrollY;
if (currentScrollY > 10) {
header?.classList.add('header-scrolled');
} else {
header?.classList.remove('header-scrolled');
}
lastScrollY = currentScrollY;
}, { passive: true });
</script>
+87
View File
@@ -0,0 +1,87 @@
---
/**
* LazyImage Component
*
* Lightweight image component with:
* - Native lazy loading
* - Placeholder background while loading
* - Aspect ratio preservation (prevents CLS)
* - Fade-in animation on load
*
* Use for placeholder/decorative images or when full Astro optimization isn't needed.
*/
interface Props {
src: string;
alt: string;
width: number;
height: number;
class?: string;
loading?: 'lazy' | 'eager';
fetchpriority?: 'high' | 'low' | 'auto';
placeholder?: string; // Background color while loading
}
const {
src,
alt,
width,
height,
class: className = '',
loading = 'lazy',
fetchpriority = 'auto',
placeholder = '#1e293b', // secondary-800
} = Astro.props;
const aspectRatio = (height / width) * 100;
---
<div
class:list={['lazy-image-wrapper', className]}
style={`--aspect-ratio: ${aspectRatio}%; --placeholder-bg: ${placeholder};`}
>
<img
src={src}
alt={alt}
width={width}
height={height}
loading={loading}
decoding="async"
fetchpriority={fetchpriority}
class="lazy-image"
onload="this.classList.add('loaded')"
/>
</div>
<style>
.lazy-image-wrapper {
position: relative;
width: 100%;
padding-bottom: var(--aspect-ratio);
background-color: var(--placeholder-bg);
overflow: hidden;
}
.lazy-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
.lazy-image.loaded {
opacity: 1;
}
/* Disable transition for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
.lazy-image {
transition: none;
opacity: 1;
}
}
</style>
+119
View File
@@ -0,0 +1,119 @@
---
/**
* OptimizedImage Component
*
* Features:
* - Automatic WebP conversion via Astro's image service
* - Native lazy loading with fallback
* - Responsive srcset generation
* - Blur placeholder support
* - Proper aspect ratio to prevent CLS
*/
import { Image } from 'astro:assets';
interface Props {
src: ImageMetadata | string;
alt: string;
width?: number;
height?: number;
class?: string;
loading?: 'lazy' | 'eager';
decoding?: 'async' | 'sync' | 'auto';
fetchpriority?: 'high' | 'low' | 'auto';
sizes?: string;
widths?: number[];
quality?: number;
format?: 'webp' | 'avif' | 'png' | 'jpg';
placeholder?: 'blur' | 'none';
}
const {
src,
alt,
width,
height,
class: className = '',
loading = 'lazy',
decoding = 'async',
fetchpriority = 'auto',
sizes = '(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw',
widths = [320, 640, 960, 1280, 1920],
quality = 80,
format = 'webp',
placeholder = 'none',
} = Astro.props;
// Check if src is a remote URL string
const isRemote = typeof src === 'string' && (src.startsWith('http') || src.startsWith('//'));
---
{isRemote ? (
<img
src={src}
alt={alt}
width={width}
height={height}
class={className}
loading={loading}
decoding={decoding}
fetchpriority={fetchpriority}
style={width && height ? `aspect-ratio: ${width}/${height}` : undefined}
/>
) : typeof src === 'string' ? (
<!-- Local path string - use native img with lazy loading -->
<img
src={src}
alt={alt}
width={width}
height={height}
class={className}
loading={loading}
decoding={decoding}
fetchpriority={fetchpriority}
style={width && height ? `aspect-ratio: ${width}/${height}` : undefined}
/>
) : (
<!-- ImageMetadata - use Astro's optimized Image component -->
<Image
src={src}
alt={alt}
width={width || src.width}
height={height || src.height}
class={className}
loading={loading}
decoding={decoding}
format={format}
quality={quality}
widths={widths}
sizes={sizes}
/>
)}
<style>
img {
/* Prevent layout shift by maintaining aspect ratio */
height: auto;
max-width: 100%;
}
/* Add subtle fade-in effect for lazy loaded images */
img[loading='lazy'] {
opacity: 0;
animation: fadeIn 0.3s ease-in-out forwards;
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
/* Disable animation for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
img[loading='lazy'] {
animation: none;
opacity: 1;
}
}
</style>
+161
View File
@@ -0,0 +1,161 @@
---
// PWA Install Prompt
// Shows a custom install banner when the browser fires 'beforeinstallprompt'
// Also provides a manual install button accessible via #pwa-install-btn
---
<!-- Install Prompt Banner -->
<div
id="pwa-install-banner"
role="banner"
aria-label="Install WorkRoot app"
class="hidden fixed bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 z-50 bg-white border border-secondary-200 rounded-xl shadow-xl p-4"
>
<div class="flex items-start gap-3">
<img
src="/apple-touch-icon.png"
alt="WorkRoot"
width="44"
height="44"
class="rounded-lg flex-shrink-0"
loading="lazy"
/>
<div class="flex-1 min-w-0">
<p class="font-semibold text-secondary-900 text-sm">Install WorkRoot</p>
<p class="text-secondary-500 text-xs mt-0.5">
Add to your home screen for quick access
</p>
</div>
<button
id="pwa-dismiss-btn"
aria-label="Dismiss install prompt"
class="flex-shrink-0 text-secondary-400 hover:text-secondary-600 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex gap-2 mt-3">
<button
id="pwa-install-btn"
class="flex-1 bg-primary text-white text-sm font-semibold py-2 px-4 rounded-lg hover:bg-primary/90 transition-colors"
>
Install
</button>
<button
id="pwa-later-btn"
class="flex-1 bg-secondary-100 text-secondary-700 text-sm font-semibold py-2 px-4 rounded-lg hover:bg-secondary-200 transition-colors"
>
Not now
</button>
</div>
</div>
<script>
// ─── Background Sync Helper ──────────────────────────────────────────────────
// Exposed globally so form pages can queue submissions
window.queueFormSync = async function (url, method, body, formType) {
const db = await openDB();
await addToDB(db, 'workroot-sync-queue', {
url,
method,
body,
formType,
timestamp: Date.now(),
});
// Register background sync if supported
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const reg = await navigator.serviceWorker.ready;
await reg.sync.register('form-sync');
}
};
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('workroot-pwa', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('workroot-sync-queue')) {
const store = db.createObjectStore('workroot-sync-queue', { keyPath: 'id', autoIncrement: true });
store.createIndex('timestamp', 'timestamp', { unique: false });
}
};
request.onsuccess = (event) => resolve(event.target.result);
request.onerror = () => reject(request.error);
});
}
function addToDB(db, storeName, item) {
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
const request = store.add(item);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// ─── Install Prompt ───────────────────────────────────────────────────────────
let deferredPrompt = null;
const banner = document.getElementById('pwa-install-banner');
const installBtn = document.getElementById('pwa-install-btn');
const dismissBtn = document.getElementById('pwa-dismiss-btn');
const laterBtn = document.getElementById('pwa-later-btn');
// Don't show banner if already installed or dismissed within 7 days
const DISMISSED_KEY = 'pwa-install-dismissed';
const dismissedAt = localStorage.getItem(DISMISSED_KEY);
const sevenDays = 7 * 24 * 60 * 60 * 1000;
const recentlyDismissed = dismissedAt && Date.now() - parseInt(dismissedAt) < sevenDays;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
if (!recentlyDismissed) {
// Delay banner so it doesn't distract on first page load
setTimeout(() => {
banner?.classList.remove('hidden');
}, 4000);
}
});
installBtn?.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
deferredPrompt = null;
banner?.classList.add('hidden');
if (outcome === 'accepted') {
localStorage.removeItem(DISMISSED_KEY);
}
});
function dismissBanner() {
banner?.classList.add('hidden');
localStorage.setItem(DISMISSED_KEY, String(Date.now()));
deferredPrompt = null;
}
dismissBtn?.addEventListener('click', dismissBanner);
laterBtn?.addEventListener('click', dismissBanner);
// Hide banner once app is installed
window.addEventListener('appinstalled', () => {
banner?.classList.add('hidden');
deferredPrompt = null;
});
// ─── SW Sync Success Listener ─────────────────────────────────────────────────
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'SYNC_SUCCESS') {
// Dispatch custom event so form handlers can react
window.dispatchEvent(new CustomEvent('pwa:sync-success', { detail: event.data }));
}
});
}
</script>
+201
View File
@@ -0,0 +1,201 @@
---
// SEO Component for page-specific structured data
// Use in the head slot of BaseLayout for additional schema markup
interface BreadcrumbItem {
name: string;
url: string;
}
interface Props {
type?: 'WebPage' | 'BlogPosting' | 'Article' | 'Service' | 'AboutPage' | 'ContactPage' | 'FAQPage';
breadcrumbs?: BreadcrumbItem[];
article?: {
author: string;
datePublished: string;
dateModified?: string;
category?: string;
tags?: string[];
image?: string;
wordCount?: number;
};
service?: {
name: string;
description: string;
};
faq?: Array<{
question: string;
answer: string;
}>;
}
const { type = 'WebPage', breadcrumbs, article, service, faq } = Astro.props;
const baseUrl = 'https://workroot.in';
const currentUrl = Astro.url.href;
// Get page title and description from parent layout props
const pageTitle = (Astro.props as any).title || 'WorkRoot IT Solutions';
const pageDescription = (Astro.props as any).description || 'Professional IT services including web development, cloud solutions, and digital transformation for businesses.';
// Generate BreadcrumbList schema
const breadcrumbSchema = breadcrumbs ? {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": breadcrumbs.map((item, index) => ({
"@type": "ListItem",
"position": index + 1,
"name": item.name,
"item": `${baseUrl}${item.url}`
}))
} : null;
// Generate BlogPosting schema for blog posts (more specific than Article)
const blogPostingSchema = article && type === 'BlogPosting' ? {
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": pageTitle,
"description": pageDescription,
"image": article.image ? {
"@type": "ImageObject",
"url": article.image,
"width": 1200,
"height": 675
} : undefined,
"author": {
"@type": "Person",
"name": article.author,
"url": `${baseUrl}/about`
},
"datePublished": article.datePublished,
"dateModified": article.dateModified || article.datePublished,
"publisher": {
"@type": "Organization",
"name": "WorkRoot IT Solutions",
"url": baseUrl,
"logo": {
"@type": "ImageObject",
"url": `${baseUrl}/logo.png`,
"width": 512,
"height": 512
}
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": currentUrl
},
"keywords": article.tags?.join(', '),
"articleSection": article.category,
"wordCount": article.wordCount,
"inLanguage": "en-US"
} : null;
// Generate Service schema
const serviceSchema = service ? {
"@context": "https://schema.org",
"@type": "Service",
"name": service.name,
"description": service.description,
"provider": {
"@type": "Organization",
"name": "WorkRoot IT Solutions",
"url": baseUrl
},
"areaServed": "Worldwide",
"serviceType": service.name
} : null;
// Generate WebPage schema for standard pages
const webPageSchema = type === 'WebPage' && Astro.url.pathname !== '/' ? {
"@context": "https://schema.org",
"@type": "WebPage",
"name": pageTitle,
"description": pageDescription,
"url": currentUrl,
"inLanguage": "en-US",
"isPartOf": {
"@type": "WebSite",
"@id": `${baseUrl}/#website`
},
"about": {
"@type": "Organization",
"name": "WorkRoot IT Solutions"
},
"primaryImageOfPage": {
"@type": "ImageObject",
"url": `${baseUrl}/og-image.jpg`
}
} : null;
// Generate FAQPage schema
const faqPageSchema = faq && faq.length > 0 ? {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": faq.map((item) => ({
"@type": "Question",
"name": item.question,
"acceptedAnswer": {
"@type": "Answer",
"text": item.answer
}
}))
} : null;
// Generate AboutPage schema
const aboutPageSchema = type === 'AboutPage' ? {
"@context": "https://schema.org",
"@type": "AboutPage",
"name": pageTitle,
"description": pageDescription,
"url": currentUrl,
"inLanguage": "en-US",
"mainEntity": {
"@type": "Organization",
"name": "WorkRoot IT Solutions",
"url": baseUrl
}
} : null;
// Generate ContactPage schema
const contactPageSchema = type === 'ContactPage' ? {
"@context": "https://schema.org",
"@type": "ContactPage",
"name": pageTitle,
"description": pageDescription,
"url": currentUrl,
"inLanguage": "en-US",
"mainEntity": {
"@type": "Organization",
"name": "WorkRoot IT Solutions",
"url": baseUrl
}
} : null;
---
{breadcrumbSchema && (
<script type="application/ld+json" set:html={JSON.stringify(breadcrumbSchema)} />
)}
{blogPostingSchema && (
<script type="application/ld+json" set:html={JSON.stringify(blogPostingSchema)} />
)}
{serviceSchema && (
<script type="application/ld+json" set:html={JSON.stringify(serviceSchema)} />
)}
{webPageSchema && (
<script type="application/ld+json" set:html={JSON.stringify(webPageSchema)} />
)}
{faqPageSchema && (
<script type="application/ld+json" set:html={JSON.stringify(faqPageSchema)} />
)}
{aboutPageSchema && (
<script type="application/ld+json" set:html={JSON.stringify(aboutPageSchema)} />
)}
{contactPageSchema && (
<script type="application/ld+json" set:html={JSON.stringify(contactPageSchema)} />
)}
+42
View File
@@ -0,0 +1,42 @@
---
/**
* Badge / Eyebrow Label Component
*
* Used as section labels ("Why Choose Us", "Our Services") above headings.
*
* @prop {string} label - The badge text
* @prop {'primary' | 'primary-dark' | 'accent' | 'secondary'} variant - Color variant
* - 'primary' → light background (use on white / light sections)
* - 'primary-dark' → translucent (use on dark/hero sections)
* - 'accent' → amber tones (testimonials, stats)
* - 'secondary' → neutral gray
* @prop {string} [class] - Extra classes to merge in
*/
interface Props {
label: string;
variant?: 'primary' | 'primary-dark' | 'accent' | 'secondary';
class?: string;
}
const {
label,
variant = 'primary',
class: extraClass = '',
} = Astro.props;
const variantClasses: Record<string, string> = {
'primary': 'bg-primary-50 text-primary-700',
'primary-dark': 'bg-primary-400/20 text-primary-300',
'accent': 'bg-accent-50 text-accent-700',
'secondary': 'bg-secondary-100 text-secondary-700',
};
const classes = [
'inline-block px-4 py-2 rounded-full text-sm font-bold uppercase tracking-wider mb-4',
variantClasses[variant],
extraClass,
].filter(Boolean).join(' ');
---
<span class={classes}>{label}</span>
+80
View File
@@ -0,0 +1,80 @@
---
/**
* Card Component
*
* A versatile card wrapper with predefined style variants matching
* the WorkRoot design system.
*
* @prop {'feature' | 'service' | 'surface' | 'dark' | 'plain'} variant
* - 'feature' → gradient bg, hover lift + border glow (benefits grid)
* - 'service' → white, rounded-3xl, heavy shadow on hover (services grid)
* - 'surface' → secondary-50 bg, subtle hover shadow (contact info, small cards)
* - 'dark' → white/5 glassmorphism on dark backgrounds (how-it-works steps)
* - 'plain' → no preset styles, just the slot (full custom)
* @prop {string} [padding] - Override default padding (e.g. 'p-6')
* @prop {string} [class] - Extra classes to merge in
*/
interface Props {
variant?: 'feature' | 'service' | 'surface' | 'dark' | 'plain';
padding?: string;
class?: string;
}
const {
variant = 'feature',
padding,
class: extraClass = '',
} = Astro.props;
const variantClasses: Record<string, string> = {
'feature': [
'group relative bg-gradient-to-br from-secondary-50 to-white',
'rounded-2xl border border-secondary-100',
'hover:border-primary/30 hover:shadow-2xl',
'transition-all duration-500 hover:-translate-y-2',
].join(' '),
'service': [
'group relative bg-white rounded-3xl shadow-lg',
'border-2 border-secondary-100',
'hover:border-primary-400 hover:shadow-2xl',
'transition-all duration-500 overflow-hidden',
].join(' '),
'surface': [
'bg-secondary-50 rounded-xl',
'border border-secondary-200',
'hover:shadow-lg transition-shadow duration-300',
].join(' '),
'dark': [
'group relative bg-white/5 backdrop-blur-sm rounded-2xl',
'border border-white/10',
'hover:bg-white/10 hover:border-primary-400/50',
'transition-all duration-500',
].join(' '),
'plain': '',
};
const defaultPadding: Record<string, string> = {
'feature': 'p-8',
'service': 'p-10',
'surface': 'p-6',
'dark': 'p-8',
'plain': '',
};
const paddingClass = padding ?? defaultPadding[variant];
const classes = [
variantClasses[variant],
paddingClass,
extraClass,
].filter(Boolean).join(' ');
---
<div class={classes}>
<slot />
</div>
+72
View File
@@ -0,0 +1,72 @@
---
/**
* SectionHeader Component
*
* Renders the standardized eyebrow + heading + description pattern
* used in every section of the WorkRoot site.
*
* @prop {string} badge - Eyebrow label text (e.g. "Why Choose Us")
* @prop {string} title - Section heading
* @prop {string} [description] - Optional supporting paragraph
* @prop {'light' | 'dark'} [theme] - 'light' for white/gray sections, 'dark' for hero-style sections
* @prop {'primary' | 'accent'} [badgeColor] - Badge color when theme is 'light'
* @prop {string} [maxWidth] - Max width class for the container (default 'max-w-3xl')
* @prop {string} [class] - Extra classes
*/
interface Props {
badge: string;
title: string;
description?: string;
theme?: 'light' | 'dark';
badgeColor?: 'primary' | 'accent';
maxWidth?: string;
class?: string;
}
const {
badge,
title,
description,
theme = 'light',
badgeColor = 'primary',
maxWidth = 'max-w-3xl',
class: extraClass = '',
} = Astro.props;
const isLight = theme === 'light';
const badgeClasses: Record<string, string> = {
'primary-light': 'bg-primary-50 text-primary-700',
'accent-light': 'bg-accent-50 text-accent-700',
'primary-dark': 'bg-primary-400/20 text-primary-300',
'accent-dark': 'bg-accent-400/20 text-accent-300',
};
const badgeKey = `${badgeColor}-${theme}`;
const badgeClass = badgeClasses[badgeKey] ?? badgeClasses['primary-light'];
const titleClass = isLight
? 'text-4xl sm:text-5xl font-bold text-secondary-900 mb-6'
: 'text-4xl sm:text-5xl font-bold text-white mb-6';
const descClass = isLight
? 'text-xl text-secondary-600'
: 'text-xl text-secondary-300';
const containerClass = [
'text-center mx-auto mb-20',
maxWidth,
extraClass,
].filter(Boolean).join(' ');
---
<div class={containerClass}>
<span class={`inline-block px-4 py-2 rounded-full text-sm font-bold uppercase tracking-wider mb-4 ${badgeClass}`}>
{badge}
</span>
<h2 class={titleClass}>{title}</h2>
{description && (
<p class={descClass}>{description}</p>
)}
</div>