Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 | 6x 310x 310x 310x 310x 310x 310x 310x 310x 310x | /**
* SpeakerDisplay Component
* Reusable component for displaying speaker with profile picture and company logo
* Used in: SpeakerGrid, SessionCards, EventProgram
*/
import type { SessionSpeaker } from '@/types/event.types';
import { Building2 } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { useCompany } from '@/hooks/useCompany/useCompany';
import { useUserPortrait } from '@/hooks/useUserPortrait';
import { buildCdnImageUrl } from '@/utils/cdnImage';
interface SpeakerDisplayProps {
speaker: SessionSpeaker;
size?: 'small' | 'medium' | 'large';
showProfilePicture?: boolean;
className?: string;
/**
* Lazy-fetch the portrait from the public-user endpoint when no URL was supplied. Default true.
* Set false for non-speaker subjects (e.g. Q&A authors, who are attendees): the public-user
* endpoint is SPEAKER-scoped and 404s for them, so the fetch is pointless noise — they render
* with an initials avatar instead (feedback #16).
*/
lazyLoadPortrait?: boolean;
}
/**
* SpeakerDisplay Component
*
* Layout:
* [Profile Picture] [Speaker Name ] [Company Logo]
* [Company Name ]
*
* - Profile picture: Left-aligned, 80x80 (medium), 56x56 (small)
* - Speaker info: Middle section with name and company
* - Company logo: Right-aligned, same size as profile picture
*/
export const SpeakerDisplay = ({
speaker,
size = 'medium',
showProfilePicture = true,
className = '',
lazyLoadPortrait = true,
}: SpeakerDisplayProps) => {
// Trigger lazy portrait fetch once the component enters (or nears) the viewport.
// rootMargin '300px' pre-loads portraits just before they scroll into view.
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: '300px' });
// Only lazy-fetch when the server didn't already supply the URL (archive list path) AND the
// subject is a speaker (lazyLoadPortrait): the public-user endpoint is speaker-scoped (#16).
const { data: lazyPortraitUrl } = useUserPortrait(
speaker.username,
showProfilePicture && inView && !speaker.profilePictureUrl && lazyLoadPortrait
);
const effectivePortraitUrl = speaker.profilePictureUrl ?? lazyPortraitUrl ?? null;
// Use the logo URL pre-loaded by the backend cross-service join when available.
// Fall back to useCompany for contexts where companyLogoUrl is not provided (e.g. detail page).
// Passing '' disables useCompany's internal query (it guards on !!name).
const { data: company } = useCompany(speaker.companyLogoUrl ? '' : speaker.company || '', {
expand: ['logo'],
});
const logoUrl = speaker.companyLogoUrl ?? company?.logo?.url;
// Prefer the human-readable display name; fall back to the slug. `speaker.company`
// (the slug) stays the stable key for logo lookup above — only the label changes.
const companyLabel = speaker.companyDisplayName ?? speaker.company;
// Size mappings
const sizeClasses = {
small: {
avatar: 'h-12 w-12',
logoContainer: 'max-h-12 max-w-24', // Limit logo to 2x height (96px)
logoImage: 'max-h-12 max-w-full',
name: 'text-sm',
company: 'text-xs',
initials: 'text-base',
},
medium: {
avatar: 'h-16 w-16',
logoContainer: 'max-h-16 max-w-32', // Limit logo to 2x height (128px)
logoImage: 'max-h-16 max-w-full',
name: 'text-base',
company: 'text-sm',
initials: 'text-xl',
},
large: {
avatar: 'h-20 w-20',
logoContainer: 'max-h-20 max-w-40', // Limit logo to 2x height (160px)
logoImage: 'max-h-20 max-w-full',
name: 'text-lg',
company: 'text-base',
initials: 'text-2xl',
},
};
const sizes = sizeClasses[size];
return (
<div ref={ref} className={`flex items-center gap-3 ${className}`}>
{/* Profile Picture — shown immediately if URL is known; otherwise initials until lazy-loaded */}
{showProfilePicture && (
<div
className={`${sizes.avatar} rounded-full bg-zinc-800 flex items-center justify-center overflow-hidden flex-shrink-0`}
>
{effectivePortraitUrl ? (
<img
src={
buildCdnImageUrl(effectivePortraitUrl, { w: 160, h: 160, fit: 'cover' }) ??
undefined
}
alt={`${speaker.firstName} ${speaker.lastName}`}
className="h-full w-full object-cover"
width={80}
height={80}
loading="lazy"
data-testid="speaker-photo"
/>
) : (
<span className={`${sizes.initials} font-light text-zinc-300`}>
{speaker.firstName[0]}
{speaker.lastName[0]}
</span>
)}
</div>
)}
{/* Speaker Info (flex-grow to take available space) */}
<div className="flex-1 min-w-0 flex flex-col justify-center">
<div className={`${sizes.name} font-light text-zinc-100`} data-testid="speaker-name">
{speaker.firstName} {speaker.lastName}
</div>
{speaker.company && (
<div
className={`${sizes.company} text-zinc-400 flex items-center gap-1.5`}
data-testid="speaker-company"
>
<Building2 className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{companyLabel}</span>
</div>
)}
</div>
{/* Company Logo - Right aligned, max height with flexible width */}
{speaker.company && (
<div
className={`${sizes.logoContainer} flex items-center justify-center flex-shrink-0 p-1`}
>
{logoUrl ? (
<img
src={buildCdnImageUrl(logoUrl, { h: 128, fit: 'inside' }) ?? undefined}
alt={`${companyLabel} logo`}
className={`${sizes.logoImage} object-contain`}
width={128}
height={64}
loading="lazy"
/>
) : (
<Building2 className="h-8 w-8 text-zinc-400" />
)}
</div>
)}
</div>
);
};
|