๐Ÿ”

Filters & Search

Negen filterdimensies, full-text PostgreSQL search via pg_trgm, TanStack Query v5 debouncing en URL-state synchronisatie.

Fastify 5 PostgreSQL 16 + pg_trgm React 19 + TanStack Query v5 @teezu/ui

๐ŸŽ›๏ธ 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.