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
@@ -1,8 +1,4 @@
'use client';
import SOCIALS from '@/data/socials.json';
import { motion, useInView } from 'framer-motion';
import { useRef } from 'react';
import {
FaArrowRight,
FaDiscord,
@@ -13,137 +9,53 @@ import {
FaTiktok,
} 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() {
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 (
<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">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.4 }}
>
<h2 className="text-3xl font-semibold tracking-tight md:text-4xl">
Bergabung dengan Komunitas Kami di
<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>
<h2 className="text-3xl font-semibold tracking-tight md:text-4xl">
Bergabung dengan Komunitas Kami di
<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>
</div>
<motion.div
className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{SOCIALS.map((community, index) => {
const IconComponent = getIconComponent(community.icon);
const colors = getPlatformColors(community.icon);
const IconComponent = ICON_MAP[community.icon] || FaArrowRight;
const iconColor = COLOR_MAP[community.icon] || 'text-primary-500';
return (
<motion.div
<div
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"
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]"
style={{ animationDelay: `${index * 80}ms`, animationFillMode: 'both' }}
>
<div className="flex flex-col items-start gap-5">
<div className="flex items-center gap-4">
<IconComponent
className={`w-8 h-8 ${colors.iconColor} transition-colors`}
/>
<h3 className="text-xl font-semibold text-gray-900">
{community.name}
</h3>
<IconComponent className={`w-8 h-8 ${iconColor} transition-colors`} />
<h3 className="text-xl font-semibold text-gray-900">{community.name}</h3>
</div>
<p className="text-gray-600 text-sm/relaxed">
{community.description}
</p>
<p className="text-gray-600 text-sm/relaxed">{community.description}</p>
<a
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"
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" />
</a>
</div>
</motion.div>
</div>
);
})}
</motion.div>
</div>
</div>
</section>
);
@@ -3,57 +3,27 @@
import HERO_CONTENT from '@/data/hero-content.json';
import HERO_STATS from '@/data/hero-stats.json';
import { Button } from '@components';
import { motion } from 'framer-motion';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { Fragment, useEffect, useState } from 'react';
import { Fragment } from 'react';
export function HeroSection() {
const router = useRouter();
const { communityLabel, headingLine1, headingLine2, description, buttons } =
HERO_CONTENT;
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<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 top-0 left-0 w-full h-full bg-gradient-to-b from-background to-background/50" />
<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"
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 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" />
<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" />
<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 className="container px-4 md:px-6 relative">
<div className="grid gap-6 lg:grid-cols-2 lg:gap-12 items-center">
<motion.div
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-col justify-center space-y-8 animate-[fadeInUp_0.5s_ease-out]">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center rounded-full border px-3 py-1 text-sm">
{communityLabel}
@@ -87,11 +57,10 @@ export function HeroSection() {
<Button
size="lg"
variant="bordered"
className="w-full sm:w-auto group relative overflow-hidden"
className="w-full sm:w-auto"
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" />
<span className="relative">{buttons.explore}</span>
{buttons.explore}
</Button>
</div>
@@ -110,24 +79,31 @@ export function HeroSection() {
</Fragment>
))}
</div>
</motion.div>
</div>
<motion.div
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 }}
>
<div className="relative w-full lg:w-auto mx-auto lg:ml-auto animate-[fadeIn_0.5s_ease-out_0.2s_both]">
<Image
src="/logo.webp"
alt="logo"
width={600}
height={500}
className="w-full h-auto object-cover"
priority
/>
</motion.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>
);
}
@@ -1,9 +1,8 @@
'use client';
import { buttonVariants } from '@components';
import { motion, useInView } from 'framer-motion';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { FaQuoteLeft } from 'react-icons/fa';
interface ApiTestimonial {
@@ -41,97 +40,46 @@ function getInitial(name: string) {
}
export function TestimonialSection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, amount: 0.1 });
const [testimonials, setTestimonials] = useState<Testimonial[]>([]);
useEffect(() => {
fetch('https://api.imphnen.dev/v1/landing/cms/testimonials')
.then((res) => {
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
})
.then((res) => res.ok ? res.json() : Promise.reject())
.then((json) => {
const items: Testimonial[] = (json.data as ApiTestimonial[])
.filter((t) => !t.is_deleted)
.slice(0, 6)
.map((t) => ({
id: t.id,
name: t.user_fullname,
role: t.role,
text: t.content,
}));
setTestimonials(items);
setTestimonials(
(json.data as ApiTestimonial[])
.filter((t) => !t.is_deleted)
.slice(0, 6)
.map((t) => ({ id: t.id, name: t.user_fullname, role: t.role, text: t.content }))
);
})
.catch(() => {
setTestimonials([]);
});
.catch(() => 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 (
<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">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.4 }}
>
<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>
<h2 className="text-3xl font-semibold tracking-tight md:text-4xl animate-[fadeInUp_0.4s_ease-out]">
Apa Kata Mereka Tentang
<span className="block mt-2 text-primary-500">Komunitas Kami?</span>
</h2>
</div>
<motion.div
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'}
>
<div className="grid gap-8 md:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mb-16">
{testimonials.map((testimonial, index) => (
<motion.div
<div
key={testimonial.id}
variants={itemVariants}
className="p-6 bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300"
className="p-6 bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300 animate-[fadeInUp_0.4s_ease-out]"
style={{ animationDelay: `${index * 100}ms`, animationFillMode: 'both' }}
>
<div className="flex flex-col space-y-4">
<div className="flex items-center gap-4">
<div
className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-semibold ${getAvatarColor(index)}`}
>
<div className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-semibold ${getAvatarColor(index)}`}>
{getInitial(testimonial.name)}
</div>
<div>
<h4 className="font-semibold text-gray-900">
{testimonial.name}
</h4>
<h4 className="font-semibold text-gray-900">{testimonial.name}</h4>
<p className="text-sm text-gray-600">{testimonial.role}</p>
</div>
</div>
@@ -140,9 +88,9 @@ export function TestimonialSection() {
<p className="text-sm/relaxed">{testimonial.text}</p>
</div>
</div>
</motion.div>
</div>
))}
</motion.div>
</div>
<div className="flex justify-center">
<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({
src: [
{ path: '../../public/fonts/BaiJamjuree-Light.ttf', weight: '300', style: 'normal' },
{ 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' },
],
export const poppinsFont = Poppins({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
display: 'swap',
});
export const poppinsFont = localFont({
src: [
{ path: '../../public/fonts/Poppins-Light.ttf', weight: '300', style: 'normal' },
{ 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' },
],
export const baiJamjureeFont = Bai_Jamjuree({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
display: 'swap',
});