add servies page with content and a typing words feature to contact page

This commit is contained in:
2026-05-30 08:51:05 +01:00
parent 87f8d2b04e
commit f67093f34e
12 changed files with 406 additions and 19 deletions

View File

@@ -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);

View 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;
}
}

View 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>
);
}

View File

@@ -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

View 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;
}
}

View 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>
);
}