diff --git a/app/[lang]/(hyperjump)/case-studies/[slug]/page.tsx b/app/[lang]/(hyperjump)/case-studies/[slug]/page.tsx index cceba5c2..a623723e 100644 --- a/app/[lang]/(hyperjump)/case-studies/[slug]/page.tsx +++ b/app/[lang]/(hyperjump)/case-studies/[slug]/page.tsx @@ -1,5 +1,4 @@ import type { Metadata } from "next"; -import Image from "next/image"; import Link from "next/link"; import { notFound } from "next/navigation"; import { BreadcrumbJsonLd } from "next-seo"; @@ -11,11 +10,15 @@ import { dynamicOpengraph } from "@/lib/default-metadata"; import { caseStudyButton, caseStudyMore, - caseStudyQuestion + caseStudyQuestion, + mainHome, + mainCaseStudiesLabel } from "@/locales/.generated/strings"; import type { SupportedLanguage } from "@/locales/.generated/types"; import { supportedLanguages } from "@/locales/.generated/types"; +import { AnimatedLines } from "../../components/animated-lines"; +import { SectionReveal } from "../../components/motion-wrappers"; import { caseStudyBy, getCaseStudies, @@ -42,7 +45,7 @@ export async function generateMetadata({ const { lang, slug } = await params; const caseStudies = caseStudyBy({ lang, slug }); const meta: Metadata = { - title: `Case-Studies - ${caseStudies?.title ?? ""}`, + title: `${mainCaseStudiesLabel(lang)} - ${caseStudies?.title ?? ""}`, description: caseStudies?.description ?? "", alternates: { canonical: `${url}/${lang}/case-studies/${caseStudies?.slug}`, @@ -83,7 +86,7 @@ export default async function CaseStudy({ params }: CaseStudyProps) { return (
- +
@@ -108,11 +111,11 @@ export default async function CaseStudy({ params }: CaseStudyProps) { -
- Hero background -
- -
-

+ className="bg-hero-premium relative overflow-hidden text-white"> +
+
+
+ + +
+
+ +
+ + {mainCaseStudiesLabel(lang)} + +

+ {heading} +

+

+ {subheading} +

+
+
+

); diff --git a/app/[lang]/(hyperjump)/case-studies/page.tsx b/app/[lang]/(hyperjump)/case-studies/page.tsx index a7d9ad7a..c8856e7e 100644 --- a/app/[lang]/(hyperjump)/case-studies/page.tsx +++ b/app/[lang]/(hyperjump)/case-studies/page.tsx @@ -1,7 +1,6 @@ import type { Metadata } from "next"; import { BreadcrumbJsonLd } from "next-seo"; -import { Hero } from "@/app/components/hero"; import data from "@/data.json"; import { dynamicOpengraph } from "@/lib/default-metadata"; import { @@ -12,11 +11,16 @@ import { caseStudyExplore, caseStudyHeroDesc, caseStudyHeroHeading, - caseStudyTitle + caseStudyTitle, + caseStudyExploreDesc, + mainHome, + mainCaseStudiesLabel } from "@/locales/.generated/strings"; import { getCaseStudies } from "../data"; -import { CaseStudyCard } from "../components/case-study-card"; +import { AnimatedLines } from "../components/animated-lines"; +import { CaseStudyCarousel } from "../components/case-study-carousel"; +import { SectionReveal } from "../components/motion-wrappers"; const { url } = data; @@ -37,7 +41,7 @@ export async function generateMetadata(props: { }): Promise { const { lang } = await props.params; const meta: Metadata = { - title: `Case Studies - ${caseStudyTitle(lang)}`, + title: `${mainCaseStudiesLabel(lang)} - ${caseStudyTitle(lang)}`, description: caseStudyHeroDesc(lang), alternates: { canonical: `${url}/${lang}/case-studies`, @@ -56,39 +60,74 @@ export async function generateMetadata(props: { export default async function CaseStudiesPage({ params }: CaseStudyProps) { const { lang } = await params; + const caseStudies = getCaseStudies(lang); return ( -
- -
-

- {caseStudyExplore(lang)} -

-
-
-
- {getCaseStudies(lang).map((caseStudy) => ( - +
+
+
+
+ + +
+
+ +
+ + {mainCaseStudiesLabel(lang)} + +

- ))} -

+

+ {caseStudyHeroDesc(lang)} +

+
+
-
-
+
+
+ +
+
+ +
+

+ {caseStudyExplore(lang)} +

+

+ {caseStudyExploreDesc(lang)} +

+
+
+ + + + +
+
+ s + r.lineCount, 0); + +type FlatLine = { + ribbonIdx: number; + /** 0–1 position within its ribbon bundle */ + t: number; + gradId: string; + strokeWidth: number; +}; + +function buildFlatLines(): FlatLine[] { + const lines: FlatLine[] = []; + RIBBONS.forEach((ribbon, ri) => { + for (let i = 0; i < ribbon.lineCount; i++) { + lines.push({ + ribbonIdx: ri, + t: ribbon.lineCount === 1 ? 0.5 : i / (ribbon.lineCount - 1), + gradId: `ribbon-${ri}-line-${i}`, + strokeWidth: ribbon.strokeWidth + }); + } + }); + return lines; +} + +function lerpColor(a: number[], b: number[], t: number): string { + return `rgba(${Math.round(a[0] + (b[0] - a[0]) * t)}, ${Math.round(a[1] + (b[1] - a[1]) * t)}, ${Math.round(a[2] + (b[2] - a[2]) * t)}, ${(a[3] + (b[3] - a[3]) * t).toFixed(3)})`; +} + +function parseRgba(s: string): number[] { + const m = s.match(/[\d.]+/g); + return m ? m.map(Number) : [0, 0, 0, 0]; +} + +/** + * Build a swooping curve for one line within a ribbon. + * All lines in a ribbon share the same base curve shape; + * only a small per-line vertical offset separates them. + */ +function buildPath( + ribbon: Ribbon, + lineT: number, + width: number, + height: number, + time: number +): string { + const points: string[] = []; + const step = 5; + const t = time / 1000; + const lineOffset = (lineT - 0.5) * ribbon.spread; + + for (let x = 0; x <= width; x += step) { + const nx = x / width; + + const envelope = Math.pow(Math.sin(nx * Math.PI), 0.8); + + const base = + Math.sin(nx * 2.8 + ribbon.phase) * ribbon.amplitude * 0.7 + + Math.sin(nx * 1.2 + ribbon.phase * 0.6) * ribbon.amplitude * 0.3; + + const breathe = + Math.sin(t * 0.25 + ribbon.phase + nx * 1.5) * ribbon.amplitude * 0.12 + + Math.sin(t * 0.18 + ribbon.phase * 1.4 + nx * 3.0) * + ribbon.amplitude * + 0.06; + + const perLineWiggle = + Math.sin(nx * 6 + lineT * 12 + t * 0.14) * ribbon.spread * 0.08; + + const y = + height * ribbon.baseY + + (base + breathe) * envelope + + lineOffset + + perLineWiggle; + + points.push(x === 0 ? `M ${x} ${y}` : `L ${x} ${y}`); + } + return points.join(" "); +} + +/** + * Detects Chromium-based browsers (excluding Edge) via user agent. + */ +function isChromeBrowser(): boolean { + if (typeof navigator === "undefined") return false; + const ua = navigator.userAgent; + return /Chrome\//.test(ua) && !/Edg\//.test(ua); +} + +/** + * Renders tightly-bundled flowing ribbon lines as an SVG overlay, + * inspired by Stripe's surface-like animated gradient lines. + * + * Animation is disabled on Chrome to avoid scroll performance issues + * caused by the per-frame SVG path recomputation competing with + * Chrome's compositor thread. + */ +export function AnimatedLines({ className }: { className?: string }) { + const svgRef = useRef(null); + const pathsRef = useRef([]); + const flatLines = useRef(buildFlatLines()); + const rafRef = useRef(0); + + useEffect(() => { + const svg = svgRef.current; + if (!svg) return; + + const chrome = isChromeBrowser(); + let cachedWidth = 0; + let cachedHeight = 0; + + function drawStatic(width: number, height: number) { + for (let i = 0; i < flatLines.current.length; i++) { + const fl = flatLines.current[i]; + const path = pathsRef.current[i]; + if (path) { + path.setAttribute( + "d", + buildPath(RIBBONS[fl.ribbonIdx], fl.t, width, height, 0) + ); + } + } + } + + const resizeObserver = new ResizeObserver(([entry]) => { + const { width, height } = entry.contentRect; + cachedWidth = width; + cachedHeight = height; + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + if (chrome && width > 0 && height > 0) { + drawStatic(width, height); + } + }); + resizeObserver.observe(svg); + + if (chrome) { + return () => { + resizeObserver.disconnect(); + }; + } + + let startTime: number | null = null; + let isVisible = false; + + function animate(timestamp: number) { + if (!isVisible) return; + if (!startTime) startTime = timestamp; + const elapsed = timestamp - startTime; + + if (cachedWidth === 0 || cachedHeight === 0) { + rafRef.current = requestAnimationFrame(animate); + return; + } + + for (let i = 0; i < flatLines.current.length; i++) { + const fl = flatLines.current[i]; + const path = pathsRef.current[i]; + if (path) { + path.setAttribute( + "d", + buildPath( + RIBBONS[fl.ribbonIdx], + fl.t, + cachedWidth, + cachedHeight, + elapsed + ) + ); + } + } + + rafRef.current = requestAnimationFrame(animate); + } + + const visObserver = new IntersectionObserver( + ([entry]) => { + isVisible = entry.isIntersecting; + if (isVisible) { + rafRef.current = requestAnimationFrame(animate); + } else { + cancelAnimationFrame(rafRef.current); + } + }, + { threshold: 0 } + ); + + visObserver.observe(svg); + return () => { + visObserver.disconnect(); + resizeObserver.disconnect(); + cancelAnimationFrame(rafRef.current); + }; + }, []); + + const parsedColors = RIBBONS.map((r) => ({ + from: parseRgba(r.colorFrom), + to: parseRgba(r.colorTo) + })); + + return ( + + ); +} diff --git a/app/[lang]/(hyperjump)/components/case-study-card.tsx b/app/[lang]/(hyperjump)/components/case-study-card.tsx index b8e3f623..c87d00a0 100644 --- a/app/[lang]/(hyperjump)/components/case-study-card.tsx +++ b/app/[lang]/(hyperjump)/components/case-study-card.tsx @@ -1,6 +1,6 @@ +import { ArrowRightIcon } from "lucide-react"; import Link from "next/link"; -import { Button } from "@/components/ui/button"; import { caseStudyButton } from "@/locales/.generated/strings"; import type { SupportedLanguage } from "@/locales/.generated/types"; @@ -19,25 +19,25 @@ export function CaseStudyCard({ return (
+ className="group flex h-full flex-col justify-between rounded-2xl border-t border-r border-b border-l-2 border-t-black/6 border-r-black/6 border-b-black/6 border-l-[#635BFF] bg-white p-7 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-black/6">
- + {serviceBySlug({ lang, slug: serviceSlug })?.title} -

+

{title}

-

+

{description}

- + + {caseStudyButton(lang)} + +
); } diff --git a/app/[lang]/(hyperjump)/components/case-study-carousel.tsx b/app/[lang]/(hyperjump)/components/case-study-carousel.tsx new file mode 100644 index 00000000..d6bdec13 --- /dev/null +++ b/app/[lang]/(hyperjump)/components/case-study-carousel.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { AnimatePresence, motion } from "framer-motion"; +import Image from "next/image"; +import Link from "next/link"; +import { useCallback, useState } from "react"; + +import { caseStudyButton } from "@/locales/.generated/strings"; +import type { SupportedLanguage } from "@/locales/.generated/types"; + +import type { CaseStudy } from "../data"; +import { serviceBySlug } from "../data"; + +type CaseStudyCarouselProps = { + caseStudies: CaseStudy[]; + lang: SupportedLanguage; +}; + +/** + * Hook encapsulating carousel navigation state and handlers. + */ +const useCarousel = (itemCount: number) => { + const [activeIndex, setActiveIndex] = useState(0); + + const goTo = useCallback( + (index: number) => { + setActiveIndex((index + itemCount) % itemCount); + }, + [itemCount] + ); + + const goNext = useCallback(() => goTo(activeIndex + 1), [activeIndex, goTo]); + const goPrev = useCallback(() => goTo(activeIndex - 1), [activeIndex, goTo]); + + return { activeIndex, goTo, goNext, goPrev }; +}; + +/** + * Stripe-style "squeezy carousel" for case studies. + * The active item expands to fill most of the row width while + * inactive items collapse into narrow vertical image strips. + */ +const CaseStudyCarousel = ({ caseStudies, lang }: CaseStudyCarouselProps) => { + const { activeIndex, goTo, goNext, goPrev } = useCarousel(caseStudies.length); + const active = caseStudies[activeIndex]; + + return ( +
+
+ + +
+ +
+ {caseStudies.map((cs, i) => { + const isActive = i === activeIndex; + return ( + goTo(i)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") goTo(i); + }} + aria-label={cs.title} + aria-current={isActive ? "true" : undefined} + style={{ position: "relative" }} + className="cursor-pointer overflow-hidden rounded-xl" + initial={false} + animate={{ + flexGrow: isActive ? 6 : 1, + flexShrink: 1, + flexBasis: 0 + }} + transition={{ duration: 0.5, ease: [0.4, 0, 0.2, 1] }}> + {cs.title} + {!isActive &&
} + + ); + })} +
+ + + +
+ + {serviceBySlug({ lang, slug: active.serviceSlug })?.title} + +

+ {active.title}.{" "} + {active.description} +

+
+ + {caseStudyButton(lang)} + + +
+
+
+ ); +}; + +export { CaseStudyCarousel }; diff --git a/app/[lang]/(hyperjump)/components/clients.tsx b/app/[lang]/(hyperjump)/components/clients.tsx index eba0e80c..46cbb7d4 100644 --- a/app/[lang]/(hyperjump)/components/clients.tsx +++ b/app/[lang]/(hyperjump)/components/clients.tsx @@ -1,6 +1,6 @@ -"use client"; - import Image from "next/image"; +import { mainTrustedBy } from "@/locales/.generated/strings"; +import type { SupportedLanguage } from "@/locales/.generated/types"; type Client = { name: string; @@ -9,15 +9,19 @@ type Client = { type ClientsProps = { clients: Client[]; + lang: SupportedLanguage; }; -export function Clients({ clients }: ClientsProps) { +export function Clients({ clients, lang }: ClientsProps) { if (clients.length === 0) return null; const repeatedClients = Array(4).fill(clients).flat(); return ( -
+
+

+ {mainTrustedBy(lang)} +

{repeatedClients.map(({ imageUrl, name }, index) => ( @@ -27,7 +31,7 @@ export function Clients({ clients }: ClientsProps) { alt={name} width={120} height={36} - className="h-6 w-auto object-contain sm:h-7" + className="h-5 w-auto object-contain opacity-50 transition-opacity duration-300 hover:opacity-90 sm:h-6" /> ))}
diff --git a/app/[lang]/(hyperjump)/components/footer.tsx b/app/[lang]/(hyperjump)/components/footer.tsx index 912590ef..bf52ba07 100644 --- a/app/[lang]/(hyperjump)/components/footer.tsx +++ b/app/[lang]/(hyperjump)/components/footer.tsx @@ -11,38 +11,28 @@ type FooterProps = { lang: SupportedLanguage }; export default function Footer({ lang }: FooterProps) { return ( -