a a vibe-coded abomination experiment of a fragrance review platform built on the atmosphere. drydown.social

adding fragrance pages

+979 -82
+8
src/app.tsx
··· 11 11 import { HousePage } from './components/HousePage' 12 12 import { EditHousePage } from './components/EditHousePage' 13 13 import { ProfileHousesPage } from './components/ProfileHousesPage' 14 + import { ProfileFragrancesPage } from './components/ProfileFragrancesPage' 15 + import { FragrancePage } from './components/FragrancePage' 14 16 import { SettingsPage } from './components/SettingsPage' 15 17 import { Header } from './components/Header' 16 18 import { Footer } from './components/Footer' ··· 202 204 <Route path="/profile/:handle/houses"> 203 205 {(params) => <ProfileHousesPage handle={params.handle} session={session} userProfile={userProfile} onLogout={handleLogout} />} 204 206 </Route> 207 + <Route path="/profile/:handle/fragrances"> 208 + {(params) => <ProfileFragrancesPage handle={params.handle} session={session} userProfile={userProfile} onLogout={handleLogout} />} 209 + </Route> 205 210 <Route path="/profile/:handle/review/:rkey"> 206 211 {(params) => <SingleReviewPage handle={params.handle} rkey={params.rkey} session={session} userProfile={userProfile} onLogout={handleLogout} />} 207 212 </Route> ··· 210 215 </Route> 211 216 <Route path="/profile/:handle/house/:rkey/edit"> 212 217 {(params) => <EditHousePage handle={params.handle} rkey={params.rkey} session={session} userProfile={userProfile} onLogout={handleLogout} />} 218 + </Route> 219 + <Route path="/profile/:handle/fragrance/:rkey"> 220 + {(params) => <FragrancePage handle={params.handle} rkey={params.rkey} session={session} userProfile={userProfile} onLogout={handleLogout} />} 213 221 </Route> 214 222 {/* Fallback to Home for now, or 404 */} 215 223 <Route path="/:rest*">
+324
src/components/FragrancePage.tsx
··· 1 + import { useState, useEffect } from 'preact/hooks' 2 + import { useLocation } from 'wouter' 3 + import { HapticLink } from './HapticLink' 4 + import type { OAuthSession } from '@atproto/oauth-client-browser' 5 + import { SEO } from './SEO' 6 + import { Header } from './Header' 7 + import { Footer } from './Footer' 8 + import { ReviewList, type FragranceInfo } from './ReviewList' 9 + import { resolveIdentity } from '../utils/resolveIdentity' 10 + import { resolveAtUri, parseAtUri } from '../utils/atUriUtils' 11 + import { cache, TTL } from '../services/cache' 12 + import { calculateWeightedScore, decodeWeightedScore } from '../utils/reviewUtils' 13 + import type { AuthorInfo } from './ReviewCard' 14 + import { useUserPreferences } from '../hooks/useUserPreferences' 15 + 16 + const MICROCOSM_API = "https://ufos-api.microcosm.blue" 17 + 18 + interface FragrancePageProps { 19 + handle: string 20 + rkey: string 21 + session: OAuthSession | null 22 + userProfile?: { displayName?: string; handle: string } | null 23 + onLogout?: () => void 24 + } 25 + 26 + export function FragrancePage({ handle, rkey, session, userProfile, onLogout }: FragrancePageProps) { 27 + const [, setLocation] = useLocation() 28 + const [fragranceName, setFragranceName] = useState<string>('Loading Fragrance...') 29 + const [houseName, setHouseName] = useState<string | null>(null) 30 + const [houseHandle, setHouseHandle] = useState<string | null>(null) 31 + const [houseRkey, setHouseRkey] = useState<string | null>(null) 32 + const [year, setYear] = useState<number | null>(null) 33 + const [reviews, setReviews] = useState<Array<{ uri: string; value: any }>>([]) 34 + const [fragranceMap, setFragranceMap] = useState<Map<string, FragranceInfo>>(new Map()) 35 + const [reviewers, setReviewers] = useState<Map<string, AuthorInfo>>(new Map()) 36 + const [isLoading, setIsLoading] = useState(true) 37 + const [error, setError] = useState<string | null>(null) 38 + const [manager, setManager] = useState<{ handle: string, displayName?: string, avatar?: string } | null>(null) 39 + const [fragranceDid, setFragranceDid] = useState<string | null>(null) 40 + const { preferences } = useUserPreferences(session) 41 + 42 + // Analytics 43 + const [totalRating, setTotalRating] = useState<number>(0) 44 + const [avgProjection, setAvgProjection] = useState<number>(0) 45 + const [avgSillage, setAvgSillage] = useState<number>(0) 46 + const [avgComplexity, setAvgComplexity] = useState<number>(0) 47 + const [totalReviews, setTotalReviews] = useState<number>(0) 48 + 49 + useEffect(() => { 50 + async function loadFragranceData() { 51 + try { 52 + setIsLoading(true) 53 + setError(null) 54 + 55 + // 1. Resolve identity to get fragrance DID and fetch the fragrance record 56 + const { did: fragranceAuthorDid, profileData } = await resolveIdentity(handle) 57 + 58 + setFragranceDid(fragranceAuthorDid) 59 + 60 + setManager({ 61 + handle: profileData?.handle || handle, 62 + displayName: profileData?.displayName, 63 + avatar: profileData?.avatar 64 + }) 65 + 66 + // Fetch fragrance using cross-PDS resolution 67 + const fragranceUri = `at://${fragranceAuthorDid}/social.drydown.fragrance/${rkey}` 68 + const fragranceData = await resolveAtUri(fragranceUri) 69 + 70 + if (!fragranceData) { 71 + setError("Fragrance not found or is private") 72 + setIsLoading(false) 73 + return 74 + } 75 + 76 + const currentFragranceName = fragranceData.name || 'Unknown Fragrance' 77 + setFragranceName(currentFragranceName) 78 + setYear(fragranceData.year || null) 79 + 80 + // Cache the fragrance 81 + cache.set(`fragrance:${fragranceUri}`, fragranceData, TTL.FRAGRANCE) 82 + 83 + // 2. Fetch house record if present 84 + if (fragranceData.house) { 85 + const houseData = await resolveAtUri(fragranceData.house) 86 + if (houseData) { 87 + setHouseName(houseData.name) 88 + 89 + // Parse house URI for linking 90 + const parsedHouse = parseAtUri(fragranceData.house) 91 + if (parsedHouse) { 92 + // Resolve house handle for URL 93 + const { profileData: houseProfile } = await resolveIdentity(parsedHouse.did) 94 + setHouseHandle(houseProfile.handle) 95 + setHouseRkey(parsedHouse.rkey) 96 + } 97 + } 98 + } 99 + 100 + // Build fragrance map with this single fragrance 101 + const tempFragranceMap = new Map<string, FragranceInfo>() 102 + const parsedFragranceUri = parseAtUri(fragranceUri) 103 + tempFragranceMap.set(fragranceUri, { 104 + name: currentFragranceName, 105 + houseName: houseName || 'Unknown House', 106 + handle: handle, 107 + rkey: parsedFragranceUri?.rkey 108 + }) 109 + 110 + // 3. Fetch all reviews globally and filter by this fragrance 111 + const rRes = await fetch(`${MICROCOSM_API}/records?collection=social.drydown.review&limit=2000`) 112 + if (!rRes.ok) throw new Error("Failed to fetch reviews from Microcosm") 113 + const allReviews = await rRes.json() 114 + 115 + const validReviews = allReviews 116 + .filter((r: any) => r.record && r.record.fragrance === fragranceUri) 117 + .map((r: any) => ({ 118 + uri: `at://${r.did}/${r.collection}/${r.rkey}`, 119 + value: r.record 120 + })) 121 + .sort((a: any, b: any) => new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()) 122 + 123 + setReviews(validReviews) 124 + setTotalReviews(validReviews.length) 125 + setFragranceMap(tempFragranceMap) 126 + 127 + // 4. Calculate aggregates 128 + let totalScore = 0 129 + let projectionSum = 0 130 + let sillageSum = 0 131 + let complexitySum = 0 132 + let count = 0 133 + 134 + validReviews.forEach((review: any) => { 135 + // Calculate weighted score 136 + const score = review.value.weightedScore 137 + ? decodeWeightedScore(review.value.weightedScore) 138 + : calculateWeightedScore(review.value) 139 + totalScore += score 140 + 141 + // Aggregate other metrics 142 + const projection = review.value.midProjection ?? review.value.openingProjection ?? 0 143 + projectionSum += projection 144 + 145 + sillageSum += review.value.sillage ?? 0 146 + complexitySum += review.value.complexity ?? 0 147 + 148 + count++ 149 + }) 150 + 151 + setTotalRating(count > 0 ? Math.round((totalScore / count) * 10) / 10 : 0) 152 + setAvgProjection(count > 0 ? Math.round((projectionSum / count) * 10) / 10 : 0) 153 + setAvgSillage(count > 0 ? Math.round((sillageSum / count) * 10) / 10 : 0) 154 + setAvgComplexity(count > 0 ? Math.round((complexitySum / count) * 10) / 10 : 0) 155 + 156 + // 5. Resolve reviewer identities 157 + const uniqueReviewerDids = [...new Set(validReviews.map((r: any) => { 158 + const parsed = parseAtUri(r.uri) 159 + return parsed?.did 160 + }).filter(Boolean))] as string[] 161 + 162 + const reviewersMap = new Map<string, AuthorInfo>() 163 + await Promise.all(uniqueReviewerDids.map(async (did: string) => { 164 + try { 165 + const { profileData } = await resolveIdentity(did) 166 + reviewersMap.set(did, { 167 + handle: profileData?.handle || did, 168 + displayName: profileData?.displayName, 169 + avatar: profileData?.avatar 170 + }) 171 + } catch (e) { 172 + console.error(`Failed to resolve reviewer ${did}`, e) 173 + reviewersMap.set(did, { handle: did }) 174 + } 175 + })) 176 + setReviewers(reviewersMap) 177 + 178 + } catch (err) { 179 + console.error("Fragrance page error:", err) 180 + setError("Could not load fragrance data. Please check the URL and try again.") 181 + } finally { 182 + setIsLoading(false) 183 + } 184 + } 185 + 186 + if (handle && rkey) { 187 + loadFragranceData() 188 + } 189 + }, [handle, rkey]) 190 + 191 + if (isLoading) { 192 + return <div className="page-container">Loading Fragrance...</div> 193 + } 194 + 195 + if (error) { 196 + return <div className="container error">{error}</div> 197 + } 198 + 199 + const reviewersArray = Array.from(reviewers.values()) 200 + 201 + return ( 202 + <div className="fragrance-page page-container"> 203 + <SEO 204 + title={`${fragranceName} - Drydown`} 205 + description={`Read ${totalReviews} reviews for ${fragranceName} on Drydown.`} 206 + url={window.location.href} 207 + /> 208 + 209 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 210 + 211 + <header style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.25rem' }}> 212 + <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', width: '100%' }}> 213 + <h1>{fragranceName}</h1> 214 + {session && session.sub === fragranceDid && ( 215 + <HapticLink 216 + href={`/profile/${handle}/fragrance/${rkey}/edit`} 217 + className="interactive" 218 + style={{ fontSize: '0.9rem', textDecoration: 'none', padding: '0.2rem 0.5rem', border: '1px solid var(--border-color)', borderRadius: '4px' }} 219 + > 220 + Edit Fragrance 221 + </HapticLink> 222 + )} 223 + </div> 224 + {houseName && houseHandle && houseRkey && ( 225 + <h2 style={{ fontSize: '1.2rem', fontWeight: 'normal', margin: '0.25rem 0' }}> 226 + <HapticLink href={`/profile/${houseHandle}/house/${houseRkey}`}> 227 + {houseName} 228 + </HapticLink> 229 + {year && ` (${year})`} 230 + </h2> 231 + )} 232 + {manager && ( 233 + <div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.9rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}> 234 + <span>Created by</span> 235 + <HapticLink href={`/profile/${manager.handle}/reviews`} className="interactive" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', textDecoration: 'none', color: 'inherit', fontWeight: 'bold' }}> 236 + {manager.avatar && ( 237 + <img src={manager.avatar} alt="" style={{ width: '20px', height: '20px', borderRadius: '50%', objectFit: 'cover' }} /> 238 + )} 239 + {manager.displayName || `@${manager.handle}`} 240 + </HapticLink> 241 + </div> 242 + )} 243 + </header> 244 + 245 + {/* Reviewers List */} 246 + {reviewersArray.length > 0 && ( 247 + <div style={{ marginBottom: '2rem' }}> 248 + <div style={{ fontSize: '0.9rem', opacity: 0.7, marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}> 249 + Reviewed By 250 + </div> 251 + <div className="fragrance-reviewers" style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}> 252 + {reviewersArray.map(author => ( 253 + <HapticLink 254 + key={author.handle} 255 + href={`/profile/${author.handle}/reviews`} 256 + title={author.displayName ? `${author.displayName} (@${author.handle})` : `@${author.handle}`} 257 + style={{ display: 'block', borderRadius: '50%', overflow: 'hidden', width: '32px', height: '32px' }} 258 + > 259 + <span className="sr-only">Profile of {author.displayName || author.handle}</span> 260 + {author.avatar ? ( 261 + <img 262 + src={author.avatar} 263 + alt="" 264 + style={{ width: '100%', height: '100%', objectFit: 'cover' }} 265 + /> 266 + ) : ( 267 + <div style={{ width: '100%', height: '100%', backgroundColor: 'var(--card-bg, #222)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '14px', color: 'var(--text-color, #fff)' }}> 268 + {(author.displayName || author.handle).charAt(0).toUpperCase()} 269 + </div> 270 + )} 271 + </HapticLink> 272 + ))} 273 + </div> 274 + </div> 275 + )} 276 + 277 + {/* Aggregate Metrics */} 278 + <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))', gap: '1rem', marginBottom: '2.5rem' }}> 279 + <div className="score-item"> 280 + <div className="score-label">Total Reviews</div> 281 + <div className="score-value">{totalReviews}</div> 282 + </div> 283 + <div className="score-item"> 284 + <div className="score-label">Avg Rating</div> 285 + <div className="score-value">{totalReviews > 0 ? `${totalRating.toFixed(1)} ★` : 'N/A'}</div> 286 + </div> 287 + <div className="score-item"> 288 + <div className="score-label">Avg Projection</div> 289 + <div className="score-value">{avgProjection > 0 ? `${avgProjection.toFixed(1)}/5` : 'N/A'}</div> 290 + </div> 291 + <div className="score-item"> 292 + <div className="score-label">Avg Sillage</div> 293 + <div className="score-value">{avgSillage > 0 ? `${avgSillage.toFixed(1)}/5` : 'N/A'}</div> 294 + </div> 295 + <div className="score-item"> 296 + <div className="score-label">Avg Complexity</div> 297 + <div className="score-value">{avgComplexity > 0 ? `${avgComplexity.toFixed(1)}/5` : 'N/A'}</div> 298 + </div> 299 + </div> 300 + 301 + <h2>Reviews for {fragranceName}</h2> 302 + {reviews.length === 0 ? ( 303 + <p>No reviews found for this fragrance.</p> 304 + ) : ( 305 + <ReviewList 306 + reviews={reviews} 307 + fragrances={fragranceMap} 308 + reviewers={reviewers} 309 + onReviewClick={(review) => { 310 + const parsed = parseAtUri(review.uri) 311 + if (parsed) { 312 + const authorHandle = reviewers.get(parsed.did)?.handle || parsed.did 313 + setLocation(`/profile/${authorHandle}/review/${parsed.rkey}`) 314 + } 315 + }} 316 + viewerPreferences={preferences || undefined} 317 + viewerDid={session?.sub} 318 + /> 319 + )} 320 + 321 + <Footer session={session} /> 322 + </div> 323 + ) 324 + }
+77 -7
src/components/HousePage.tsx
··· 5 5 import { SEO } from './SEO' 6 6 import { Header } from './Header' 7 7 import { Footer } from './Footer' 8 - import { ReviewList } from './ReviewList' 8 + import { ReviewList, type FragranceInfo } from './ReviewList' 9 9 import { resolveIdentity } from '../utils/resolveIdentity' 10 10 import { resolveAtUri } from '../utils/atUriUtils' 11 11 import { cache, TTL } from '../services/cache' ··· 27 27 const [, setLocation] = useLocation() 28 28 const [houseName, setHouseName] = useState<string>('Loading House...') 29 29 const [reviews, setReviews] = useState<Array<{ uri: string; value: any }>>([]) 30 - const [fragrances, setFragrances] = useState<Map<string, { name: string, houseName?: string }>>(new Map()) 30 + const [fragrances, setFragrances] = useState<Map<string, FragranceInfo>>(new Map()) 31 31 const [reviewers, setReviewers] = useState<Map<string, AuthorInfo>>(new Map()) 32 32 const [isLoading, setIsLoading] = useState(true) 33 33 const [error, setError] = useState<string | null>(null) ··· 76 76 77 77 setTotalFragrances(houseFragranceUris.size) 78 78 79 - const fragranceMap = new Map<string, { name: string, houseName?: string }>() 79 + // Resolve handles for all fragrances to enable linking 80 + const fragranceHandles = new Map<string, string>() 81 + await Promise.all( 82 + houseFragrances.map(async (f: any) => { 83 + try { 84 + const { profileData } = await resolveIdentity(f.did) 85 + fragranceHandles.set(f.did, profileData.handle) 86 + } catch (e) { 87 + console.error(`Failed to resolve handle for fragrance author ${f.did}`, e) 88 + } 89 + }) 90 + ) 91 + 92 + // Temporary fragrance map for immediate use (will be updated with review counts later) 93 + const fragranceMap = new Map<string, FragranceInfo>() 80 94 houseFragrances.forEach((f: any) => { 81 95 const uri = `at://${f.did}/${f.collection}/${f.rkey}` 82 - fragranceMap.set(uri, { name: f.record.name, houseName: currentHouseName }) 83 - cache.set(`fragrance:${uri}`, f.record, TTL.FRAGRANCE) 96 + fragranceMap.set(uri, { 97 + name: f.record.name, 98 + houseName: currentHouseName, 99 + handle: fragranceHandles.get(f.did), 100 + rkey: f.rkey, 101 + reviewCount: 0 // Will be updated after counting reviews 102 + }) 84 103 }) 85 104 setFragrances(fragranceMap) 86 105 ··· 106 125 uri: `at://${r.did}/${r.collection}/${r.rkey}`, 107 126 value: r.record 108 127 })) 109 - 128 + 110 129 // Sort by recency 111 130 formattedReviews.sort((a: any, b: any) => new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()) 112 - 131 + 113 132 setReviews(formattedReviews) 114 133 setTotalReviews(formattedReviews.length) 134 + 135 + // Count reviews per fragrance 136 + const fragranceReviewCounts = new Map<string, number>() 137 + formattedReviews.forEach((review: any) => { 138 + const fragUri = review.value.fragrance 139 + fragranceReviewCounts.set( 140 + fragUri, 141 + (fragranceReviewCounts.get(fragUri) || 0) + 1 142 + ) 143 + }) 144 + 145 + // Update fragrance map with review counts 146 + const updatedFragranceMap = new Map<string, FragranceInfo>() 147 + houseFragrances.forEach((f: any) => { 148 + const uri = `at://${f.did}/${f.collection}/${f.rkey}` 149 + updatedFragranceMap.set(uri, { 150 + name: f.record.name, 151 + houseName: currentHouseName, 152 + handle: fragranceHandles.get(f.did), 153 + rkey: f.rkey, 154 + reviewCount: fragranceReviewCounts.get(uri) || 0 155 + }) 156 + cache.set(`fragrance:${uri}`, f.record, TTL.FRAGRANCE) 157 + }) 158 + setFragrances(updatedFragranceMap) 115 159 116 160 // Calculate aggregates 117 161 let sumRating = 0 ··· 291 335 <div className="score-value">{avgComplexity > 0 ? `${avgComplexity.toFixed(1)}/5` : 'N/A'}</div> 292 336 </div> 293 337 </div> 338 + 339 + {/* Fragrances List */} 340 + {totalFragrances > 0 && ( 341 + <div style={{ marginBottom: '2.5rem' }}> 342 + <h2>Fragrances from {houseName}</h2> 343 + <div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '0.75rem' }}> 344 + {Array.from(fragrances.entries()).map(([uri, fragInfo]) => ( 345 + fragInfo.handle && fragInfo.rkey && ( 346 + <HapticLink 347 + key={uri} 348 + href={`/profile/${fragInfo.handle}/fragrance/${fragInfo.rkey}`} 349 + className="review-card interactive" 350 + style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem', marginBottom: 0, textDecoration: 'none', color: 'inherit' }} 351 + > 352 + <div style={{ fontSize: '1.1rem', fontWeight: 500 }}>{fragInfo.name}</div> 353 + {fragInfo.reviewCount !== undefined && ( 354 + <div style={{ opacity: 0.7, fontSize: '0.9rem' }}> 355 + {fragInfo.reviewCount} {fragInfo.reviewCount === 1 ? 'Review' : 'Reviews'} 356 + </div> 357 + )} 358 + </HapticLink> 359 + ) 360 + ))} 361 + </div> 362 + </div> 363 + )} 294 364 295 365 <h2>Reviews for {houseName}</h2> 296 366 {reviews.length === 0 ? (
+391
src/components/ProfileFragrancesPage.tsx
··· 1 + import { useState, useEffect } from 'preact/hooks' 2 + import { HapticLink } from './HapticLink' 3 + import type { OAuthSession } from '@atproto/oauth-client-browser' 4 + import { AtpBaseClient } from '../client/index' 5 + import { SEO } from './SEO' 6 + import { Header } from './Header' 7 + import { Footer } from './Footer' 8 + import { TabBar } from './TabBar' 9 + import { EmptyProfileState } from './EmptyProfileState' 10 + import { resolveIdentity } from '../utils/resolveIdentity' 11 + import { batchResolveAtUris, parseAtUri } from '../utils/atUriUtils' 12 + import { cache } from '../services/cache' 13 + import { getReviewDisplayScore } from '../utils/reviewUtils' 14 + import { useInviteButton } from '../hooks/useInviteButton' 15 + import { useService } from '../contexts/ServiceContext' 16 + import { DEFAULT_SERVICE } from '../config/services' 17 + 18 + interface ProfileFragrancesPageProps { 19 + handle: string 20 + session: OAuthSession | null 21 + userProfile?: { displayName?: string; handle: string } | null 22 + onLogout?: () => void 23 + } 24 + 25 + interface FragranceStat { 26 + uri: string 27 + name: string 28 + houseName: string 29 + reviewCount: number 30 + totalScore: number 31 + avgScore: number 32 + } 33 + 34 + type FragranceFilter = 'all' | 'maintained' 35 + 36 + export function ProfileFragrancesPage({ handle, session, userProfile, onLogout }: ProfileFragrancesPageProps) { 37 + const { userService } = useService() 38 + const { invite } = useInviteButton() 39 + const [fragrances, setFragrances] = useState<FragranceStat[]>([]) 40 + const [isLoading, setIsLoading] = useState(true) 41 + const [profile, setProfile] = useState<{ displayName?: string, handle: string, did: string, avatar?: string } | null>(null) 42 + const [error, setError] = useState<string | null>(null) 43 + const [fragranceFilter, setFragranceFilter] = useState<FragranceFilter>('all') 44 + const [isLikelyNonUser, setIsLikelyNonUser] = useState(false) 45 + 46 + useEffect(() => { 47 + async function loadProfileAndFragrances() { 48 + try { 49 + setIsLoading(true) 50 + setError(null) 51 + 52 + // 1. Resolve identity 53 + const { did, pdsUrl, profileData } = await resolveIdentity(handle) 54 + 55 + // Create client for the PDS 56 + const pdsClient = new AtpBaseClient(async (url, init) => { 57 + const reqUrl = new URL(url, pdsUrl) 58 + const res = await fetch(reqUrl, init) 59 + return res 60 + }) 61 + 62 + setProfile({ 63 + displayName: profileData.displayName, 64 + handle: profileData.handle, 65 + did: did, 66 + avatar: profileData.avatar 67 + }) 68 + 69 + // Fetch reviews, fragrances, and houses in parallel 70 + const [reviewRecords, fragranceRecords, houseRecords] = await Promise.all([ 71 + pdsClient.social.drydown.review.list({ repo: did }), 72 + pdsClient.social.drydown.fragrance.list({ repo: did }), 73 + pdsClient.social.drydown.house.list({ repo: did }), 74 + ]) 75 + 76 + // Detect if user has ANY drydown data 77 + const isNonUser = 78 + reviewRecords.records.length === 0 && 79 + fragranceRecords.records.length === 0 && 80 + houseRecords.records.length === 0 81 + 82 + setIsLikelyNonUser(isNonUser) 83 + 84 + // Collect all fragrance URIs from reviews 85 + const reviewedFragranceUris = [ 86 + ...new Set( 87 + reviewRecords.records 88 + .map(r => (r.value as any).fragrance) 89 + .filter(Boolean) 90 + ) 91 + ] as string[] 92 + 93 + // Pre-fetch all fragrances using batch resolution (automatically populates cache) 94 + if (reviewedFragranceUris.length > 0) { 95 + await batchResolveAtUris(reviewedFragranceUris) 96 + } 97 + 98 + // Collect all house URIs from fragrances 99 + const allFragranceUris = [...new Set([ 100 + ...fragranceRecords.records.map(f => f.uri), 101 + ...reviewedFragranceUris 102 + ])] 103 + 104 + const houseUris = [ 105 + ...new Set( 106 + allFragranceUris 107 + .map(uri => { 108 + const fragData = cache.get(`fragrance:${uri}`) 109 + return fragData ? (fragData as any).house : null 110 + }) 111 + .filter(Boolean) 112 + ) 113 + ] as string[] 114 + 115 + // Pre-fetch all houses using batch resolution 116 + if (houseUris.length > 0) { 117 + await batchResolveAtUris(houseUris) 118 + } 119 + 120 + // Map fragrance URI -> name and house name 121 + const fragranceMap = new Map<string, { name: string, houseName: string }>( 122 + allFragranceUris.map(uri => { 123 + const fragData = cache.get(`fragrance:${uri}`) 124 + if (!fragData) { 125 + return [uri, { name: 'Unknown Fragrance', houseName: 'Unknown House' }] 126 + } 127 + 128 + const houseUri = (fragData as any).house 129 + const houseName = houseUri && cache.get(`house:${houseUri}`) 130 + ? (cache.get(`house:${houseUri}`) as any).name 131 + : 'Unknown House' 132 + 133 + return [uri, { name: (fragData as any).name, houseName }] 134 + }) 135 + ) 136 + 137 + // Aggregate review counts and scores by fragrance URI 138 + const fragranceCounts = new Map<string, FragranceStat>() 139 + 140 + for (const review of reviewRecords.records) { 141 + const fragUri = (review.value as any).fragrance 142 + const fragInfo = fragranceMap.get(fragUri) 143 + const score = getReviewDisplayScore(review.value) 144 + 145 + if (fragInfo) { 146 + const current = fragranceCounts.get(fragUri) || { 147 + uri: fragUri, 148 + name: fragInfo.name, 149 + houseName: fragInfo.houseName, 150 + reviewCount: 0, 151 + totalScore: 0, 152 + avgScore: 0 153 + } 154 + current.reviewCount++ 155 + current.totalScore += score 156 + current.avgScore = current.totalScore / current.reviewCount 157 + fragranceCounts.set(fragUri, current) 158 + } 159 + } 160 + 161 + const sortedFragrances = Array.from(fragranceCounts.values()).sort((a, b) => b.reviewCount - a.reviewCount) 162 + setFragrances(sortedFragrances) 163 + 164 + } catch (e) { 165 + console.error('Failed to load profile fragrances data', e) 166 + setError('Failed to load profile. Please check the handle and try again.') 167 + } finally { 168 + setIsLoading(false) 169 + } 170 + } 171 + 172 + if (handle) { 173 + loadProfileAndFragrances() 174 + } 175 + }, [handle]) 176 + 177 + if (isLoading) { 178 + return <div class="page-container">Loading profile...</div> 179 + } 180 + 181 + if (error) { 182 + return <div class="container error">{error}</div> 183 + } 184 + 185 + if (!profile) { 186 + return <div class="container">Profile not found</div> 187 + } 188 + 189 + // Invite handler for non-users 190 + const handleInvite = () => { 191 + if (profile) { 192 + invite(profile.handle) 193 + } 194 + } 195 + 196 + return ( 197 + <div class="profile-page page-container"> 198 + <SEO 199 + title={`Fragrances by ${profile.displayName || profile.handle} (@${profile.handle}) - Drydown`} 200 + description={`See fragrances reviewed by ${profile.displayName || profile.handle} on Drydown.`} 201 + url={window.location.href} 202 + /> 203 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 204 + 205 + <header style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}> 206 + {profile.avatar && ( 207 + <img 208 + src={profile.avatar} 209 + alt={profile.displayName || profile.handle} 210 + className="profile-avatar" 211 + style={{ width: '80px', height: '80px', borderRadius: '50%', objectFit: 'cover' }} 212 + /> 213 + )} 214 + <div> 215 + <h1>{profile.displayName || profile.handle}</h1> 216 + <div style={{ opacity: 0.7, fontSize: '1.2rem', marginTop: '0.25rem', textAlign: 'right' }}> 217 + <a 218 + href={(userService || DEFAULT_SERVICE).profileUrl(profile.handle, profile.did)} 219 + target="_blank" 220 + rel="noopener noreferrer" 221 + style={{ 222 + textDecoration: 'underline', 223 + textDecorationColor: 'rgba(255, 255, 255, 0.3)', 224 + color: 'inherit', 225 + display: 'inline-flex', 226 + alignItems: 'center', 227 + gap: '0.25rem' 228 + }} 229 + > 230 + @{profile.handle} 231 + <svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ display: 'inline-block' }}> 232 + <path d="M12 8.66667V12.6667C12 13.0203 11.8595 13.3594 11.6095 13.6095C11.3594 13.8595 11.0203 14 10.6667 14H3.33333C2.97971 14 2.64057 13.8595 2.39052 13.6095C2.14048 13.3594 2 13.0203 2 12.6667V5.33333C2 4.97971 2.14048 4.64057 2.39052 4.39052C2.64057 4.14048 2.97971 4 3.33333 4H7.33333" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 233 + <path d="M10 2H14V6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 234 + <path d="M6.66667 9.33333L14 2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 235 + </svg> 236 + </a> 237 + </div> 238 + </div> 239 + </header> 240 + 241 + {isLikelyNonUser ? ( 242 + <EmptyProfileState 243 + profileHandle={profile.handle} 244 + profileDid={profile.did} 245 + isLikelyNonUser={isLikelyNonUser} 246 + onInviteClick={handleInvite} 247 + /> 248 + ) : ( 249 + <> 250 + <TabBar 251 + tabs={[ 252 + { label: 'Reviews', href: `/profile/${handle}/reviews` }, 253 + { label: 'Houses', href: `/profile/${handle}/houses` }, 254 + { label: 'Fragrances' }, 255 + ]} 256 + /> 257 + 258 + {fragrances.length > 0 && (() => { 259 + // Calculate stats 260 + const totalFragrances = fragrances.length 261 + const maintainedFragrances = fragrances.filter(fragrance => { 262 + const parsed = parseAtUri(fragrance.uri) 263 + return parsed && parsed.did === profile.did 264 + }).length 265 + 266 + let favoriteFragrance = 'N/A' 267 + let favoriteScore = 0 268 + let leastFavoriteFragrance = 'N/A' 269 + let leastFavoriteScore = Infinity 270 + 271 + for (const fragrance of fragrances) { 272 + if (fragrance.avgScore > favoriteScore) { 273 + favoriteScore = fragrance.avgScore 274 + favoriteFragrance = fragrance.name 275 + } 276 + if (fragrance.avgScore < leastFavoriteScore) { 277 + leastFavoriteScore = fragrance.avgScore 278 + leastFavoriteFragrance = fragrance.name 279 + } 280 + } 281 + 282 + // Helper function to render visual star ratings 283 + const renderStars = (score: number, maxStars: number = 5): string => { 284 + const roundedScore = Math.round(score) 285 + const fullStars = roundedScore 286 + const emptyStars = maxStars - fullStars 287 + return '★'.repeat(fullStars) + '☆'.repeat(emptyStars) 288 + } 289 + 290 + return ( 291 + <div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '1rem', marginBottom: '2rem' }}> 292 + <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}> 293 + <div class="score-item"> 294 + <div className="score-label">Total Fragrances Reviewed</div> 295 + <div className="score-value">{totalFragrances}</div> 296 + </div> 297 + <div class="score-item"> 298 + <div className="score-label">Fragrances Maintained</div> 299 + <div className="score-value">{maintainedFragrances}</div> 300 + </div> 301 + </div> 302 + <div class="score-item"> 303 + <div className="score-label">Favorite Fragrance</div> 304 + <div className="score-value"> 305 + <div style={{ fontSize: '1.1rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> 306 + {favoriteFragrance} 307 + </div> 308 + {favoriteScore > 0 && ( 309 + <div style={{ fontSize: '0.9rem', opacity: 0.6, marginTop: '0.25rem' }}> 310 + {renderStars(favoriteScore)} ({favoriteScore.toFixed(1)}) 311 + </div> 312 + )} 313 + </div> 314 + </div> 315 + <div class="score-item"> 316 + <div className="score-label">Least Favorite Fragrance</div> 317 + <div className="score-value"> 318 + <div style={{ fontSize: '1.1rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> 319 + {leastFavoriteFragrance} 320 + </div> 321 + {leastFavoriteScore < Infinity && ( 322 + <div style={{ fontSize: '0.9rem', opacity: 0.6, marginTop: '0.25rem' }}> 323 + {renderStars(leastFavoriteScore)} ({leastFavoriteScore.toFixed(1)}) 324 + </div> 325 + )} 326 + </div> 327 + </div> 328 + </div> 329 + ) 330 + })()} 331 + 332 + <h2>Fragrances Reviewed</h2> 333 + 334 + <div className="house-filter"> 335 + <button 336 + className={`house-filter-button ${fragranceFilter === 'all' ? 'active' : ''}`} 337 + onClick={() => setFragranceFilter('all')} 338 + > 339 + All 340 + </button> 341 + <button 342 + className={`house-filter-button ${fragranceFilter === 'maintained' ? 'active' : ''}`} 343 + onClick={() => setFragranceFilter('maintained')} 344 + > 345 + Maintained 346 + </button> 347 + </div> 348 + {(() => { 349 + const filteredFragrances = fragrances.filter(fragrance => { 350 + if (fragranceFilter === 'maintained') { 351 + const parsed = parseAtUri(fragrance.uri) 352 + return parsed && parsed.did === profile.did 353 + } 354 + return true // 'all' shows everything 355 + }) 356 + 357 + return filteredFragrances.length === 0 ? ( 358 + <p>No {fragranceFilter === 'maintained' ? 'maintained ' : ''}fragrances found.</p> 359 + ) : ( 360 + <div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '1rem' }}> 361 + {filteredFragrances.map(fragrance => { 362 + const parsed = parseAtUri(fragrance.uri) 363 + if (!parsed) return null 364 + 365 + return ( 366 + <HapticLink 367 + key={fragrance.uri} 368 + href={`/profile/${handle}/fragrance/${parsed.rkey}`} 369 + className="review-card interactive" 370 + style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1.5rem', marginBottom: 0, textDecoration: 'none', color: 'inherit' }} 371 + > 372 + <div> 373 + <div style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>{fragrance.name}</div> 374 + <div style={{ fontSize: '0.9rem', opacity: 0.7, marginTop: '0.25rem' }}>{fragrance.houseName}</div> 375 + </div> 376 + <div style={{ opacity: 0.7 }}> 377 + {fragrance.reviewCount} {fragrance.reviewCount === 1 ? 'Review' : 'Reviews'} 378 + </div> 379 + </HapticLink> 380 + ) 381 + })} 382 + </div> 383 + ) 384 + })()} 385 + </> 386 + )} 387 + 388 + <Footer /> 389 + </div> 390 + ) 391 + }
+11 -7
src/components/ProfileHousesPage.tsx
··· 221 221 tabs={[ 222 222 { label: 'Reviews', href: `/profile/${handle}/reviews` }, 223 223 { label: 'Houses' }, 224 + { label: 'Fragrances', href: `/profile/${handle}/fragrances` }, 224 225 ]} 225 226 /> 226 227 ··· 330 331 {filteredHouses.map(house => { 331 332 const rkey = house.uri.split('/').pop() 332 333 return ( 333 - <HapticLink key={house.uri} href={`/profile/${handle}/house/${rkey}`} style={{ textDecoration: 'none', color: 'inherit' }}> 334 - <div class="review-card interactive" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1.5rem', marginBottom: 0 }}> 335 - <div style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>{house.name}</div> 336 - <div style={{ opacity: 0.7 }}> 337 - {house.reviewCount} {house.reviewCount === 1 ? 'Review' : 'Reviews'} 338 - </div> 339 - </div> 334 + <HapticLink 335 + key={house.uri} 336 + href={`/profile/${handle}/house/${rkey}`} 337 + className="review-card interactive" 338 + style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1.5rem', marginBottom: 0, textDecoration: 'none', color: 'inherit' }} 339 + > 340 + <div style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>{house.name}</div> 341 + <div style={{ opacity: 0.7 }}> 342 + {house.reviewCount} {house.reviewCount === 1 ? 'Review' : 'Reviews'} 343 + </div> 340 344 </HapticLink> 341 345 ) 342 346 })}
+75 -31
src/components/ProfilePage.tsx
··· 6 6 import { SEO } from './SEO' 7 7 import { Header } from './Header' 8 8 import { Footer } from './Footer' 9 - import { ReviewList } from './ReviewList' 9 + import { ReviewList, type FragranceInfo } from './ReviewList' 10 10 import { TabBar } from './TabBar' 11 11 import { EmptyProfileState } from './EmptyProfileState' 12 12 import { resolveIdentity } from '../utils/resolveIdentity' 13 - import { batchResolveAtUris } from '../utils/atUriUtils' 13 + import { batchResolveAtUris, parseAtUri } from '../utils/atUriUtils' 14 14 import { cache, TTL } from '../services/cache' 15 15 import { getReviewDisplayScore, type UserPreferencesForScoring } from '../utils/reviewUtils' 16 16 import { useUserPreferences } from '../hooks/useUserPreferences' ··· 42 42 const { userService } = useService() 43 43 const { invite } = useInviteButton() 44 44 const [reviews, setReviews] = useState<Array<{ uri: string; value: any }>>([]) 45 - const [fragrances, setFragrances] = useState<Map<string, { name: string, houseName?: string }>>(new Map()) 45 + const [fragrances, setFragrances] = useState<Map<string, FragranceInfo>>(new Map()) 46 46 const [isLoading, setIsLoading] = useState(true) 47 47 const [profile, setProfile] = useState<{ displayName?: string, handle: string, did: string, avatar?: string } | null>(null) 48 48 const [error, setError] = useState<string | null>(null) ··· 96 96 cache.set(`fragrance:${f.uri}`, f.value, TTL.FRAGRANCE) 97 97 }) 98 98 99 - // Batch-prefetch and cache house records in parallel 99 + // Extract all unique fragrance URIs (local + cross-PDS from reviews) 100 + const allFragranceUris = [ 101 + ...new Set([ 102 + ...fragranceRecords.records.map(f => f.uri), 103 + ...reviewRecords.records 104 + .map(r => (r.value as any).fragrance) 105 + .filter(Boolean) 106 + ]) 107 + ] as string[] 108 + 109 + // Extract house URIs from local fragrances 100 110 const houseUris = [ 101 111 ...new Set( 102 112 fragranceRecords.records ··· 105 115 ) 106 116 ] as string[] 107 117 108 - // Pre-fetch all houses using batch resolution (automatically populates cache) 109 - if (houseUris.length > 0) { 110 - await batchResolveAtUris(houseUris) 111 - } 118 + // Batch-prefetch fragrances AND houses in parallel (automatically populates cache) 119 + await Promise.all([ 120 + allFragranceUris.length > 0 ? batchResolveAtUris(allFragranceUris) : Promise.resolve(), 121 + houseUris.length > 0 ? batchResolveAtUris(houseUris) : Promise.resolve() 122 + ]) 123 + 124 + // Resolve handles for all fragrances to enable linking 125 + const fragranceHandles = new Map<string, string>() 126 + await Promise.all( 127 + allFragranceUris.map(async (uri) => { 128 + const parsed = parseAtUri(uri) 129 + if (parsed) { 130 + try { 131 + const { profileData } = await resolveIdentity(parsed.did) 132 + fragranceHandles.set(uri, profileData.handle) 133 + } catch (e) { 134 + console.error(`Failed to resolve handle for fragrance ${uri}`, e) 135 + } 136 + } 137 + }) 138 + ) 112 139 113 - const fragranceMap = new Map( 114 - fragranceRecords.records.map(f => { 115 - const houseUri = (f.value as any).house 116 - const houseName = houseUri && cache.get(`house:${houseUri}`) 117 - ? (cache.get(`house:${houseUri}`) as any).name 140 + // Build fragrance map from cache (supports cross-PDS fragrances) 141 + const fragranceMap = new Map<string, FragranceInfo>( 142 + allFragranceUris.map(uri => { 143 + const fragData = cache.get(`fragrance:${uri}`) 144 + const parsed = parseAtUri(uri) 145 + 146 + if (!fragData) { 147 + return [uri, { name: 'Unknown Fragrance', houseName: 'Unknown House' }] 148 + } 149 + 150 + const houseUri = (fragData as any).house 151 + const houseName = houseUri && cache.get(`house:${houseUri}`) 152 + ? (cache.get(`house:${houseUri}`) as any).name 118 153 : 'Unknown House' 119 - return [f.uri, { name: (f.value as any).name, houseName }] 154 + 155 + return [uri, { 156 + name: (fragData as any).name, 157 + houseName, 158 + handle: fragranceHandles.get(uri), 159 + rkey: parsed?.rkey 160 + }] 120 161 }) 121 162 ) 122 163 setFragrances(fragranceMap) ··· 161 202 totalScore: number 162 203 reviewCount: number 163 204 mostRecentDate: string 164 - maxScore: number // Highest single review score 205 + avgScore: number // Average score across all reviews 165 206 } 166 207 167 208 let totalRating = 0 ··· 178 219 if (fragInfo) { 179 220 const current = fragranceStats.get(fragUri) 180 221 if (current) { 222 + const newTotalScore = current.totalScore + score 223 + const newReviewCount = current.reviewCount + 1 181 224 fragranceStats.set(fragUri, { 182 225 name: fragInfo.name, 183 - totalScore: current.totalScore + score, 184 - reviewCount: current.reviewCount + 1, 226 + totalScore: newTotalScore, 227 + reviewCount: newReviewCount, 185 228 mostRecentDate: review.value.createdAt > current.mostRecentDate 186 229 ? review.value.createdAt 187 230 : current.mostRecentDate, 188 - maxScore: Math.max(current.maxScore, score) // Track highest score 231 + avgScore: newTotalScore / newReviewCount // Calculate average 189 232 }) 190 233 } else { 191 234 fragranceStats.set(fragUri, { ··· 193 236 totalScore: score, 194 237 reviewCount: 1, 195 238 mostRecentDate: review.value.createdAt, 196 - maxScore: score // Initialize with first score 239 + avgScore: score // Initialize with first score 197 240 }) 198 241 } 199 242 } ··· 201 244 202 245 avgRating = Math.round((totalRating / numReviews) * 10) / 10 203 246 204 - // Find favorite fragrance by highest single review score (with recency tiebreaker) 205 - let bestMaxScore = 0 247 + // Find favorite fragrance by highest average score (with recency tiebreaker) 248 + let bestAvgScore = 0 206 249 let bestDate = '' 207 250 for (const [_, stats] of fragranceStats.entries()) { 208 - if (stats.maxScore > bestMaxScore || 209 - (stats.maxScore === bestMaxScore && stats.mostRecentDate > bestDate)) { 210 - bestMaxScore = stats.maxScore 251 + if (stats.avgScore > bestAvgScore || 252 + (stats.avgScore === bestAvgScore && stats.mostRecentDate > bestDate)) { 253 + bestAvgScore = stats.avgScore 211 254 bestDate = stats.mostRecentDate 212 255 favoriteFragrance = stats.name 213 - favoriteScore = stats.maxScore 256 + favoriteScore = stats.avgScore 214 257 } 215 258 } 216 259 217 - // Find least favorite fragrance by lowest single review score (with recency tiebreaker) 218 - let worstMaxScore = Infinity 260 + // Find least favorite fragrance by lowest average score (with recency tiebreaker) 261 + let worstAvgScore = Infinity 219 262 let worstDate = '' 220 263 for (const [_, stats] of fragranceStats.entries()) { 221 - if (stats.maxScore < worstMaxScore || 222 - (stats.maxScore === worstMaxScore && stats.mostRecentDate > worstDate)) { 223 - worstMaxScore = stats.maxScore 264 + if (stats.avgScore < worstAvgScore || 265 + (stats.avgScore === worstAvgScore && stats.mostRecentDate > worstDate)) { 266 + worstAvgScore = stats.avgScore 224 267 worstDate = stats.mostRecentDate 225 268 leastFavoriteFragrance = stats.name 226 - leastFavoriteScore = stats.maxScore 269 + leastFavoriteScore = stats.avgScore 227 270 } 228 271 } 229 272 } ··· 300 343 tabs={[ 301 344 { label: 'Reviews' }, 302 345 { label: 'Houses', href: `/profile/${handle}/houses` }, 346 + { label: 'Fragrances', href: `/profile/${handle}/fragrances` }, 303 347 ]} 304 348 /> 305 349
+18 -2
src/components/ReviewCard.tsx
··· 1 1 import { useWebHaptics } from 'web-haptics/react' 2 + import { HapticLink } from './HapticLink' 2 3 import { getReviewActionState, getPersonalizedScore, getReviewDisplayScore, type UserPreferencesForScoring } from '../utils/reviewUtils' 3 4 4 5 export interface AuthorInfo { ··· 11 12 review: { uri: string; value: any } 12 13 fragranceName: string 13 14 houseName?: string 15 + fragranceHandle?: string 16 + fragranceRkey?: string 14 17 author?: AuthorInfo 15 18 status: 'active' | 'past' 16 19 onClick?: () => void ··· 18 21 viewerDid?: string 19 22 } 20 23 21 - export function ReviewCard({ review, fragranceName, houseName, author, status, onClick, viewerPreferences, viewerDid }: ReviewCardProps) { 24 + export function ReviewCard({ review, fragranceName, houseName, fragranceHandle, fragranceRkey, author, status, onClick, viewerPreferences, viewerDid }: ReviewCardProps) { 22 25 const { value } = review 23 26 const { hint } = getReviewActionState(value) 24 27 const { trigger } = useWebHaptics() ··· 39 42 } 40 43 } 41 44 45 + const hasFragranceLink = fragranceHandle && fragranceRkey 46 + 42 47 return ( 43 48 <div 44 49 onClick={handleClick} ··· 67 72 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}> 68 73 <div> 69 74 <h4 className="review-card-title"> 70 - {fragranceName} 75 + {hasFragranceLink ? ( 76 + <div onClick={(e: any) => e.stopPropagation()}> 77 + <HapticLink 78 + href={`/profile/${fragranceHandle}/fragrance/${fragranceRkey}`} 79 + style={{ color: 'inherit', textDecoration: 'none' }} 80 + > 81 + {fragranceName} 82 + </HapticLink> 83 + </div> 84 + ) : ( 85 + fragranceName 86 + )} 71 87 </h4> 72 88 {houseName && ( 73 89 <div style={{ fontSize: '0.85rem', opacity: 0.8, marginTop: '2px' }}>
+47 -33
src/components/ReviewList.tsx
··· 2 2 import { ReviewCard, type AuthorInfo } from './ReviewCard' 3 3 import { categorizeReviews, calculateWeightedScore, type UserPreferencesForScoring } from '../utils/reviewUtils' 4 4 5 + export interface FragranceInfo { 6 + name: string 7 + houseName?: string 8 + handle?: string 9 + rkey?: string 10 + reviewCount?: number 11 + } 12 + 5 13 interface ReviewListProps { 6 14 reviews: Array<{ uri: string; value: any }> 7 - fragrances: Map<string, { name: string; houseName?: string }> 15 + fragrances: Map<string, FragranceInfo> 8 16 reviewers?: Map<string, AuthorInfo> 9 17 onReviewClick: (review: { uri: string; value: any }) => void 10 18 viewerPreferences?: UserPreferencesForScoring ··· 34 42 localStorage.setItem('drydown_review_sort_order', sortOrder) 35 43 }, [sortBy, sortOrder]) 36 44 37 - const getFragranceName = (fragranceUri: string) => { 38 - return fragrances.get(fragranceUri)?.name || 'Unknown Fragrance' 39 - } 40 - 41 - const getHouseName = (fragranceUri: string) => { 42 - return fragrances.get(fragranceUri)?.houseName 45 + const getFragranceInfo = (fragranceUri: string) => { 46 + return fragrances.get(fragranceUri) || { name: 'Unknown Fragrance' } 43 47 } 44 48 45 49 const getAuthor = (reviewUri: string): AuthorInfo | undefined => { ··· 66 70 <div> 67 71 {active.length > 0 && ( 68 72 <section className="review-section" style={{ marginBottom: '1rem' }}> 69 - {active.map(review => ( 70 - <ReviewCard 71 - key={review.uri} 72 - review={review} 73 - fragranceName={getFragranceName(review.value.fragrance)} 74 - houseName={getHouseName(review.value.fragrance)} 75 - author={getAuthor(review.uri)} 76 - status="active" 77 - onClick={() => onReviewClick(review)} 78 - viewerPreferences={viewerPreferences} 79 - viewerDid={viewerDid} 80 - /> 81 - ))} 73 + {active.map(review => { 74 + const fragInfo = getFragranceInfo(review.value.fragrance) 75 + return ( 76 + <ReviewCard 77 + key={review.uri} 78 + review={review} 79 + fragranceName={fragInfo.name} 80 + houseName={fragInfo.houseName} 81 + fragranceHandle={fragInfo.handle} 82 + fragranceRkey={fragInfo.rkey} 83 + author={getAuthor(review.uri)} 84 + status="active" 85 + onClick={() => onReviewClick(review)} 86 + viewerPreferences={viewerPreferences} 87 + viewerDid={viewerDid} 88 + /> 89 + ) 90 + })} 82 91 </section> 83 92 )} 84 93 ··· 98 107 </select> 99 108 </div> 100 109 </div> 101 - {sortedPast.map(review => ( 102 - <ReviewCard 103 - key={review.uri} 104 - review={review} 105 - fragranceName={getFragranceName(review.value.fragrance)} 106 - houseName={getHouseName(review.value.fragrance)} 107 - author={getAuthor(review.uri)} 108 - status="past" 109 - onClick={() => onReviewClick(review)} 110 - viewerPreferences={viewerPreferences} 111 - viewerDid={viewerDid} 112 - /> 113 - ))} 110 + {sortedPast.map(review => { 111 + const fragInfo = getFragranceInfo(review.value.fragrance) 112 + return ( 113 + <ReviewCard 114 + key={review.uri} 115 + review={review} 116 + fragranceName={fragInfo.name} 117 + houseName={fragInfo.houseName} 118 + fragranceHandle={fragInfo.handle} 119 + fragranceRkey={fragInfo.rkey} 120 + author={getAuthor(review.uri)} 121 + status="past" 122 + onClick={() => onReviewClick(review)} 123 + viewerPreferences={viewerPreferences} 124 + viewerDid={viewerDid} 125 + /> 126 + ) 127 + })} 114 128 </section> 115 129 )} 116 130
+28 -2
src/components/SingleReviewPage.tsx
··· 104 104 if (fragranceUri) { 105 105 const fragValue = await resolveAtUri(fragranceUri) 106 106 if (fragValue) { 107 - setFragrance(fragValue) 107 + // Parse fragrance URI to extract handle and rkey for linking 108 + const [,, fragranceDid, , fragranceRkey] = fragranceUri.split('/') 109 + let fragranceHandle: string | undefined 110 + 111 + try { 112 + const { profileData } = await resolveIdentity(fragranceDid) 113 + fragranceHandle = profileData.handle 114 + } catch (e) { 115 + console.error(`Failed to resolve fragrance handle`, e) 116 + } 117 + 118 + setFragrance({ 119 + ...fragValue, 120 + handle: fragranceHandle, 121 + rkey: fragranceRkey 122 + }) 108 123 109 124 // 4. Fetch house if fragrance has one 110 125 if (fragValue.house) { ··· 326 341 <div style={{ flex: 1 }}> 327 342 {fragrance && ( 328 343 <> 329 - <h1 className="review-title">{fragrance.name}</h1> 344 + <h1 className="review-title"> 345 + {fragrance.handle && fragrance.rkey ? ( 346 + <HapticLink 347 + href={`/profile/${fragrance.handle}/fragrance/${fragrance.rkey}`} 348 + style={{ color: 'inherit', textDecoration: 'none' }} 349 + > 350 + {fragrance.name} 351 + </HapticLink> 352 + ) : ( 353 + fragrance.name 354 + )} 355 + </h1> 330 356 <h2 className="review-subtitle" style={{ fontSize: '1.2rem', margin: '0.25rem 0 0 0' }}> 331 357 {fragrance.houseDid && fragrance.houseRkey ? ( 332 358 <HapticLink