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 {SendMessageButton} from 'web/components/messaging/send-message-button'
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 {StarButton} from 'web/components/widgets/star-button'
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 function ProfileGridSkeleton(props: {count?: number; className?: string}) {
const {count = 6, className} = props
return (
{Array.from({length: count}).map((_, i) => (
{/* name */}
{/* age / location */}
{/* headline */}
{/* keyword pills */}
{/* bio lines */}
{/* photo placeholder */}
))}
)
}
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,
hasStar,
refreshStars,
} = 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 [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},
)}
) => {
e.preventDefault()
e.stopPropagation()
onUndoHidden?.(profile.user_id)
}}
>
{t('profile_grid.undo', 'Undo')}
)
}
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
}}
/>
)}
{hasStar !== undefined && (
{
hideButtonClickedRef.current = true
}}
/>
)}
{user && (
{
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 && (
)}
)
}