Complete SEO overhaul
This commit is contained in:
43
components/AreasWeServe.tsx
Normal file
43
components/AreasWeServe.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { locationData } from '../src/data/seoData';
|
||||
|
||||
const AreasWeServe: React.FC = () => {
|
||||
return (
|
||||
<section className="py-24 px-6 bg-gray-50 dark:bg-white/5 mx-auto text-center border-t border-gray-200 dark:border-white/10">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h2 className="font-display text-3xl md:text-4xl font-bold mb-6 text-gray-900 dark:text-white">
|
||||
Areas We Serve – Local IT Support Across the Coastal Bend
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 mb-12 leading-relaxed">
|
||||
We provide professional IT support and IT services for businesses throughout Corpus Christi and the surrounding Coastal Bend area.
|
||||
Our team supports local companies with business IT support, outsourced IT services, and help desk solutions, delivered remotely or on-site when needed.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-12">
|
||||
{locationData.map((loc) => (
|
||||
<Link
|
||||
key={loc.slug}
|
||||
to={`/${loc.slug}`}
|
||||
className="p-4 bg-white dark:bg-white/10 rounded-lg shadow-sm hover:shadow-md transition-all text-gray-800 dark:text-gray-200 font-medium hover:text-black dark:hover:text-white"
|
||||
>
|
||||
{loc.city}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
Not sure if your location is covered? <Link to="/contact" className="underline hover:text-black dark:hover:text-white transition-colors">Contact us today</Link> to discuss your IT needs.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link to="/it-support-corpus-christi" className="text-sm text-gray-500 hover:text-gray-800 dark:hover:text-gray-300 transition-colors">
|
||||
Get local IT support in Corpus Christi and nearby areas
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AreasWeServe;
|
||||
@@ -1,74 +1,74 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const CTA: React.FC = () => {
|
||||
return (
|
||||
<section className="py-24 px-6 bg-white dark:bg-[#0f0f0f] border-t border-gray-100 dark:border-white/5 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(0,0,0,0.05),rgba(0,0,0,0))] dark:bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(255,255,255,0.05),rgba(255,255,255,0))]">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="font-display text-4xl md:text-5xl font-bold mb-6 text-gray-900 dark:text-white"
|
||||
>
|
||||
Ready for <span className="text-gray-400 dark:text-gray-500">reliable IT?</span>
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="text-xl text-gray-600 dark:text-gray-300 mb-12 leading-relaxed"
|
||||
>
|
||||
Join 150+ Coastal Bend businesses that trust us with their technology. Get started with a free 20-minute assessment.
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
|
||||
>
|
||||
<Link
|
||||
to="/contact"
|
||||
className="px-8 py-4 bg-black dark:bg-white text-white dark:text-black rounded-full font-medium transition-all hover:scale-105 shadow-lg w-full sm:w-auto"
|
||||
>
|
||||
Book a 20-minute assessment
|
||||
</Link>
|
||||
<Link
|
||||
to="/contact"
|
||||
className="px-8 py-4 bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white rounded-full font-medium transition-all hover:bg-gray-200 dark:hover:bg-white/20 w-full sm:w-auto"
|
||||
>
|
||||
Send a message
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mt-16 grid md:grid-cols-3 gap-8 text-left"
|
||||
>
|
||||
<div className="p-6 rounded-2xl bg-gray-50 dark:bg-white/5 border border-gray-100 dark:border-white/10">
|
||||
<h3 className="font-bold text-lg mb-2 text-gray-900 dark:text-white">How quickly can you start?</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">Most assessments can begin within 48 hours of contact.</p>
|
||||
</div>
|
||||
<div className="p-6 rounded-2xl bg-gray-50 dark:bg-white/5 border border-gray-100 dark:border-white/10">
|
||||
<h3 className="font-bold text-lg mb-2 text-gray-900 dark:text-white">How do you price services?</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">Transparent monthly pricing based on devices and services needed.</p>
|
||||
</div>
|
||||
<div className="p-6 rounded-2xl bg-gray-50 dark:bg-white/5 border border-gray-100 dark:border-white/10">
|
||||
<h3 className="font-bold text-lg mb-2 text-gray-900 dark:text-white">What's included in support?</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">24/7 monitoring, helpdesk, proactive maintenance, and SLA guarantees.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default CTA;
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const CTA: React.FC = () => {
|
||||
return (
|
||||
<section className="py-24 px-6 bg-white dark:bg-[#0f0f0f] border-t border-gray-100 dark:border-white/5 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(0,0,0,0.05),rgba(0,0,0,0))] dark:bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(255,255,255,0.05),rgba(255,255,255,0))]">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="font-display text-4xl md:text-5xl font-bold mb-6 text-gray-900 dark:text-white"
|
||||
>
|
||||
Ready for <span className="text-gray-400 dark:text-gray-500">reliable IT?</span>
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="text-xl text-gray-600 dark:text-gray-300 mb-12 leading-relaxed"
|
||||
>
|
||||
Join 150+ Coastal Bend businesses that trust us with their technology. Get started with a free 20-minute assessment.
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
|
||||
>
|
||||
<Link
|
||||
to="/contact"
|
||||
className="px-8 py-4 bg-black dark:bg-white text-white dark:text-black rounded-full font-medium transition-all hover:scale-105 shadow-lg w-full sm:w-auto"
|
||||
>
|
||||
Book a 20-minute assessment
|
||||
</Link>
|
||||
<Link
|
||||
to="/contact"
|
||||
className="px-8 py-4 bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white rounded-full font-medium transition-all hover:bg-gray-200 dark:hover:bg-white/20 w-full sm:w-auto"
|
||||
>
|
||||
Send a message
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mt-16 grid md:grid-cols-3 gap-8 text-left"
|
||||
>
|
||||
<div className="p-6 rounded-2xl bg-gray-50 dark:bg-white/5 border border-gray-100 dark:border-white/10">
|
||||
<h3 className="font-bold text-lg mb-2 text-gray-900 dark:text-white">How quickly can you start?</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">Most assessments can begin within 48 hours of contact.</p>
|
||||
</div>
|
||||
<div className="p-6 rounded-2xl bg-gray-50 dark:bg-white/5 border border-gray-100 dark:border-white/10">
|
||||
<h3 className="font-bold text-lg mb-2 text-gray-900 dark:text-white">How do you price services?</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">Transparent monthly pricing based on devices and services needed.</p>
|
||||
</div>
|
||||
<div className="p-6 rounded-2xl bg-gray-50 dark:bg-white/5 border border-gray-100 dark:border-white/10">
|
||||
<h3 className="font-bold text-lg mb-2 text-gray-900 dark:text-white">What's included in support?</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">24/7 monitoring, helpdesk, proactive maintenance, and SLA guarantees.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default CTA;
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { motion, useInView, useSpring, useTransform } from 'framer-motion';
|
||||
|
||||
interface CounterProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
const Counter: React.FC<CounterProps> = ({ value }) => {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-20%" });
|
||||
// Using slow/heavy physics as requested for premium feel
|
||||
const spring = useSpring(0, { mass: 3, stiffness: 75, damping: 30 });
|
||||
|
||||
const display = useTransform(spring, (current) =>
|
||||
// formatting: if decimal exists in target, show 1 decimal, else integer
|
||||
value % 1 !== 0 ? current.toFixed(1) : Math.round(current).toLocaleString()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInView) {
|
||||
spring.set(value);
|
||||
}
|
||||
}, [isInView, value, spring]);
|
||||
|
||||
return <motion.span ref={ref}>{display}</motion.span>;
|
||||
};
|
||||
|
||||
export default Counter;
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { motion, useInView, useSpring, useTransform } from 'framer-motion';
|
||||
|
||||
interface CounterProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
const Counter: React.FC<CounterProps> = ({ value }) => {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-20%" });
|
||||
// Using slow/heavy physics as requested for premium feel
|
||||
const spring = useSpring(0, { mass: 3, stiffness: 75, damping: 30 });
|
||||
|
||||
const display = useTransform(spring, (current) =>
|
||||
// formatting: if decimal exists in target, show 1 decimal, else integer
|
||||
value % 1 !== 0 ? current.toFixed(1) : Math.round(current).toLocaleString()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInView) {
|
||||
spring.set(value);
|
||||
}
|
||||
}, [isInView, value, spring]);
|
||||
|
||||
return <motion.span ref={ref}>{display}</motion.span>;
|
||||
};
|
||||
|
||||
export default Counter;
|
||||
|
||||
65
components/FAQ.tsx
Normal file
65
components/FAQ.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FAQItem } from '../src/data/seoData';
|
||||
|
||||
interface FAQProps {
|
||||
items: FAQItem[];
|
||||
}
|
||||
|
||||
const FAQ: React.FC<FAQProps> = ({ items }) => {
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="py-24 px-6 bg-white dark:bg-black">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="font-display text-3xl md:text-4xl font-bold mb-4 text-gray-900 dark:text-white">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Common questions about our IT services.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{items.map((faq, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="p-6 rounded-2xl bg-gray-50 dark:bg-white/5 border border-gray-100 dark:border-white/10"
|
||||
>
|
||||
<h3 className="font-bold text-lg mb-2 text-gray-900 dark:text-white">{faq.question}</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">{faq.answer}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON-LD for FAQ Page */}
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": items.map(faq => ({
|
||||
"@type": "Question",
|
||||
"name": faq.question,
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": faq.answer
|
||||
}
|
||||
}))
|
||||
})
|
||||
}} />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FAQ;
|
||||
68
components/SEO.tsx
Normal file
68
components/SEO.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
interface SEOProps {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords?: string[];
|
||||
canonicalUrl?: string;
|
||||
schema?: object; // JSON-LD schema
|
||||
}
|
||||
|
||||
const SEO: React.FC<SEOProps> = ({ title, description, keywords, canonicalUrl, schema }) => {
|
||||
useEffect(() => {
|
||||
// Update Title
|
||||
document.title = title;
|
||||
|
||||
// Helper to set meta tag
|
||||
const setMetaTag = (name: string, content: string) => {
|
||||
let element = document.querySelector(`meta[name="${name}"]`);
|
||||
if (!element) {
|
||||
element = document.createElement('meta');
|
||||
element.setAttribute('name', name);
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
element.setAttribute('content', content);
|
||||
};
|
||||
|
||||
// Update Meta Description
|
||||
setMetaTag('description', description);
|
||||
|
||||
// Update Keywords
|
||||
if (keywords && keywords.length > 0) {
|
||||
setMetaTag('keywords', keywords.join(', '));
|
||||
}
|
||||
|
||||
// Update Canonical
|
||||
if (canonicalUrl) {
|
||||
let link = document.querySelector('link[rel="canonical"]');
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.setAttribute('rel', 'canonical');
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.setAttribute('href', canonicalUrl);
|
||||
}
|
||||
|
||||
// Inject Schema
|
||||
if (schema) {
|
||||
const scriptId = 'seo-schema-script';
|
||||
let script = document.getElementById(scriptId);
|
||||
if (!script) {
|
||||
script = document.createElement('script');
|
||||
script.id = scriptId;
|
||||
script.setAttribute('type', 'application/ld+json');
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
script.textContent = JSON.stringify(schema);
|
||||
}
|
||||
|
||||
// Cleanup function not strictly necessary for single page app navigation
|
||||
// unless we want to remove specific tags on unmount, but usually we just overwrite them.
|
||||
|
||||
}, [title, description, keywords, canonicalUrl, schema]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default SEO;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useLayoutEffect } from 'react';
|
||||
import React, { useState, useRef, useLayoutEffect, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import gsap from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
@@ -69,45 +69,68 @@ const servicesData = [
|
||||
description: 'Selection, setup, and maintenance of Network Attached Storage solutions to provide scalable and reliable data storage.',
|
||||
icon: 'storage',
|
||||
image: '/assets/services/nas-storage.png'
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
category: 'IT Infrastructure',
|
||||
title: 'Business IT Support',
|
||||
description: 'Comprehensive IT support for businesses, including help desk, maintenance, and strategic planning.',
|
||||
icon: 'business_center',
|
||||
image: '/assets/services/business-it.png'
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
category: 'IT Infrastructure',
|
||||
title: 'IT Help Desk',
|
||||
description: 'Fast and reliable help desk support for employees, resolving technical issues remotely or on-site.',
|
||||
icon: 'support_agent',
|
||||
image: '/assets/services/help-desk.png'
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
category: 'IT Infrastructure',
|
||||
title: 'Managed IT Services',
|
||||
description: 'Proactive monitoring, security, and management of your entire IT infrastructure for a fixed monthly fee.',
|
||||
icon: 'admin_panel_settings',
|
||||
image: '/assets/services/managed-it.png'
|
||||
}
|
||||
];
|
||||
|
||||
const categories = ['All', 'IT Infrastructure', 'Web Services', 'Security', 'Networking'];
|
||||
|
||||
const Services: React.FC<{ preview?: boolean }> = ({ preview = false }) => {
|
||||
interface ServicesProps {
|
||||
preview?: boolean;
|
||||
featuredIds?: number[];
|
||||
}
|
||||
|
||||
const Services: React.FC<ServicesProps> = ({ preview = false, featuredIds }) => {
|
||||
const [activeCategory, setActiveCategory] = useState('All');
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imagesRef = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
// Reset refs on render to handle filtering updates
|
||||
imagesRef.current = [];
|
||||
// Determine if we should be in "preview mode" (showing only a subset)
|
||||
// This applies if preview is true OR if featuredIds are provided and we haven't clicked "Show More"
|
||||
const isRestrictedView = (preview || featuredIds) && !showAll;
|
||||
|
||||
const filteredServices = activeCategory === 'All'
|
||||
// Filter services based on category first (unless in restricted view with specific IDs, where we might want to ignore category or just show the specific ones)
|
||||
const filteredByCategory = activeCategory === 'All'
|
||||
? servicesData
|
||||
: servicesData.filter(s => s.category === activeCategory || (activeCategory === 'Web Development' && s.category === 'Security'));
|
||||
|
||||
const displayedServices = preview ? servicesData.slice(0, 3) : filteredServices;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
imagesRef.current.forEach((imgWrapper) => {
|
||||
if (!imgWrapper) return;
|
||||
|
||||
gsap.to(imgWrapper, {
|
||||
yPercent: 30,
|
||||
ease: "none",
|
||||
scrollTrigger: {
|
||||
trigger: imgWrapper.closest('.group'),
|
||||
start: "top bottom",
|
||||
end: "bottom top",
|
||||
scrub: true
|
||||
}
|
||||
});
|
||||
});
|
||||
}, containerRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [filteredServices]);
|
||||
const displayedServices = useMemo(() => {
|
||||
if (isRestrictedView) {
|
||||
if (featuredIds && featuredIds.length > 0) {
|
||||
// Sort the services to match the order of featuredIds
|
||||
return featuredIds
|
||||
.map(id => servicesData.find(s => s.id === id))
|
||||
.filter((s): s is typeof servicesData[0] => s !== undefined);
|
||||
}
|
||||
// Fallback to first 3 if no IDs but preview is true
|
||||
return servicesData.slice(0, 3);
|
||||
}
|
||||
// Show all (filtered by category)
|
||||
return filteredByCategory;
|
||||
}, [isRestrictedView, featuredIds, filteredByCategory]);
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
@@ -127,32 +150,35 @@ const Services: React.FC<{ preview?: boolean }> = ({ preview = false }) => {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 mb-12 border-b border-gray-200 dark:border-white/10 text-sm font-medium overflow-x-auto pb-2 no-scrollbar">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
className={`pb-2 whitespace-nowrap transition-colors relative ${activeCategory === cat
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-500 hover:text-gray-800 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
{activeCategory === cat && (
|
||||
<motion.div
|
||||
layoutId="activeTab"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-black dark:bg-white"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Categories - Hide in restricted view to keep it clean, or keep it? User said "mach nur das 3 services angezeigt werden". usually categories are for the full list. */}
|
||||
{!isRestrictedView && (
|
||||
<div className="flex gap-6 mb-12 border-b border-gray-200 dark:border-white/10 text-sm font-medium overflow-x-auto pb-2 no-scrollbar">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
className={`pb-2 whitespace-nowrap transition-colors relative ${activeCategory === cat
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-500 hover:text-gray-800 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
{activeCategory === cat && (
|
||||
<motion.div
|
||||
layoutId="activeTab"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-black dark:bg-white"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-6"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredServices.map((service, index) => (
|
||||
{displayedServices.map((service) => (
|
||||
<motion.div
|
||||
key={service.id}
|
||||
layout
|
||||
@@ -164,34 +190,28 @@ const Services: React.FC<{ preview?: boolean }> = ({ preview = false }) => {
|
||||
className="group relative bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl overflow-hidden hover:border-gray-300 dark:hover:border-white/30 hover:shadow-2xl transition-all duration-300"
|
||||
>
|
||||
{/* Image Container */}
|
||||
<div className="h-64 bg-gray-200 dark:bg-black/40 overflow-hidden relative">
|
||||
{/* Parallax Wrapper */}
|
||||
<div
|
||||
ref={el => { if (el) imagesRef.current.push(el); }}
|
||||
className="w-full h-[140%] -mt-[20%]"
|
||||
>
|
||||
<img
|
||||
src={service.image}
|
||||
alt={service.title}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110 opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-40 bg-gray-200 dark:bg-black/40 overflow-hidden relative">
|
||||
<img
|
||||
src={service.image}
|
||||
alt={service.title}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110 opacity-100"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-gray-50 dark:from-[#161616] to-transparent pointer-events-none"></div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 relative">
|
||||
<div className="p-4 relative">
|
||||
<motion.div
|
||||
className="w-10 h-10 rounded-full bg-white dark:bg-white/10 flex items-center justify-center mb-4 border border-gray-200 dark:border-white/10"
|
||||
className="w-8 h-8 rounded-full bg-white dark:bg-white/10 flex items-center justify-center mb-3 border border-gray-200 dark:border-white/10"
|
||||
whileHover={{ rotate: 360, backgroundColor: "#171717", color: "#ffffff", borderColor: "#171717" }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm text-gray-900 dark:text-white group-hover:text-white">{service.icon}</span>
|
||||
</motion.div>
|
||||
<h3 className="font-display text-xl font-bold text-gray-900 dark:text-white mb-2">{service.title}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed mb-4">
|
||||
<h3 className="font-display text-lg font-bold text-gray-900 dark:text-white mb-2">{service.title}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed mb-3">
|
||||
{service.description}
|
||||
</p>
|
||||
<a href="#" className="inline-flex items-center text-xs font-bold uppercase tracking-wide text-gray-900 dark:text-white group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors">
|
||||
<a href="/services" className="inline-flex items-center text-xs font-bold uppercase tracking-wide text-gray-900 dark:text-white group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors">
|
||||
Learn More <motion.span
|
||||
className="material-symbols-outlined text-xs ml-1"
|
||||
animate={{ x: [0, 5, 0] }}
|
||||
@@ -204,16 +224,18 @@ const Services: React.FC<{ preview?: boolean }> = ({ preview = false }) => {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
{isRestrictedView && (
|
||||
<div className="mt-12 text-center">
|
||||
<a
|
||||
href="/services"
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className="inline-flex items-center gap-2 px-8 py-3 bg-black dark:bg-white text-white dark:text-black rounded-full font-medium hover:bg-gray-800 dark:hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
View all services <span className="material-symbols-outlined text-sm">arrow_forward</span>
|
||||
</a>
|
||||
Show More Services <span className="material-symbols-outlined text-sm">expand_more</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* If we are showing all and originally had a restricted view, maybe show a "Show Less" but user didn't ask for it. The user said "then all are shown". */}
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const Testimonials: React.FC = () => {
|
||||
return (
|
||||
<section className="py-24 px-6 bg-background-light dark:bg-background-dark relative overflow-hidden bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(0,0,0,0.05),rgba(0,0,0,0))] dark:bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(255,255,255,0.05),rgba(255,255,255,0))]">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
className="bg-white dark:bg-white/5 backdrop-blur-sm p-8 md:p-12 rounded-3xl border border-gray-200 dark:border-white/10 shadow-2xl relative"
|
||||
>
|
||||
{/* Quote Icon */}
|
||||
<div className="absolute top-8 right-8 text-blue-100 dark:text-white/5 select-none">
|
||||
<span className="material-symbols-outlined text-8xl">format_quote</span>
|
||||
</div>
|
||||
|
||||
<div className="flex text-yellow-400 mb-6 gap-1 relative z-10">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<span key={star} className="material-symbols-outlined fill-current">star</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<blockquote className="text-xl md:text-2xl font-medium leading-relaxed text-gray-900 dark:text-white mb-8 relative z-10">
|
||||
"Bay Area Affiliates transformed our IT infrastructure completely. Their proactive approach means we rarely have downtime, and when issues do arise, they're resolved quickly. Our team can focus on patient care instead of tech problems."
|
||||
</blockquote>
|
||||
|
||||
<div className="flex items-center gap-4 relative z-10">
|
||||
<div className="w-12 h-12 bg-black dark:bg-white rounded-full flex items-center justify-center text-white dark:text-black font-bold text-lg">
|
||||
SM
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-gray-900 dark:text-white">Sarah Martinez</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Operations Manager, Coastal Medical Group</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Testimonials;
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const Testimonials: React.FC = () => {
|
||||
return (
|
||||
<section className="py-24 px-6 bg-background-light dark:bg-background-dark relative overflow-hidden bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(0,0,0,0.05),rgba(0,0,0,0))] dark:bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(255,255,255,0.05),rgba(255,255,255,0))]">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
className="bg-white dark:bg-white/5 backdrop-blur-sm p-8 md:p-12 rounded-3xl border border-gray-200 dark:border-white/10 shadow-2xl relative"
|
||||
>
|
||||
{/* Quote Icon */}
|
||||
<div className="absolute top-8 right-8 text-blue-100 dark:text-white/5 select-none">
|
||||
<span className="material-symbols-outlined text-8xl">format_quote</span>
|
||||
</div>
|
||||
|
||||
<div className="flex text-yellow-400 mb-6 gap-1 relative z-10">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<span key={star} className="material-symbols-outlined fill-current">star</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<blockquote className="text-xl md:text-2xl font-medium leading-relaxed text-gray-900 dark:text-white mb-8 relative z-10">
|
||||
"Bay Area Affiliates transformed our IT infrastructure completely. Their proactive approach means we rarely have downtime, and when issues do arise, they're resolved quickly. Our team can focus on patient care instead of tech problems."
|
||||
</blockquote>
|
||||
|
||||
<div className="flex items-center gap-4 relative z-10">
|
||||
<div className="w-12 h-12 bg-black dark:bg-white rounded-full flex items-center justify-center text-white dark:text-black font-bold text-lg">
|
||||
SM
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-gray-900 dark:text-white">Sarah Martinez</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Operations Manager, Coastal Medical Group</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Testimonials;
|
||||
|
||||
Reference in New Issue
Block a user