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
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' })}>
|
||||
|
||||
@@ -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