Skip to content

Commit 6d0d277

Browse files
authored
feat: implement EmptyState component with GSAP animations (#257)
* feat: implement EmptyState component with GSAP animations - Formatted some files to pass pre commit checks - Replace Framer Motion with GSAP for smooth, modern animations - Add coordinated entry animations with staggered timing - Implement continuous floating animation for visual appeal - Support all empty state types (default, comment, campaign, user) - Enhance responsiveness across desktop, tablet, and mobile - Add proper accessibility with ARIA attributes - Create reusable, modular component architecture Features: - Smooth container fade-in animation (0.3s) - Image scale + rotation with bounce effect using back.out easing (0.6s) - Staggered text content animation for polished feel (0.5s) - Action button entrance animation (0.4s) - Continuous subtle floating motion (2s loop) - Fully responsive design with dynamic image sizing - Clean TypeScript implementation with comprehensive error handling Fixes #171 * fix: match EmptyState design exactly to Figma specifications
1 parent 3d8e184 commit 6d0d277

8 files changed

Lines changed: 9948 additions & 2569 deletions

File tree

app/test-empty-state/page.tsx

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
'use client';
2+
3+
import React, { useState } from 'react';
4+
import EmptyState from '@/components/EmptyState';
5+
import { Menu, ChevronDown, Search } from 'lucide-react';
6+
import {
7+
FaDiscord,
8+
FaTelegramPlane,
9+
FaGithub,
10+
FaLinkedin,
11+
FaTwitter,
12+
} from 'react-icons/fa';
13+
import { SiGmail } from 'react-icons/si';
14+
import Image from 'next/image';
15+
16+
export default function TestEmptyStatePage() {
17+
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
18+
19+
const handleAddProject = () => alert('Add Project clicked!');
20+
21+
const socialIcons = [
22+
{ Icon: FaDiscord, href: 'https://discord.com' },
23+
{ Icon: FaTelegramPlane, href: 'https://t.me' },
24+
{ Icon: FaGithub, href: 'https://github.com' },
25+
{ Icon: FaLinkedin, href: 'https://linkedin.com' },
26+
{ Icon: FaTwitter, href: 'https://x.com' },
27+
{ Icon: SiGmail, href: 'mailto:example@gmail.com' },
28+
];
29+
30+
return (
31+
<div className='min-h-screen bg-black'>
32+
{/* Header */}
33+
<div className='border-b border-gray-700 bg-black'>
34+
<div className='flex items-center justify-between px-4 py-4 sm:px-6'>
35+
<h1 className='text-lg font-bold text-white sm:text-xl'>
36+
Explore Boundless Projects
37+
</h1>
38+
<button
39+
className='p-2 text-gray-300 hover:text-white lg:hidden'
40+
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
41+
>
42+
<Menu size={20} />
43+
</button>
44+
</div>
45+
46+
{/* Controls */}
47+
<div className='px-4 pb-4 sm:px-6'>
48+
<div className='hidden items-center justify-between lg:flex'>
49+
<div className='flex items-center gap-3'>
50+
{['Sort', 'Status', 'Category'].map(label => (
51+
<button
52+
key={label}
53+
className='flex items-center gap-1 rounded border border-gray-600 px-3 py-2 text-gray-300 transition-colors hover:text-white'
54+
>
55+
<span className='text-sm'>{label}</span>
56+
<ChevronDown size={16} />
57+
</button>
58+
))}
59+
</div>
60+
<div className='relative'>
61+
<Search
62+
className='absolute top-1/2 left-3 -translate-y-1/2 transform text-gray-400'
63+
size={16}
64+
/>
65+
<input
66+
type='text'
67+
placeholder='Search project or creator...'
68+
className='w-80 rounded-lg border border-gray-600 bg-gray-800 py-2 pr-4 pl-10 text-white placeholder-gray-400 transition-colors focus:border-green-400 focus:outline-none'
69+
/>
70+
</div>
71+
</div>
72+
73+
{/* Mobile */}
74+
<div className='lg:hidden'>
75+
<div className='relative mb-4'>
76+
<Search
77+
className='absolute top-1/2 left-3 -translate-y-1/2 transform text-gray-400'
78+
size={16}
79+
/>
80+
<input
81+
type='text'
82+
placeholder='Search project or creator...'
83+
className='w-full rounded-lg border border-gray-600 bg-gray-800 py-2 pr-4 pl-10 text-white placeholder-gray-400 transition-colors focus:border-green-400 focus:outline-none'
84+
/>
85+
</div>
86+
87+
<div className={`${mobileMenuOpen ? 'block' : 'hidden'} space-y-2`}>
88+
<div className='flex flex-col gap-2 sm:flex-row'>
89+
{['Sort', 'Status', 'Category'].map(label => (
90+
<button
91+
key={label}
92+
className='flex w-full items-center justify-between rounded bg-gray-800 px-3 py-2 text-gray-300 transition-colors hover:text-white sm:w-auto'
93+
>
94+
<span className='text-sm'>{label}</span>
95+
<ChevronDown size={16} />
96+
</button>
97+
))}
98+
</div>
99+
</div>
100+
</div>
101+
</div>
102+
</div>
103+
104+
{/* Main */}
105+
<div className='flex min-h-96 flex-1 items-center justify-center p-4 sm:p-8'>
106+
<EmptyState
107+
title='No Projects'
108+
description='There are currently no projects here. Go ahead and create the first one!'
109+
buttonText='Add Project'
110+
onAddClick={handleAddProject}
111+
type='default'
112+
/>
113+
</div>
114+
115+
{/* Footer */}
116+
<div className='border-t border-gray-700 px-4 py-6 sm:px-6'>
117+
<div className='flex flex-col items-start justify-between gap-4 text-sm text-gray-400 lg:flex-row lg:items-center'>
118+
<div className='mb-2 flex items-center gap-2 lg:mb-0'>
119+
<Image
120+
src='/test_footer_logo.png'
121+
alt='Logo'
122+
width={40}
123+
height={40}
124+
/>
125+
</div>
126+
<div className='flex flex-col gap-2 sm:flex-row sm:gap-6'>
127+
<a href='#' className='transition-colors hover:text-white'>
128+
Terms of Service
129+
</a>
130+
<a href='#' className='transition-colors hover:text-white'>
131+
Privacy Policy
132+
</a>
133+
</div>
134+
</div>
135+
136+
<div className='flex flex-col items-start justify-between gap-4 pt-5 text-sm text-gray-400 lg:flex-row lg:items-center'>
137+
<span className='text-center leading-relaxed lg:text-left'>
138+
© 2023 Boundless — Transparent, Community-Driven, Web3-Native
139+
Funding
140+
</span>
141+
<div className='flex items-center gap-3 text-gray-600'>
142+
{socialIcons.map(({ Icon, href }, idx) => (
143+
<a
144+
key={idx}
145+
href={href}
146+
target='_blank'
147+
rel='noopener noreferrer'
148+
className='transition-colors hover:text-white'
149+
>
150+
<Icon className='h-5 w-5' />
151+
</a>
152+
))}
153+
</div>
154+
</div>
155+
</div>
156+
</div>
157+
);
158+
}

components/EmptyState.tsx

Lines changed: 93 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,107 @@
11
'use client';
2-
import React from 'react';
2+
3+
import React, { useEffect, useRef } from 'react';
4+
import { Plus } from 'lucide-react';
35
import Image from 'next/image';
4-
import { motion } from 'framer-motion';
5-
import { fadeInUp, scaleIn } from '@/lib/motion';
66

7-
interface EmptyStateProps {
8-
title: string;
9-
description: string;
7+
export interface EmptyStateProps {
8+
title?: string;
9+
description?: string;
10+
buttonText?: string;
11+
onAddClick?: () => void;
12+
className?: string;
13+
type?: 'default' | 'compact' | 'custom';
1014
action?: React.ReactNode;
11-
type: 'default' | 'comment';
1215
}
1316

14-
const EmptyState = ({
15-
title,
16-
description,
17-
action,
17+
const EmptyState: React.FC<EmptyStateProps> = ({
18+
title = 'No Projects',
19+
description = 'There are currently no projects here. Go ahead and create the first one!',
20+
buttonText = 'Add Project',
21+
onAddClick = () => {},
22+
className = '',
1823
type = 'default',
19-
}: EmptyStateProps) => {
24+
action,
25+
}) => {
26+
const containerRef = useRef<HTMLDivElement>(null);
27+
const titleRef = useRef<HTMLHeadingElement>(null);
28+
const descriptionRef = useRef<HTMLParagraphElement>(null);
29+
const buttonRef = useRef<HTMLButtonElement>(null);
30+
31+
useEffect(() => {
32+
const elements = [
33+
containerRef.current,
34+
titleRef.current,
35+
descriptionRef.current,
36+
buttonRef.current,
37+
];
38+
39+
elements.forEach((el, index) => {
40+
if (el) {
41+
el.style.opacity = '0';
42+
el.style.transform = 'translateY(30px)';
43+
setTimeout(() => {
44+
el.style.transition = 'all 0.6s ease-out';
45+
el.style.opacity = '1';
46+
el.style.transform = 'translateY(0)';
47+
}, index * 150);
48+
}
49+
});
50+
}, []);
51+
52+
const buttonStyle =
53+
type === 'compact'
54+
? 'px-4 py-2 text-sm'
55+
: type === 'custom'
56+
? 'px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white'
57+
: 'px-6 py-3';
58+
2059
return (
21-
<motion.div
22-
className='flex h-full flex-col items-center justify-center'
23-
initial='hidden'
24-
animate='visible'
25-
variants={fadeInUp}
60+
<div
61+
ref={containerRef}
62+
className={`flex min-h-[400px] flex-col items-center justify-center p-8 ${className}`}
2663
>
27-
<div className='flex flex-col items-center justify-center gap-6 text-center'>
28-
{type === 'default' && (
29-
<motion.div variants={scaleIn}>
30-
<Image
31-
src='/empty/default.svg'
32-
alt='Empty State'
33-
width={100}
34-
height={100}
35-
/>
36-
</motion.div>
37-
)}
38-
{type === 'comment' && (
39-
<motion.div variants={scaleIn}>
40-
<Image
41-
src='/empty/comment.svg'
42-
alt='Empty State'
43-
width={100}
44-
height={100}
45-
/>
46-
</motion.div>
47-
)}
48-
<motion.div className='space-y-1' variants={fadeInUp}>
49-
<h3 className='text-xl leading-[30px] text-white'>{title}</h3>
50-
<p className='text-sm leading-[145%] text-white/60'>{description}</p>
51-
</motion.div>
64+
{/* Illustration */}
65+
<div className='relative mb-8'>
66+
<Image
67+
src='/empty-state.png'
68+
alt='Empty state illustration'
69+
width={240}
70+
height={260}
71+
className='rounded-lg'
72+
/>
5273
</div>
53-
{action && (
54-
<motion.div className='mt-4' variants={fadeInUp}>
55-
{action}
56-
</motion.div>
74+
75+
{/* Title */}
76+
<h2
77+
ref={titleRef}
78+
className='mb-3 text-center text-2xl font-semibold text-white md:text-3xl'
79+
>
80+
{title}
81+
</h2>
82+
83+
{/* Description */}
84+
<p
85+
ref={descriptionRef}
86+
className='mb-8 max-w-md px-4 text-center leading-relaxed text-gray-400'
87+
>
88+
{description}
89+
</p>
90+
91+
{/* Button or custom action */}
92+
{action ? (
93+
action
94+
) : (
95+
<button
96+
ref={buttonRef}
97+
onClick={onAddClick}
98+
className={`focus:ring-opacity-50 flex w-auto transform items-center justify-center gap-2 rounded-[8px] bg-[#00D2A4] font-medium text-black shadow-[0_2px_8px_rgba(0,210,164,0.2)] transition-all duration-200 ease-out hover:bg-[#00B894] focus:ring-2 focus:ring-[#00D2A4] focus:outline-none ${buttonStyle}`}
99+
>
100+
<Plus size={18} />
101+
{buttonText}
102+
</button>
57103
)}
58-
</motion.div>
104+
</div>
59105
);
60106
};
61107

components/testimonials/TestimonialCard.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ReactNode } from 'react';
1+
import { ReactNode, useState } from 'react';
22
import Image from 'next/image';
33

44
interface TestimonialCardProps {
@@ -12,23 +12,26 @@ interface TestimonialCardProps {
1212

1313
const TestimonialCard: React.FC<TestimonialCardProps> = ({
1414
avatarSrc = '/avatar-placeholder.png',
15-
// avatarFallback = 'U',
15+
avatarFallback = '/avatar-placeholder.png',
1616
name,
1717
username,
1818
content,
1919
icon,
2020
}) => {
21+
const [imageError, setImageError] = useState(false);
22+
2123
return (
2224
<div className='mb-6 flex w-[300px] max-w-[300px] transform flex-col gap-4 rounded-[8px] border border-[#A7F950]/20 bg-[#101010] p-6 shadow-xl transition-all duration-500 hover:scale-105 hover:border-[#A7F950]/40 hover:shadow-[0_0_30px_rgba(167,249,80,0.3)]'>
2325
<div className='flex flex-row items-start justify-between'>
2426
<div className='flex items-center gap-3'>
2527
<div className='relative'>
2628
<Image
27-
src={avatarSrc}
29+
src={imageError ? avatarFallback : avatarSrc}
2830
alt={`${name} profile picture`}
2931
className='h-12 w-12 rounded-full object-cover ring-2 ring-[#A7F950]/30'
3032
width={48}
3133
height={48}
34+
onError={() => setImageError(true)}
3235
/>
3336
</div>
3437
<div className='flex flex-col items-baseline'>

0 commit comments

Comments
 (0)