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
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:
@@ -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 -->
|
||||
)}
|
||||
@@ -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">
|
||||
© {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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)} />
|
||||
)}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user