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:
co-authored by
Claude Opus 4.6
parent
e945da4b7f
commit
0ade3c3e50
@@ -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' })}>
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user