import {JSONContent} from '@tiptap/core' import clsx from 'clsx' import {INVERTED_DIET_CHOICES, INVERTED_LANGUAGE_CHOICES} from 'common/choices' import {Gender} from 'common/gender' import {CompatibilityScore} from 'common/profiles/compatibility-score' import {Profile} from 'common/profiles/profile' import {DisplayOptions} from 'common/profiles-rendering' import { Brain, Briefcase, Calendar, Cigarette, HandHeart, Languages, Salad, Sparkles, Wine, } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import React, {useEffect, useRef, useState} from 'react' import {PiMagnifyingGlassBold} from 'react-icons/pi' import GenderIcon from 'web/components/gender-icon' import {IconWithInfo} from 'web/components/icons' import {Row} from 'web/components/layout/row' import {ProfileLocation} from 'web/components/profile/profile-location' import {getSeekingText} from 'web/components/profile-about' import {CompatibleBadge} from 'web/components/widgets/compatible-badge' import {Content} from 'web/components/widgets/editor' import HideProfileButton from 'web/components/widgets/hide-profile-button' import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator' import {LoadMoreUntilNotVisible} from 'web/components/widgets/visibility-observer' import {useChoicesContext} from 'web/hooks/use-choices' import {isDark, useTheme} from 'web/hooks/use-theme' import {useUser} from 'web/hooks/use-user' import {useT} from 'web/lib/locale' import {getSeekingConnectionText} from 'web/lib/profile/seeking' import {capitalizePure} from 'web/lib/util/time' import {Col} from './layout/col' export const ProfileGrid = (props: { profiles: Profile[] loadMore: () => Promise isLoadingMore: boolean isReloading: boolean compatibilityScores: Record | undefined starredUserIds: string[] | undefined refreshStars: () => Promise onHide?: (userId: string) => void hiddenUserIds?: string[] onUndoHidden?: (userId: string) => void displayOptions?: Partial }) => { const { profiles, loadMore, isLoadingMore, isReloading, compatibilityScores, starredUserIds, refreshStars, onHide, hiddenUserIds, onUndoHidden, displayOptions, } = props const {cardSize} = displayOptions ?? {} const user = useUser() const t = useT() const other_profiles = profiles.filter((profile) => profile.user_id !== user?.id) const gridCols = { small: 'lg:grid-cols-2', medium: '', large: '', }[cardSize ?? 'medium'] return (
{other_profiles.map((profile) => ( ))}
{isLoadingMore && (
)} {user?.isBannedFromPosting ? (

You can't see profiles as you got banned.

) : ( !isLoadingMore && !isReloading && other_profiles.length === 0 && (

{t('profile_grid.no_profiles', 'No profiles found.')}

{t( 'profile_grid.notification_cta', "Feel free to click on Get Notified and we'll notify you when new users match your search!", )}

) )}
) } function ProfilePreview(props: { profile: Profile compatibilityScore: CompatibilityScore | undefined hasStar: boolean refreshStars: () => Promise onHide?: (userId: string) => void isHidden?: boolean onUndoHidden?: (userId: string) => void displayOptions?: Partial }) { const {profile, compatibilityScore, onHide, isHidden, onUndoHidden, displayOptions} = props const { showPhotos, showAge, showGender, showLanguages, showHeadline, showKeywords, showCity, showOccupation, showSeeking, showInterests, showCauses, showDiet, showSmoking, showDrinks, showMBTI, showBio, cardSize, } = displayOptions ?? {} const {user} = profile const choicesIdsToLabels = useChoicesContext() const t = useT() // const currentUser = useUser() const [isLoading, setIsLoading] = useState(false) const [showRing, setShowRing] = useState(false) const ringTimeoutRef = useRef(null) const pointerStartRef = useRef<{x: number; y: number} | null>(null) const hideButtonClickedRef = useRef(false) const {theme} = useTheme() const isDarkTheme = isDark(theme) const handlePointerDown = (e: React.PointerEvent) => { pointerStartRef.current = {x: e.clientX, y: e.clientY} } const handlePointerUp = (e: React.PointerEvent) => { if (pointerStartRef.current) { const dx = Math.abs(e.clientX - pointerStartRef.current.x) const dy = Math.abs(e.clientY - pointerStartRef.current.y) // Check if opening in new tab const isNewTab = e.button === 1 || e.ctrlKey || e.metaKey || e.shiftKey // Reset hide button click flag after checking const wasHideButtonClicked = hideButtonClickedRef.current hideButtonClickedRef.current = false // If moved more than 10px, treat as drag/scroll - cancel loading // Also cancel if clicking hide button or opening in new tab if (dx > 10 || dy > 10 || wasHideButtonClicked || isNewTab) { setIsLoading(false) setShowRing(false) if (ringTimeoutRef.current) { clearTimeout(ringTimeoutRef.current) ringTimeoutRef.current = null } } else { setIsLoading(true) ringTimeoutRef.current = setTimeout(() => { setShowRing(true) }, 500) } } pointerStartRef.current = null } const handleClick = () => {} // Show the bottom transparent gradient only if the text can't fit the card const textRef = useRef(null) const [isOverflowing, setIsOverflowing] = useState(false) useEffect(() => { const el = textRef.current if (!el) return const check = () => setIsOverflowing(el.scrollHeight > el.clientHeight) check() const ro = new ResizeObserver(check) ro.observe(el) return () => ro.disconnect() }, []) const bio = profile.bio as JSONContent // If this profile was just hidden, render a compact placeholder with Undo action. if (isHidden) { return (
{t( 'profile_grid.profile_hidden_short', "You won't see {name} in your search results anymore.", {name: user?.name}, )}
) } if (bio && bio.content) { const newBio = [] let i = 0 for (const c of bio.content) { if ((c?.content?.length || 0) == 0) continue if (c.type === 'paragraph') { newBio.push(c) } else if (['heading'].includes(c.type ?? '')) { newBio.push({ type: 'paragraph', content: c.content, }) } else if (c.type === 'image') { continue } else { newBio.push(c) } i += 1 if (i >= 5) break } bio.content = newBio } const seekingText = profile.pref_relation_styles?.length ? cardSize === 'large' ? getSeekingText(profile, t, true) : getSeekingConnectionText(profile, t, true) : null // if (!profile.work?.length && !profile.occupation_title && !profile.interests?.length && (profile.bio_length || 0) < 100) { // return null // } const isPhotoRendered = showPhotos !== false && profile.pinned_url const textHeightClass = { small: 'max-h-40', medium: 'max-h-60 lg:max-h-40', large: 'max-h-80', }[cardSize ?? 'medium'] const photoSizeClass = { small: 'w-40 h-auto min-h-24', medium: 'w-40 lg:w-40 h-auto lg:h-auto min-h-20', large: 'w-60 lg:w-60 h-60 lg:h-auto min-h-48', }[cardSize ?? 'medium'] const cardClass = { small: 'flex-row', medium: 'flex-row', large: 'flex-col', }[cardSize ?? 'medium'] return (
{/* Phase 1: Dim overlay */} {isLoading && (
)} {compatibilityScore && ( )} {onHide && ( { hideButtonClickedRef.current = true }} /> )}

