feat: implement Testimonials section with dynamic data fetching and UI integration

This commit is contained in:
ArraysID
2025-05-10 14:52:35 +07:00
parent a800b1e293
commit c4cf9e4e6c
6 changed files with 250 additions and 123 deletions
@@ -1,87 +1,64 @@
'use client'; 'use client';
import { QuoteIcon } from '@imphnen-frontend-service/shadcn-ui/atoms'; import { QuoteIcon } from '@imphnen-frontend-service/shadcn-ui/atoms';
import { formatCMSImageDataToMedia } from 'apps/landing/src/lib/format';
import { TestimonialsSection } from 'apps/landing/src/payload-types';
import { motion, useInView } from 'framer-motion'; import { motion, useInView } from 'framer-motion';
import Image from 'next/image'; import Image from 'next/image';
import { useRef } from 'react'; import { useRef } from 'react';
export function Testimonials() { export function Testimonials(props: TestimonialsSection) {
const ref = useRef(null); const { title, subtitle, items, stats, joinTitle, joinText } = props;
const isInView = useInView(ref, { once: true, amount: 0.2 });
const testimonials = [ const ref = useRef<HTMLDivElement>(null);
{ const isInView = useInView(ref, { once: true, amount: 0.2 });
quote:
'Saya yang tadinya tidak mengerti apa-apa tentang programming, sekarang bisa membuat website sendiri dengan mudah. Terima kasih IMPHNEN!',
name: 'Budi Santoso',
role: 'Web Developer Pemula',
avatar: '/placeholder.svg?height=64&width=64',
},
{
quote:
'Komunitas yang sangat supportif! Setiap pertanyaan selalu dijawab dengan cepat dan jelas. Diskusi programnya mudah dipahami.',
name: 'Anita Ratna',
role: 'Mobile App Developer',
avatar: '/placeholder.svg?height=64&width=64',
},
{
quote:
'Server Discord IMPHNEN adalah tempat belajar terbaik untuk programmer pemula seperti saya. Materinya lengkap dan komunitasnya sangat membantu!',
name: 'Dedi Permana',
role: 'Data Scientist',
avatar: '/placeholder.svg?height=64&width=64',
},
];
const containerVariants = { const containerVariants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
visible: { visible: { opacity: 1, transition: { staggerChildren: 0.1 } },
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
}; };
const itemVariants = { const itemVariants = {
hidden: { y: 20, opacity: 0 }, hidden: { y: 20, opacity: 0 },
visible: { visible: { y: 0, opacity: 1, transition: { duration: 0.5 } },
y: 0,
opacity: 1,
transition: { duration: 0.5 },
},
}; };
return ( return (
<section <section
id="testimoni" id="testimoni"
className="w-full py-20 md:py-32 bg-muted relative overflow-hidden" className="w-full py-20 md:py-32 bg-muted relative overflow-hidden"
ref={ref}
> >
{/* Background Elements */}
<div className="absolute inset-0 -z-10"> <div className="absolute inset-0 -z-10">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.1),transparent_50%)]" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.1),transparent_50%)]" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom,rgba(96,165,250,0.1),transparent_50%)]" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom,rgba(96,165,250,0.1),transparent_50%)]" />
</div> </div>
<div className="container px-4 md:px-6" ref={ref}> <div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16"> <motion.div
<motion.div className="flex flex-col items-center justify-center space-y-4 text-center mb-16"
className="space-y-2" initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 20 }} animate={isInView ? { opacity: 1, y: 0 } : {}}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }} transition={{ duration: 0.5 }}
transition={{ duration: 0.5 }} >
> <h2 className="text-3xl font-bold tracking-tighter md:text-4xl lg:text-5xl">
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl"> {title.split(' ').map((word, i) =>
Testimoni{' '} i === title.split(' ').length - 1 ? (
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"> <span
Member key={i}
</span> className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"
</h2> >
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg"> {word}
Apa kata mereka yang telah bergabung dengan komunitas IMPHNEN? </span>
</p> ) : (
</motion.div> <span key={i}>{word} </span>
</div> )
)}
</h2>
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
{subtitle}
</p>
</motion.div>
<motion.div <motion.div
className="grid gap-8 md:grid-cols-3" className="grid gap-8 md:grid-cols-3"
@@ -89,98 +66,79 @@ export function Testimonials() {
initial="hidden" initial="hidden"
animate={isInView ? 'visible' : 'hidden'} animate={isInView ? 'visible' : 'hidden'}
> >
{testimonials.map((testimonial, index) => ( {items?.map((t, idx) => (
<motion.div <motion.div
key={index} key={idx}
className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-lg" className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-lg"
variants={itemVariants} variants={itemVariants}
> >
<div className="absolute top-6 right-6 text-primary/20 group-hover:text-primary/40 transition-colors"> <div className="absolute top-6 right-6 text-primary/20 group-hover:text-primary/40 transition-colors">
<QuoteIcon className="h-8 w-8" /> <QuoteIcon className="h-8 w-8" />
</div> </div>
<div className="relative z-10"> <div className="relative z-10">
<p className="mb-6 text-muted-foreground italic"> <p className="mb-6 text-muted-foreground italic">
&ldquo;{testimonial.quote}&rdquo; &ldquo;{t.quote}&rdquo;
</p> </p>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="relative h-12 w-12 overflow-hidden rounded-full border-2 border-primary/20"> <div className="relative h-12 w-12 overflow-hidden rounded-full border-2 border-primary/20">
<Image {(() => {
src={testimonial.avatar || '/placeholder.svg'} const media = formatCMSImageDataToMedia(t.avatar);
alt={testimonial.name} if (!media) return null;
width={48} return (
height={48} <Image
className="object-cover" src={media.url!}
/> alt={media.alt}
width={600}
height={500}
className="object-cover"
/>
);
})()}
</div> </div>
<div> <div>
<h4 className="font-bold">{testimonial.name}</h4> <h4 className="font-bold">{t.name}</h4>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{t.role}</p>
{testimonial.role}
</p>
</div> </div>
</div> </div>
</div> </div>
<div className="absolute -bottom-1 -left-1 w-20 h-20 bg-gradient-to-tr from-primary/10 to-transparent rounded-tr-full opacity-0 group-hover:opacity-100 transition-opacity duration-500" /> <div className="absolute -bottom-1 -left-1 w-20 h-20 bg-gradient-to-tr from-primary/10 to-transparent rounded-tr-full opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
</motion.div> </motion.div>
))} ))}
</motion.div> </motion.div>
{/* Testimonial Stats */}
<motion.div <motion.div
className="mt-20 rounded-xl overflow-hidden border bg-background/50 backdrop-blur-sm p-8 md:p-12" className="mt-20 rounded-xl overflow-hidden border bg-background/50 backdrop-blur-sm p-8 md:p-12"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }} animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: 0.3 }} transition={{ duration: 0.5, delay: 0.3 }}
> >
<div className="grid md:grid-cols-2 gap-8 items-center"> <div className="grid md:grid-cols-2 gap-8 items-center">
<div> <div>
<h3 className="text-2xl md:text-3xl font-bold mb-4"> <h3 className="text-2xl md:text-3xl font-bold mb-4">
Bergabunglah dengan{' '} {joinTitle.split(' ').map((word, i) =>
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"> i === 1 ? (
10,000+ <span
</span>{' '} key={i}
programmer Indonesia lainnya className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"
>
{word}
</span>
) : (
<span key={i}>{word} </span>
)
)}
</h3> </h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground">{joinText}</p>
Komunitas kami terus berkembang dengan programmer dari berbagai
latar belakang dan tingkat keahlian. Bersama-sama, kita belajar,
berbagi, dan tumbuh sebagai profesional.
</p>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="rounded-lg border p-4 text-center"> {stats?.map((s, idx) => (
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"> <div key={idx} className="rounded-lg border p-4 text-center">
98% <div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
{s.value}
</div>
<div className="text-sm text-muted-foreground">{s.label}</div>
</div> </div>
<div className="text-sm text-muted-foreground"> ))}
Tingkat Kemalasan
</div>
</div>
<div className="rounded-lg border p-4 text-center">
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
4.9/5
</div>
<div className="text-sm text-muted-foreground">
Rating Drama
</div>
</div>
<div className="rounded-lg border p-4 text-center">
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
85%
</div>
<div className="text-sm text-muted-foreground">
Mendapat Pekerjaan
</div>
</div>
<div className="rounded-lg border p-4 text-center">
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
24/7
</div>
<div className="text-sm text-muted-foreground">Yapping</div>
</div>
</div> </div>
</div> </div>
</motion.div> </motion.div>
+15 -8
View File
@@ -2,6 +2,7 @@ import { getGlobalsCommunitiesSection } from '../../services/get-globals-communi
import { getGlobalsFeaturesSection } from '../../services/get-globals-features-section'; import { getGlobalsFeaturesSection } from '../../services/get-globals-features-section';
import { getGlobalsHeroSection } from '../../services/get-globals-hero-section'; import { getGlobalsHeroSection } from '../../services/get-globals-hero-section';
import { getGlobalsLearningResourcesSection } from '../../services/get-globals-learning-resources-section'; import { getGlobalsLearningResourcesSection } from '../../services/get-globals-learning-resources-section';
import { getGlobalsTestimonialsSection } from '../../services/get-globals-testimonials-section';
import { CallToAction } from './_components/call-to-action'; import { CallToAction } from './_components/call-to-action';
import { Community } from './_components/community'; import { Community } from './_components/community';
import { Features } from './_components/features'; import { Features } from './_components/features';
@@ -12,13 +13,19 @@ import { Testimonials } from './_components/testimonials';
export const revalidate = 10; // Seconds export const revalidate = 10; // Seconds
export default async function Page() { export default async function Page() {
const [heroData, featuresData, communitiesData, learningResourcesData] = const [
await Promise.all([ heroData,
getGlobalsHeroSection(), featuresData,
getGlobalsFeaturesSection(), communitiesData,
getGlobalsCommunitiesSection(), learningResourcesData,
getGlobalsLearningResourcesSection(), testimonialsData,
]); ] = await Promise.all([
getGlobalsHeroSection(),
getGlobalsFeaturesSection(),
getGlobalsCommunitiesSection(),
getGlobalsLearningResourcesSection(),
getGlobalsTestimonialsSection(),
]);
return ( return (
<> <>
@@ -26,7 +33,7 @@ export default async function Page() {
<Features {...featuresData} /> <Features {...featuresData} />
<Community {...communitiesData} /> <Community {...communitiesData} />
<LearningResources {...learningResourcesData} /> <LearningResources {...learningResourcesData} />
<Testimonials /> <Testimonials {...testimonialsData} />
<CallToAction /> <CallToAction />
</> </>
); );
@@ -0,0 +1,93 @@
import { GlobalConfig } from 'payload';
export const TestimonialsSection: GlobalConfig = {
slug: 'testimonials-section',
label: 'Testimonials Section',
fields: [
{
name: 'title',
label: 'Section Title',
type: 'text',
defaultValue: 'Testimoni Member',
required: true,
},
{
name: 'subtitle',
label: 'Section Subtitle',
type: 'textarea',
defaultValue:
'Apa kata mereka yang telah bergabung dengan komunitas IMPHNEN?',
required: true,
},
{
name: 'items',
label: 'Testimonials Items',
type: 'array',
fields: [
{ name: 'quote', type: 'textarea', label: 'Quote', required: true },
{ name: 'name', type: 'text', label: 'Name', required: true },
{ name: 'role', type: 'text', label: 'Role', required: true },
{
name: 'avatar',
type: 'upload',
label: 'Avatar',
relationTo: 'media',
required: true,
},
],
defaultValue: [
{
quote:
'Saya yang tadinya tidak mengerti apa-apa tentang programming, sekarang bisa membuat website sendiri dengan mudah. Terima kasih IMPHNEN!',
name: 'Budi Santoso',
role: 'Web Developer Pemula',
avatar: { id: '', relationTo: 'media' },
},
{
quote:
'Komunitas yang sangat supportif! Setiap pertanyaan selalu dijawab dengan cepat dan jelas. Diskusi programnya mudah dipahami.',
name: 'Anita Ratna',
role: 'Mobile App Developer',
avatar: { id: '', relationTo: 'media' },
},
{
quote:
'Server Discord IMPHNEN adalah tempat belajar terbaik untuk programmer pemula seperti saya. Materinya lengkap dan komunitasnya sangat membantu!',
name: 'Dedi Permana',
role: 'Data Scientist',
avatar: { id: '', relationTo: 'media' },
},
],
},
{
name: 'stats',
label: 'Statistics',
type: 'array',
fields: [
{ name: 'value', type: 'text', label: 'Value', required: true },
{ name: 'label', type: 'text', label: 'Label', required: true },
],
defaultValue: [
{ value: '98%', label: 'Tingkat Kemalasan' },
{ value: '4.9/5', label: 'Rating Drama' },
{ value: '85%', label: 'Mendapat Pekerjaan' },
{ value: '24/7', label: 'Yapping' },
],
},
{
name: 'joinTitle',
label: 'Join Section Title',
type: 'text',
defaultValue: 'Bergabunglah dengan 10,000+ programmer Indonesia lainnya',
required: true,
},
{
name: 'joinText',
label: 'Join Section Text',
type: 'textarea',
defaultValue:
'Komunitas kami terus berkembang dengan programmer dari berbagai latar belakang dan tingkat keahlian. Bersama-sama, kita belajar, berbagi, dan tumbuh sebagai profesional.',
required: true,
},
],
};
+60
View File
@@ -89,12 +89,14 @@ export interface Config {
'features-section': FeaturesSection; 'features-section': FeaturesSection;
'communities-section': CommunitiesSection; 'communities-section': CommunitiesSection;
'learning-resources-section': LearningResourcesSection; 'learning-resources-section': LearningResourcesSection;
'testimonials-section': TestimonialsSection;
}; };
globalsSelect: { globalsSelect: {
'hero-section': HeroSectionSelect<false> | HeroSectionSelect<true>; 'hero-section': HeroSectionSelect<false> | HeroSectionSelect<true>;
'features-section': FeaturesSectionSelect<false> | FeaturesSectionSelect<true>; 'features-section': FeaturesSectionSelect<false> | FeaturesSectionSelect<true>;
'communities-section': CommunitiesSectionSelect<false> | CommunitiesSectionSelect<true>; 'communities-section': CommunitiesSectionSelect<false> | CommunitiesSectionSelect<true>;
'learning-resources-section': LearningResourcesSectionSelect<false> | LearningResourcesSectionSelect<true>; 'learning-resources-section': LearningResourcesSectionSelect<false> | LearningResourcesSectionSelect<true>;
'testimonials-section': TestimonialsSectionSelect<false> | TestimonialsSectionSelect<true>;
}; };
locale: null; locale: null;
user: User & { user: User & {
@@ -377,6 +379,35 @@ export interface LearningResourcesSection {
updatedAt?: string | null; updatedAt?: string | null;
createdAt?: string | null; createdAt?: string | null;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "testimonials-section".
*/
export interface TestimonialsSection {
id: number;
title: string;
subtitle: string;
items?:
| {
quote: string;
name: string;
role: string;
avatar: number | Media;
id?: string | null;
}[]
| null;
stats?:
| {
value: string;
label: string;
id?: string | null;
}[]
| null;
joinTitle: string;
joinText: string;
updatedAt?: string | null;
createdAt?: string | null;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "hero-section_select". * via the `definition` "hero-section_select".
@@ -483,6 +514,35 @@ export interface LearningResourcesSectionSelect<T extends boolean = true> {
createdAt?: T; createdAt?: T;
globalType?: T; globalType?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "testimonials-section_select".
*/
export interface TestimonialsSectionSelect<T extends boolean = true> {
title?: T;
subtitle?: T;
items?:
| T
| {
quote?: T;
name?: T;
role?: T;
avatar?: T;
id?: T;
};
stats?:
| T
| {
value?: T;
label?: T;
id?: T;
};
joinTitle?: T;
joinText?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth". * via the `definition` "auth".
+2
View File
@@ -13,6 +13,7 @@ import { FeaturesSection } from './collections/FeaturesSection';
import { HeroSection } from './collections/HeroSection'; import { HeroSection } from './collections/HeroSection';
import { LearningResourcesSection } from './collections/LearningResourcesSection'; import { LearningResourcesSection } from './collections/LearningResourcesSection';
import { Media } from './collections/Media'; import { Media } from './collections/Media';
import { TestimonialsSection } from './collections/TestimonialsSection';
import { Users } from './collections/Users'; import { Users } from './collections/Users';
const filename = fileURLToPath(import.meta.url); const filename = fileURLToPath(import.meta.url);
@@ -37,6 +38,7 @@ export default buildConfig({
FeaturesSection, FeaturesSection,
CommunitiesSection, CommunitiesSection,
LearningResourcesSection, LearningResourcesSection,
TestimonialsSection,
], ],
editor: lexicalEditor(), editor: lexicalEditor(),
secret: process.env.CMS_SECRET || '', secret: process.env.CMS_SECRET || '',
@@ -0,0 +1,7 @@
import { payload } from '../lib/payload';
export async function getGlobalsTestimonialsSection() {
return await payload.findGlobal({
slug: 'testimonials-section',
});
}