๐๏ธ Filter Dimensies
Negen orthogonale filterdimensies. Elke combinatie is geldig. Filters worden als query-params in de URL gespiegeld voor shareability.
๐ญ Role
member
creator
provider
staff
โง Gender
man
vrouw
non-binary
anders
๐ณ๏ธโ๐ Sexuality
straight
bi
gay/les
pan
๐ฏ Intent
casual
entertainment
connection
fantasy
๐ Location
radius in km
25 km
๐ Mood
๐ spicy
๐ flirty
๐ chill
๐ playful
๐ค Consent Level
all-ages
18+ soft
18+ explicit
๐ค AI Usage
human only
AI-assisted
AI Teezer
๐ Member Tier
free
premium
VIP
diamond
๐ Full-Text Search โ PostgreSQL pg_trgm
Migratie & Index
-- migrations/0025_pg_trgm.sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- GIN index voor snelle trigram search
CREATE INDEX idx_users_name_trgm
ON users USING GIN (display_name gin_trgm_ops);
CREATE INDEX idx_users_bio_trgm
ON users USING GIN (bio gin_trgm_ops);
-- Gecombineerde zoekkolom (voor รฉรฉn index-scan)
ALTER TABLE users
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('dutch', coalesce(display_name,'') || ' '
|| coalesce(bio,'') || ' '
|| coalesce(location_city,''))
) STORED;
CREATE INDEX idx_users_fts ON users USING GIN (search_vector);
Fastify 5 query
// GET /api/users?q=lena&role=creator
fastify.get('/users', async (req) => {
const { q, role, mood, radius, page=1, limit=20 } = req.query;
let query = db.select().from(users);
if (q) {
query = query.where(
sql`search_vector @@ plainto_tsquery('dutch', ${q})
OR display_name % ${q}` // trigram fuzzy fallback
).orderBy(
sql`ts_rank(search_vector,
plainto_tsquery('dutch', ${q})) DESC`
);
}
if (role) query = query.where(eq(users.role, role));
if (mood) query = query.where(eq(users.currentMood, mood));
const offset = (page - 1) * limit;
return query.limit(limit).offset(offset);
});
โก TanStack Query v5 โ Debounced Filtering
useFilteredUsers hook
// hooks/useFilteredUsers.ts
import { useQuery } from '@tanstack/react-query';
import { useDebounce } from '@teezu/ui';
export function useFilteredUsers(filters: FilterState) {
// Debounce alle filter-changes (300 ms)
const debouncedFilters = useDebounce(filters, 300);
return useQuery({
queryKey: ['users', 'filtered', debouncedFilters],
queryFn: () => fetchUsers(debouncedFilters),
placeholderData: keepPreviousData, // v5 API
staleTime: 60_000,
});
}
function fetchUsers(f: FilterState) {
const params = new URLSearchParams(
Object.entries(f).filter(([,v]) => v != null) as any
);
return fetch(`/api/users?${params}`).then(r => r.json());
}
URL State โ filter shareability
// hooks/useFilterURLSync.ts
import { useSearchParams } from 'react-router-dom';
export function useFilterURLSync() {
const [params, setParams] = useSearchParams();
const filters = {
role: params.get('role') ?? undefined,
gender: params.get('gender') ?? undefined,
mood: params.get('mood') ?? undefined,
consentLevel: params.get('consent') ?? undefined,
radius: Number(params.get('radius')) || 50,
memberTier: params.get('tier') ?? undefined,
aiUsage: params.get('ai') ?? undefined,
};
const setFilter = (key: string, value: string | null) => {
setParams(prev => {
const next = new URLSearchParams(prev);
value ? next.set(key, value) : next.delete(key);
return next;
}, { replace: true }); // geen history-entry per filter-klik
};
return { filters, setFilter };
}
URL voorbeeld: /discover?role=creator&mood=spicy&radius=25&consent=18%2B+soft
๐งฉ @teezu/ui โ Filter Components
๐
FilterBar
Horizontale balk met chips voor alle actieve filters. Desktop layout (sticky top).
<FilterBar
filters={activeFilters}
onRemove={setFilter}
onClear={clearAll}
/>
๐ท๏ธ
FilterChip
Individuele chip met label + remove-knop. Active/inactive state.
<FilterChip
label="creator"
active={true}
onToggle={() => setFilter('role','creator')}
/>
creator โ
spicy
๐ฑ
FilterDrawer
Bottom sheet op mobiel. Alle 9 dimensies in scrollable lijst. Sluit met swipe-down.
<FilterDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
filters={activeFilters}
onChange={setFilter}
/>
๐ฆ Import:
import { FilterBar, FilterChip, FilterDrawer } from '@teezu/ui'; โ packages/ui in de monorepo.
๐ ๏ธ API Endpoint
GET
/api/users
Ondersteunt elke combinatie van filterdimensies
Query parameters
rolemember | creator | provider | staff
genderman | vrouw | non-binary | anders
sexualitystraight | bi | gay | pan
intentcasual | entertainment | fantasy
moodspicy | flirty | chill | playful
radiusgetal in km (vereist locatie-toestemming)
consentall-ages | 18+soft | 18+explicit
aihuman | assisted | teezer
tierfree | premium | vip | diamond
qfull-text zoekterm (pg_trgm)
page / limitdefault 1 / 20
Voorbeeld request
GET /api/users?role=creator&mood=spicy&radius=25&consent=18%2Bsoft&q=lena&page=1&limit=20
Response shape
{ data: User[], total: number, page: number, limit: number, hasMore: boolean }
โ Best Practices
โ
Debounce op 300 ms โ Voorkom API-hammering bij snelle filter-wijzigingen. Gebruik
useDebounce uit @teezu/ui.
โ
URL state via useSearchParams โ Filters zijn altijd shareable en bookmarkable. Gebruik
replace: true om history-flood te voorkomen.
โ
keepPreviousData (TanStack Query v5) โ Toon vorige resultaten terwijl nieuwe fetch loopt. Voorkomt layout-shift.
โ
GIN index op pg_trgm โ Verplicht voor fuzzy search op grote datasets. Zonder index: full table scan.
โ
FilterDrawer op mobiel โ Gebruik
FilterDrawer (bottom sheet) in plaats van FilterBar op schermen < 768 px. Detecteer via CSS breakpoint of useMediaQuery.
โ
Consent-level filter verplicht โ Toon nooit 18+ explicit content aan users zonder verified age (>18). Server-side enforced, niet alleen frontend.