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 { 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',
});