add servies page with content and a typing words feature to contact page
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
isolation: isolate;
|
||||
width: 100%;
|
||||
max-width: 36rem;
|
||||
margin: 0 auto;
|
||||
margin: 0;
|
||||
padding: 2rem;
|
||||
border-radius: 1.5rem;
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
|
||||
152
components/services-feature.css
Normal file
152
components/services-feature.css
Normal file
@@ -0,0 +1,152 @@
|
||||
.services-feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.services-feature-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.services-feature-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.service-feature {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
height: 100%;
|
||||
border-radius: 1.5rem;
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
.service-feature::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 2px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(56, 189, 248, 0.45),
|
||||
rgba(109, 40, 217, 0.35),
|
||||
rgba(60, 60, 60, 0.15)
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.service-feature:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(56, 189, 248, 0.12),
|
||||
0 8px 24px rgba(109, 40, 217, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.service-feature:hover::after {
|
||||
opacity: 1;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(56, 189, 248, 0.65),
|
||||
rgba(109, 40, 217, 0.55),
|
||||
rgba(60, 60, 60, 0.2)
|
||||
);
|
||||
}
|
||||
|
||||
.service-feature-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.service-feature-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
color: #6d28d9;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(56, 189, 248, 0.15),
|
||||
rgba(109, 40, 217, 0.15)
|
||||
);
|
||||
}
|
||||
|
||||
.service-feature-title {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--foreground, #171717);
|
||||
}
|
||||
|
||||
.service-feature-description {
|
||||
flex: 1;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.service-feature {
|
||||
background-color: rgba(24, 24, 27, 0.95);
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.service-feature:hover {
|
||||
box-shadow:
|
||||
0 12px 32px rgba(56, 189, 248, 0.15),
|
||||
0 8px 24px rgba(109, 40, 217, 0.12),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.service-feature-title {
|
||||
color: #ededed;
|
||||
}
|
||||
|
||||
.service-feature-description {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.service-feature,
|
||||
.service-feature::after {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.service-feature:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
97
components/services-feature.tsx
Normal file
97
components/services-feature.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import './services-feature.css';
|
||||
|
||||
export type Service = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const services: Service[] = [
|
||||
{
|
||||
title: 'Custom Web Design',
|
||||
description:
|
||||
'Tailored layouts and branding that reflect your business and engage your audience from the first visit.',
|
||||
},
|
||||
{
|
||||
title: 'Web Development',
|
||||
description:
|
||||
'Fast, reliable sites built with modern frameworks—scalable architecture that grows with your company.',
|
||||
},
|
||||
{
|
||||
title: 'E-commerce',
|
||||
description:
|
||||
'Online stores with smooth checkout, inventory management, and payment integrations that convert.',
|
||||
},
|
||||
{
|
||||
title: 'Performance & SEO',
|
||||
description:
|
||||
'Optimised load times, accessibility, and search visibility so customers can find and use your site.',
|
||||
},
|
||||
{
|
||||
title: 'Hosting & Maintenance',
|
||||
description:
|
||||
'Secure hosting, updates, backups, and monitoring so your site stays online and up to date.',
|
||||
},
|
||||
{
|
||||
title: 'APIs & Integrations',
|
||||
description:
|
||||
'Connect CRMs, booking systems, analytics, and third-party tools into one cohesive workflow.',
|
||||
},
|
||||
];
|
||||
|
||||
function ServiceFeatureCard({ title, description }: Service) {
|
||||
return (
|
||||
<article className="service-feature">
|
||||
<div className="service-feature-body">
|
||||
<div className="service-feature-icon" aria-hidden="true">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="service-feature-title">{title}</h3>
|
||||
<p className="service-feature-description">{description}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
type ServicesFeatureProps = {
|
||||
heading?: string;
|
||||
subheading?: string;
|
||||
items?: Service[];
|
||||
};
|
||||
|
||||
export default function ServicesFeature({
|
||||
heading = 'What we offer',
|
||||
subheading = 'End-to-end web solutions for businesses of every size.',
|
||||
items = services,
|
||||
}: ServicesFeatureProps) {
|
||||
return (
|
||||
<section className="services-feature w-full">
|
||||
<header className="mb-10 max-w-2xl">
|
||||
<h2 className="mb-3 text-4xl font-bold tracking-tight text-heading md:text-5xl">
|
||||
{heading}
|
||||
</h2>
|
||||
<p className="text-lg text-body lg:text-xl">{subheading}</p>
|
||||
</header>
|
||||
<ul className="services-feature-grid">
|
||||
{items.map((service) => (
|
||||
<li key={service.title} className="list-none">
|
||||
<ServiceFeatureCard {...service} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import './site-header.css'
|
||||
const menuLinks = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'About', href: '/about-us' },
|
||||
{ label: 'Plans', href: '/plans' },
|
||||
{ label: 'Services', href: '/services' },
|
||||
{ label: 'Contact', href: '/contact' },
|
||||
] as const
|
||||
|
||||
|
||||
25
components/typing-words.css
Normal file
25
components/typing-words.css
Normal file
@@ -0,0 +1,25 @@
|
||||
.typing-words {
|
||||
display: inline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.typing-words-cursor {
|
||||
display: inline-block;
|
||||
margin-left: 0.05em;
|
||||
font-weight: 300;
|
||||
color: #38bdf8;
|
||||
animation: typing-cursor-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes typing-cursor-blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.typing-words-cursor {
|
||||
animation: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
84
components/typing-words.tsx
Normal file
84
components/typing-words.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import './typing-words.css';
|
||||
|
||||
const WORDS = ['plan', 'idea', 'problem', 'goals'] as const;
|
||||
|
||||
const TYPING_MS = 90;
|
||||
const DELETING_MS = 55;
|
||||
const PAUSE_AFTER_TYPED_MS = 2000;
|
||||
const PAUSE_AFTER_DELETED_MS = 400;
|
||||
|
||||
type Phase = 'typing' | 'pausing' | 'deleting' | 'waiting';
|
||||
|
||||
export default function TypingWords() {
|
||||
const [wordIndex, setWordIndex] = useState(0);
|
||||
const [charIndex, setCharIndex] = useState(0);
|
||||
const [phase, setPhase] = useState<Phase>('typing');
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
setPrefersReducedMotion(media.matches);
|
||||
|
||||
const onChange = () => setPrefersReducedMotion(media.matches);
|
||||
media.addEventListener('change', onChange);
|
||||
return () => media.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (prefersReducedMotion) return;
|
||||
|
||||
const currentWord = WORDS[wordIndex];
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
if (phase === 'typing') {
|
||||
if (charIndex < currentWord.length) {
|
||||
timeoutId = setTimeout(() => setCharIndex((i) => i + 1), TYPING_MS);
|
||||
} else {
|
||||
timeoutId = setTimeout(() => setPhase('pausing'), PAUSE_AFTER_TYPED_MS);
|
||||
}
|
||||
} else if (phase === 'pausing') {
|
||||
timeoutId = setTimeout(() => setPhase('deleting'), 0);
|
||||
} else if (phase === 'deleting') {
|
||||
if (charIndex > 0) {
|
||||
timeoutId = setTimeout(() => setCharIndex((i) => i - 1), DELETING_MS);
|
||||
} else {
|
||||
timeoutId = setTimeout(() => {
|
||||
setWordIndex((i) => (i + 1) % WORDS.length);
|
||||
setPhase('waiting');
|
||||
}, PAUSE_AFTER_DELETED_MS);
|
||||
}
|
||||
} else if (phase === 'waiting') {
|
||||
timeoutId = setTimeout(() => setPhase('typing'), 0);
|
||||
}
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [charIndex, phase, wordIndex, prefersReducedMotion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefersReducedMotion) return;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
setWordIndex((i) => (i + 1) % WORDS.length);
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [prefersReducedMotion]);
|
||||
|
||||
const displayedWord = prefersReducedMotion
|
||||
? WORDS[wordIndex]
|
||||
: WORDS[wordIndex].slice(0, charIndex);
|
||||
|
||||
return (
|
||||
<span className="typing-words" aria-live="polite">
|
||||
<span className="typing-words-text text-transparent bg-clip-text bg-gradient-to-r from-sky-400 to-violet-800">
|
||||
{displayedWord}
|
||||
</span>
|
||||
<span className="typing-words-cursor" aria-hidden="true">
|
||||
|
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user