Compare commits

...

2 Commits

13 changed files with 418 additions and 26 deletions

View File

@@ -1,5 +1,10 @@
import PicWithText from "@/components/pic-with-text";
import heda_bang from "../../assets/u_mey4kjj5ww-angry-2498.gif"
export default function Page() {
return (
<h1>about us page</h1>
<PicWithText src={heda_bang.src} text="Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?" reverse={false} title="About us"/>
)
}

View File

@@ -1,11 +1,23 @@
import ContactForm from '@/components/contact-form'
import ContactForm from '@/components/contact-form';
import TypingWords from '@/components/typing-words';
export default function ContactPage() {
return (
<div className="flex min-h-screen flex-col bg-zinc-50 font-sans dark:bg-black">
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col items-center px-8 py-16 sm:py-24">
<ContactForm />
</main>
<div className="flex w-full flex-col gap-10 lg:flex-row lg:items-start lg:gap-12 xl:gap-16">
<div className="w-full lg:w-1/2 lg:max-w-xl">
<h1 className="mb-4 text-4xl font-bold leading-tight text-heading sm:text-5xl md:text-6xl">
Tell us about your
</h1>
<h1 className="mb-4 text-4xl font-bold leading-tight text-heading sm:text-5xl md:text-6xl">
<TypingWords />
</h1>
<p className="text-lg text-body lg:text-xl">
Share a few details and we&apos;ll get back to you as soon as we can.
</p>
</div>
)
<div className="w-full lg:w-1/2 lg:flex lg:justify-end">
<ContactForm />
</div>
</div>
);
}

View File

@@ -36,7 +36,11 @@ export default function RootLayout({
>
<body className="min-h-full flex flex-col dark:bg-black">
<SiteHeader />
<div className="flex min-h-screen flex-col bg-zinc-50 font-sans dark:bg-black">
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-between bg-white px-8 py-32 dark:bg-black sm:items-start">
{children}
</main>
</div>
<Footer />
</body>
</html>

View File

@@ -46,16 +46,13 @@ export default function Home() {
}
return (
<div className="flex min-h-screen flex-col bg-zinc-50 font-sans dark:bg-black">
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-between bg-white px-8 py-32 dark:bg-black sm:items-start">
<>
<HeroSection />
<PicWithText {...picWithText} />
<div className="w-full m-auto">
<ContentCardCarousel cards={contentCards} />
</div>
</main>
</div>
</>
)
}

View File

@@ -1,5 +0,0 @@
export default function Page() {
return (
<h1>plans page</h1>
)
}

16
app/services/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
import ServicesFeature from '@/components/services-feature';
export default function ServicesPage() {
return (
<>
<h1 className="mb-4 text-5xl font-bold text-heading md:text-6xl">
Our <span className="text-transparent bg-clip-text bg-gradient-to-r from-sky-400 to-violet-800">Services</span>
</h1>
<p className="mb-12 max-w-2xl text-lg text-body lg:text-xl">
Sculpted Arc helps companies build, launch, and grow onlinefrom first design
through long-term support.
</p>
<ServicesFeature />
</>
);
}

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

@@ -16,6 +16,7 @@ interface ContentCardCarouselProps {
cards: ContentCardItem[]
}
// correctly works
function getVisibleCardCount() {
if (typeof window === 'undefined') return 1
if (window.matchMedia('(min-width: 1024px)').matches) return 3
@@ -52,21 +53,26 @@ export default function ContentCardCarousel({ cards }: ContentCardCarouselProps)
const maxIndex = useMemo(() => Math.max(cards.length - visibleCards, 0), [cards.length, visibleCards])
// custom mod function because JS does not have working modulus
function mod(n: number, m: number) {
return ((n % m) + m) % m;
}
useEffect(() => {
setCurrentIndex((previous) => Math.min(previous, maxIndex))
}, [maxIndex])
// % of track width — matches flex slide sizes (100% / 50% / 33.333% per breakpoint)
const trackOffsetPercent = cards.length > 0 ? (currentIndex * 100) / cards.length : 0
const trackOffsetPercent = cards.length > 0 ? ((currentIndex) * -100) / visibleCards : 0
const next = () => {
if (maxIndex === 0) return
setCurrentIndex((previous) => (previous >= maxIndex ? 0 : previous + 1))
setCurrentIndex(mod(Math.abs((currentIndex + 1)), (cards.length)))
}
const previous = () => {
if (maxIndex === 0) return
setCurrentIndex((index) => (index <= 0 ? maxIndex : index - 1))
setCurrentIndex(mod((currentIndex - 1), (cards.length)))
}
return (
@@ -76,7 +82,7 @@ export default function ContentCardCarousel({ cards }: ContentCardCarouselProps)
<div className="carousel-viewport relative overflow-hidden">
<div
className="carousel-track flex items-stretch transition-transform duration-500 ease-in-out"
style={{ transform: `translate3d(-${trackOffsetPercent}%, 0, 0)` }}
style={{ transform: `translate3d(${trackOffsetPercent}%, 0, 0)` }}
>
{cards.map((card, index) => {
const isEdgeCard =
@@ -97,15 +103,14 @@ export default function ContentCardCarousel({ cards }: ContentCardCarouselProps)
})}
</div>
<div className="pointer-events-none absolute inset-y-0 left-0 w-12 bg-gradient-to-r from-white to-transparent dark:from-black" />
<div className="pointer-events-none absolute inset-y-0 right-0 w-12 bg-gradient-to-l from-white to-transparent dark:from-black" />
<div className="absolute inset-y-0 left-0 w-12 bg-linear-to-r from-white to-transparent dark:from-black" />
<div className="absolute inset-y-0 right-0 w-12 bg-linear-to-l from-white to-transparent dark:from-black" />
</div>
<div className="carousel-controls relative z-20 mt-6 flex justify-center gap-4">
<button
type="button"
onClick={previous}
disabled={maxIndex === 0}
className="carousel-control-btn"
aria-label="Show previous content cards"
>

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