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