perf: optimize landing page - remove framer-motion, use Google Fonts

Performance improvements:
- Replaced framer-motion animations with CSS @keyframes in hero,
  testimonial, and community sections (saves ~130KB bundle)
- Switched from local Poppins/BaiJamjuree font files (750KB) to
  next/font/google with auto-subsetting
- Deleted 10 local .ttf font files
- Community section is now a server component (no 'use client')
- Removed scroll parallax effect from hero (was causing repaints)
- Hero image marked as priority for LCP optimization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
maulanasdqn
2026-04-10 21:53:04 +07:00
co-authored by Claude Opus 4.6
parent e945da4b7f
commit 0ade3c3e50
14 changed files with 83 additions and 257 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,8 +1,4 @@
'use client';
import SOCIALS from '@/data/socials.json'; import SOCIALS from '@/data/socials.json';
import { motion, useInView } from 'framer-motion';
import { useRef } from 'react';
import { import {
FaArrowRight, FaArrowRight,
FaDiscord, FaDiscord,
@@ -13,137 +9,53 @@ import {
FaTiktok, FaTiktok,
} from 'react-icons/fa'; } from 'react-icons/fa';
const ICON_MAP: Record<string, typeof FaArrowRight> = {
FaFacebook, FaDiscord, FaGithub, FaInstagram, FaTiktok, FaLinkedin,
};
const COLOR_MAP: Record<string, string> = {
FaFacebook: 'text-[#1877F2]',
FaDiscord: 'text-[#5865F2]',
FaGithub: 'text-[#000000]',
FaInstagram: 'text-[#E4405F]',
FaTiktok: 'text-[#000000]',
FaLinkedin: 'text-[#0A66C2]',
};
export function CommunitySection() { export function CommunitySection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, amount: 0.2 });
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
};
const itemVariants = {
hidden: { y: 20, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94] as any,
},
},
};
const getIconComponent = (iconName: string) => {
switch (iconName) {
case 'FaFacebook':
return FaFacebook;
case 'FaDiscord':
return FaDiscord;
case 'FaGithub':
return FaGithub;
case 'FaInstagram':
return FaInstagram;
case 'FaTiktok':
return FaTiktok;
case 'FaLinkedin':
return FaLinkedin;
default:
return FaArrowRight;
}
};
const getPlatformColors = (iconName: string) => {
switch (iconName) {
case 'FaFacebook':
return {
iconColor: 'text-[#1877F2]',
};
case 'FaDiscord':
return {
iconColor: 'text-[#5865F2]',
};
case 'FaGithub':
return {
iconColor: 'text-[#000000]',
};
case 'FaInstagram':
return {
iconColor: 'text-[#E4405F]',
};
case 'FaTiktok':
return {
iconColor: 'text-[#000000]',
};
case 'FaLinkedin':
return {
iconColor: 'text-[#0A66C2]',
};
default:
return {
iconColor: 'text-primary-500',
};
}
};
return ( return (
<section id="community" className="w-full py-20 md:py-28"> <section id="community" className="w-full py-20 md:py-28">
<div className="container" ref={ref}> <div className="container">
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16"> <div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
<motion.div <h2 className="text-3xl font-semibold tracking-tight md:text-4xl">
initial={{ opacity: 0, y: 20 }} Bergabung dengan Komunitas Kami di
animate={isInView ? { opacity: 1, y: 0 } : {}} <span className="block mt-2 text-primary-500">Berbagai Platform</span>
transition={{ duration: 0.4 }} </h2>
> <p className="max-w-[600px] mx-auto text-gray-600 md:text-lg/relaxed mt-4">
<h2 className="text-3xl font-semibold tracking-tight md:text-4xl"> Terhubung dengan sesama developer di komunitas kami
Bergabung dengan Komunitas Kami di </p>
<span className="block mt-2 text-primary-500">
Berbagai Platform
</span>
</h2>
<p className="max-w-[600px] mx-auto text-gray-600 md:text-lg/relaxed mt-4">
Terhubung dengan sesama developer di komunitas kami
</p>
</motion.div>
</div> </div>
<motion.div <div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
{SOCIALS.map((community, index) => { {SOCIALS.map((community, index) => {
const IconComponent = getIconComponent(community.icon); const IconComponent = ICON_MAP[community.icon] || FaArrowRight;
const colors = getPlatformColors(community.icon); const iconColor = COLOR_MAP[community.icon] || 'text-primary-500';
return ( return (
<motion.div <div
key={index} key={index}
variants={itemVariants} className="group relative p-8 bg-white border border-gray-200 rounded-xl hover:border-gray-300 transition-all duration-300 animate-[fadeInUp_0.4s_ease-out]"
className="group relative p-8 bg-white border border-gray-200 rounded-xl hover:border-gray-300 transition-all duration-300" style={{ animationDelay: `${index * 80}ms`, animationFillMode: 'both' }}
> >
<div className="flex flex-col items-start gap-5"> <div className="flex flex-col items-start gap-5">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<IconComponent <IconComponent className={`w-8 h-8 ${iconColor} transition-colors`} />
className={`w-8 h-8 ${colors.iconColor} transition-colors`} <h3 className="text-xl font-semibold text-gray-900">{community.name}</h3>
/>
<h3 className="text-xl font-semibold text-gray-900">
{community.name}
</h3>
</div> </div>
<p className="text-gray-600 text-sm/relaxed"> <p className="text-gray-600 text-sm/relaxed">{community.description}</p>
{community.description}
</p>
<a <a
href={community.link} href={community.link}
className={`inline-flex items-center gap-2 mt-2 text-sm font-medium transition-colors`} className="inline-flex items-center gap-2 mt-2 text-sm font-medium transition-colors"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@@ -151,10 +63,10 @@ export function CommunitySection() {
<FaArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" /> <FaArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</a> </a>
</div> </div>
</motion.div> </div>
); );
})} })}
</motion.div> </div>
</div> </div>
</section> </section>
); );
@@ -3,57 +3,27 @@
import HERO_CONTENT from '@/data/hero-content.json'; import HERO_CONTENT from '@/data/hero-content.json';
import HERO_STATS from '@/data/hero-stats.json'; import HERO_STATS from '@/data/hero-stats.json';
import { Button } from '@components'; import { Button } from '@components';
import { motion } from 'framer-motion';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Fragment, useEffect, useState } from 'react'; import { Fragment } from 'react';
export function HeroSection() { export function HeroSection() {
const router = useRouter(); const router = useRouter();
const { communityLabel, headingLine1, headingLine2, description, buttons } = const { communityLabel, headingLine1, headingLine2, description, buttons } =
HERO_CONTENT; HERO_CONTENT;
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return ( return (
<section className="relative w-full py-20 md:py-32 lg:py-40 overflow-hidden"> <section className="relative w-full py-20 md:py-32 lg:py-40 overflow-hidden">
<div className="absolute inset-0 -z-10 overflow-hidden"> <div className="absolute inset-0 -z-10 overflow-hidden">
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-b from-background to-background/50" /> <div className="absolute top-0 left-0 w-full h-full bg-gradient-to-b from-background to-background/50" />
<div <div className="absolute top-1/4 -left-20 w-80 h-80 rounded-full bg-gradient-to-r from-primary/20 to-blue-400/20 blur-3xl opacity-60" />
className="absolute top-1/4 -left-20 w-80 h-80 rounded-full bg-gradient-to-r from-primary/20 to-blue-400/20 blur-3xl" <div className="absolute bottom-1/3 -right-20 w-80 h-80 rounded-full bg-gradient-to-r from-blue-400/20 to-primary/20 blur-3xl opacity-60" />
style={{
transform: `translate(${scrollY * 0.1}px, ${scrollY * -0.05}px)`,
opacity: Math.max(0.2, 1 - scrollY * 0.001),
}}
/>
<div
className="absolute bottom-1/3 -right-20 w-80 h-80 rounded-full bg-gradient-to-r from-blue-400/20 to-primary/20 blur-3xl"
style={{
transform: `translate(${scrollY * -0.1}px, ${scrollY * 0.05}px)`,
opacity: Math.max(0.2, 1 - scrollY * 0.001),
}}
/>
<div className="absolute inset-0 bg-[linear-gradient(rgba(59,130,246,0.05)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.05)_1px,transparent_1px)] bg-[size:40px_40px]" /> <div className="absolute inset-0 bg-[linear-gradient(rgba(59,130,246,0.05)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.05)_1px,transparent_1px)] bg-[size:40px_40px]" />
</div> </div>
<div className="container px-4 md:px-6 relative"> <div className="container px-4 md:px-6 relative">
<div className="grid gap-6 lg:grid-cols-2 lg:gap-12 items-center"> <div className="grid gap-6 lg:grid-cols-2 lg:gap-12 items-center">
<motion.div <div className="flex flex-col justify-center space-y-8 animate-[fadeInUp_0.5s_ease-out]">
className="flex flex-col justify-center space-y-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center rounded-full border px-3 py-1 text-sm"> <span className="inline-flex items-center rounded-full border px-3 py-1 text-sm">
{communityLabel} {communityLabel}
@@ -87,11 +57,10 @@ export function HeroSection() {
<Button <Button
size="lg" size="lg"
variant="bordered" variant="bordered"
className="w-full sm:w-auto group relative overflow-hidden" className="w-full sm:w-auto"
onClick={() => router.push(buttons.exploreUrl)} onClick={() => router.push(buttons.exploreUrl)}
> >
<span className="absolute inset-0 bg-gradient-to-r from-primary/10 to-blue-400/10 translate-y-full group-hover:translate-y-0 transition-transform duration-300" /> {buttons.explore}
<span className="relative">{buttons.explore}</span>
</Button> </Button>
</div> </div>
@@ -110,24 +79,31 @@ export function HeroSection() {
</Fragment> </Fragment>
))} ))}
</div> </div>
</motion.div> </div>
<motion.div <div className="relative w-full lg:w-auto mx-auto lg:ml-auto animate-[fadeIn_0.5s_ease-out_0.2s_both]">
className="relative w-full lg:w-auto mx-auto lg:ml-auto"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<Image <Image
src="/logo.webp" src="/logo.webp"
alt="logo" alt="logo"
width={600} width={600}
height={500} height={500}
className="w-full h-auto object-cover" className="w-full h-auto object-cover"
priority
/> />
</motion.div> </div>
</div> </div>
</div> </div>
<style jsx global>{`
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
`}</style>
</section> </section>
); );
} }
@@ -1,9 +1,8 @@
'use client'; 'use client';
import { buttonVariants } from '@components'; import { buttonVariants } from '@components';
import { motion, useInView } from 'framer-motion';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import { FaQuoteLeft } from 'react-icons/fa'; import { FaQuoteLeft } from 'react-icons/fa';
interface ApiTestimonial { interface ApiTestimonial {
@@ -41,97 +40,46 @@ function getInitial(name: string) {
} }
export function TestimonialSection() { export function TestimonialSection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, amount: 0.1 });
const [testimonials, setTestimonials] = useState<Testimonial[]>([]); const [testimonials, setTestimonials] = useState<Testimonial[]>([]);
useEffect(() => { useEffect(() => {
fetch('https://api.imphnen.dev/v1/landing/cms/testimonials') fetch('https://api.imphnen.dev/v1/landing/cms/testimonials')
.then((res) => { .then((res) => res.ok ? res.json() : Promise.reject())
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
})
.then((json) => { .then((json) => {
const items: Testimonial[] = (json.data as ApiTestimonial[]) setTestimonials(
.filter((t) => !t.is_deleted) (json.data as ApiTestimonial[])
.slice(0, 6) .filter((t) => !t.is_deleted)
.map((t) => ({ .slice(0, 6)
id: t.id, .map((t) => ({ id: t.id, name: t.user_fullname, role: t.role, text: t.content }))
name: t.user_fullname, );
role: t.role,
text: t.content,
}));
setTestimonials(items);
}) })
.catch(() => { .catch(() => setTestimonials([]));
setTestimonials([]);
});
}, []); }, []);
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
};
const itemVariants = {
hidden: { y: 20, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94] as any,
},
},
};
return ( return (
<section className="w-full py-20 md:py-28 bg-gray-50"> <section className="w-full py-20 md:py-28 bg-gray-50">
<div className="container" ref={ref}> <div className="container">
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16"> <div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
<motion.div <h2 className="text-3xl font-semibold tracking-tight md:text-4xl animate-[fadeInUp_0.4s_ease-out]">
initial={{ opacity: 0, y: 20 }} Apa Kata Mereka Tentang
animate={isInView ? { opacity: 1, y: 0 } : {}} <span className="block mt-2 text-primary-500">Komunitas Kami?</span>
transition={{ duration: 0.4 }} </h2>
>
<h2 className="text-3xl font-semibold tracking-tight md:text-4xl">
Apa Kata Mereka Tentang
<span className="block mt-2 text-primary-500">
Komunitas Kami?
</span>
</h2>
</motion.div>
</div> </div>
<motion.div <div className="grid gap-8 md:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mb-16">
className="grid gap-8 md:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mb-16"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
{testimonials.map((testimonial, index) => ( {testimonials.map((testimonial, index) => (
<motion.div <div
key={testimonial.id} key={testimonial.id}
variants={itemVariants} className="p-6 bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300 animate-[fadeInUp_0.4s_ease-out]"
className="p-6 bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300" style={{ animationDelay: `${index * 100}ms`, animationFillMode: 'both' }}
> >
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div <div className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-semibold ${getAvatarColor(index)}`}>
className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-semibold ${getAvatarColor(index)}`}
>
{getInitial(testimonial.name)} {getInitial(testimonial.name)}
</div> </div>
<div> <div>
<h4 className="font-semibold text-gray-900"> <h4 className="font-semibold text-gray-900">{testimonial.name}</h4>
{testimonial.name}
</h4>
<p className="text-sm text-gray-600">{testimonial.role}</p> <p className="text-sm text-gray-600">{testimonial.role}</p>
</div> </div>
</div> </div>
@@ -140,9 +88,9 @@ export function TestimonialSection() {
<p className="text-sm/relaxed">{testimonial.text}</p> <p className="text-sm/relaxed">{testimonial.text}</p>
</div> </div>
</div> </div>
</motion.div> </div>
))} ))}
</motion.div> </div>
<div className="flex justify-center"> <div className="flex justify-center">
<Link href="/testimonials" className={buttonVariants({ size: 'lg' })}> <Link href="/testimonials" className={buttonVariants({ size: 'lg' })}>
+7 -17
View File
@@ -1,23 +1,13 @@
import localFont from 'next/font/local'; import { Poppins, Bai_Jamjuree } from 'next/font/google';
export const baiJamjureeFont = localFont({ export const poppinsFont = Poppins({
src: [ subsets: ['latin'],
{ path: '../../public/fonts/BaiJamjuree-Light.ttf', weight: '300', style: 'normal' }, weight: ['400', '500', '600', '700'],
{ path: '../../public/fonts/BaiJamjuree-Regular.ttf', weight: '400', style: 'normal' },
{ path: '../../public/fonts/BaiJamjuree-Medium.ttf', weight: '500', style: 'normal' },
{ path: '../../public/fonts/BaiJamjuree-SemiBold.ttf', weight: '600', style: 'normal' },
{ path: '../../public/fonts/BaiJamjuree-Bold.ttf', weight: '700', style: 'normal' },
],
display: 'swap', display: 'swap',
}); });
export const poppinsFont = localFont({ export const baiJamjureeFont = Bai_Jamjuree({
src: [ subsets: ['latin'],
{ path: '../../public/fonts/Poppins-Light.ttf', weight: '300', style: 'normal' }, weight: ['400', '500', '600', '700'],
{ path: '../../public/fonts/Poppins-Regular.ttf', weight: '400', style: 'normal' },
{ path: '../../public/fonts/Poppins-Medium.ttf', weight: '500', style: 'normal' },
{ path: '../../public/fonts/Poppins-SemiBold.ttf', weight: '600', style: 'normal' },
{ path: '../../public/fonts/Poppins-Bold.ttf', weight: '700', style: 'normal' },
],
display: 'swap', display: 'swap',
}); });