{user.name}

{showCity !== false && } {showAge !== false && profile.age && ( } /> )} {showGender !== false && profile.gender && ( } /> )} {showHeadline !== false && profile.headline && (

"{profile.headline}"

)} {showKeywords !== false && !!profile.keywords?.length && ( {profile.keywords ?.slice(0, 10) ?.map(capitalizePure) ?.map((tag, i) => ( {tag.trim()} ))} )} {showSeeking !== false && seekingText && ( } /> )} {showOccupation !== false && profile.occupation_title && ( } /> )} {showInterests !== false && !!profile.interests?.length && ( choicesIdsToLabels['interests'][id]) .join(' • ')} icon={} /> )} {showCauses !== false && !!profile.causes?.length && ( choicesIdsToLabels['causes'][id]) .join(' • ')} icon={} /> )} {showDiet !== false && !!profile.diet?.length && ( t(`profile.diet.${e}`, INVERTED_DIET_CHOICES[e])) .join(' • ')} icon={} /> )} {showSmoking !== false && profile.is_smoker && ( } /> )} {showDrinks !== false && profile.drinks_per_month !== null && profile.drinks_per_month !== undefined && ( } /> )} {showMBTI !== false && profile.mbti && ( } /> )} {showLanguages !== false && !!profile.languages?.length && ( t(`profile.language.${v}`, INVERTED_LANGUAGE_CHOICES[v])) .join(' • ')} icon={} /> )} {showBio !== false && bio && (
)} {isOverflowing && (
)}
{isPhotoRendered && (
)}
{/* Phase 2: Animated ring - appears after 200ms */} {isLoading && showRing && (
)}
) }