Skip to content

Commit bf55002

Browse files
feat: add sticky marketplace filter bar
1 parent 7b0ece9 commit bf55002

2 files changed

Lines changed: 141 additions & 10 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { useEffect, useState, type ReactNode } from 'react';
2+
import { cn } from '@/lib/utils';
3+
4+
interface StickyFilterBarProps {
5+
eyebrow?: string;
6+
title: string;
7+
description?: string;
8+
resultCount?: number;
9+
children: ReactNode;
10+
className?: string;
11+
}
12+
13+
const COMPACT_SCROLL_Y = 96;
14+
15+
const StickyFilterBar: React.FC<StickyFilterBarProps> = ({
16+
eyebrow = 'Filters',
17+
title,
18+
description,
19+
resultCount,
20+
children,
21+
className,
22+
}) => {
23+
const [isCompact, setIsCompact] = useState(() =>
24+
typeof window !== 'undefined' ? window.scrollY > COMPACT_SCROLL_Y : false
25+
);
26+
27+
useEffect(() => {
28+
if (typeof window === 'undefined') {
29+
return;
30+
}
31+
32+
let ticking = false;
33+
34+
const updateCompactState = () => {
35+
ticking = false;
36+
const nextIsCompact = window.scrollY > COMPACT_SCROLL_Y;
37+
setIsCompact(prev => (prev === nextIsCompact ? prev : nextIsCompact));
38+
};
39+
40+
const onScroll = () => {
41+
if (ticking) {
42+
return;
43+
}
44+
45+
ticking = true;
46+
window.requestAnimationFrame(updateCompactState);
47+
};
48+
49+
window.addEventListener('scroll', onScroll, { passive: true });
50+
51+
return () => {
52+
window.removeEventListener('scroll', onScroll);
53+
};
54+
}, []);
55+
56+
return (
57+
<div
58+
className={cn(
59+
'sticky top-4 z-20 mb-10 transition-all duration-300 md:top-6',
60+
className
61+
)}
62+
>
63+
<div
64+
className={cn(
65+
'relative overflow-hidden rounded-[1.75rem] border border-white/10 bg-slate-950/78 text-white shadow-[0_20px_80px_rgba(0,0,0,0.28)] backdrop-blur-xl transition-all duration-300',
66+
isCompact
67+
? 'px-4 py-4 md:px-5 md:py-4'
68+
: 'px-5 py-5 md:px-7 md:py-6'
69+
)}
70+
>
71+
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(135deg,rgba(255,255,255,0.08),rgba(255,255,255,0.02)_48%,rgba(245,158,11,0.08))]" />
72+
73+
<div className="relative flex flex-col gap-4">
74+
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
75+
<div className="min-w-0">
76+
<p className="text-[0.68rem] font-bold uppercase tracking-[0.28em] text-amber-300/85">
77+
{eyebrow}
78+
</p>
79+
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-2">
80+
<h2
81+
className={cn(
82+
'font-grotesque font-bold tracking-tight text-white transition-all duration-300',
83+
isCompact ? 'text-lg md:text-xl' : 'text-xl md:text-2xl'
84+
)}
85+
>
86+
{title}
87+
</h2>
88+
{typeof resultCount === 'number' && (
89+
<span className="inline-flex items-center rounded-full border border-white/10 bg-white/8 px-3 py-1 text-xs font-medium text-white/75">
90+
{resultCount} {resultCount === 1 ? 'result' : 'results'}
91+
</span>
92+
)}
93+
</div>
94+
{description && (
95+
<p
96+
className={cn(
97+
'mt-2 max-w-2xl text-sm text-white/62 transition-all duration-300',
98+
isCompact
99+
? 'max-h-0 overflow-hidden opacity-0'
100+
: 'max-h-20 opacity-100'
101+
)}
102+
>
103+
{description}
104+
</p>
105+
)}
106+
</div>
107+
</div>
108+
109+
<div
110+
className={cn(
111+
'flex flex-col gap-3 transition-all duration-300 md:flex-row md:items-center md:justify-between',
112+
isCompact ? 'gap-2' : 'gap-3'
113+
)}
114+
>
115+
<div className="w-full md:max-w-2xl">{children}</div>
116+
<span className="hidden text-xs font-medium uppercase tracking-[0.18em] text-white/45 md:inline-flex">
117+
{isCompact ? 'Filters stay pinned while you browse' : 'Scroll to keep filters in reach'}
118+
</span>
119+
</div>
120+
</div>
121+
</div>
122+
</div>
123+
);
124+
};
125+
126+
export default StickyFilterBar;

src/pages/LandingPage.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState, useEffect, useMemo } from 'react';
22
import { courseService, type Course } from '@/services/course.service';
33
import SearchBar from '@/components/common/SearchBar';
4+
import StickyFilterBar from '@/components/common/StickyFilterBar';
45
import CreatorCard from '@/components/common/CreatorCard';
56
import { CreatorGridSkeleton } from '@/components/common/CreatorSkeleton';
67
import EmptyState from '@/components/common/EmptyState';
@@ -125,19 +126,23 @@ function LandingPage() {
125126
<h1 className="mb-8 font-grotesque text-[clamp(2.5rem,8vw,5rem)] font-extrabold leading-[1.1] tracking-tight text-white">
126127
Access Layer
127128
</h1>
128-
129-
{/* Search Bar Integration */}
130-
<div className="mx-auto flex max-w-2xl justify-center">
131-
<SearchBar
132-
value={searchQuery}
133-
onChange={setSearchQuery}
134-
className="shadow-2xl shadow-black/20"
135-
/>
136-
</div>
137129
</header>
138130

131+
<StickyFilterBar
132+
eyebrow="Marketplace filters"
133+
title="Find creators without losing your place"
134+
description="Search by creator name or handle while you keep scrolling through the marketplace. The filter shell stays visible and compresses as you move deeper into the list."
135+
resultCount={filteredCreators.length}
136+
>
137+
<SearchBar
138+
value={searchQuery}
139+
onChange={setSearchQuery}
140+
className="max-w-none shadow-2xl shadow-black/20"
141+
/>
142+
</StickyFilterBar>
143+
139144
{/* Marketplace Grid Section */}
140-
<section className="mt-12">
145+
<section className="mt-2">
141146
{isLoading ? (
142147
<CreatorGridSkeleton count={6} />
143148
) : filteredCreators.length > 0 ? (

0 commit comments

Comments
 (0)