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

better non-existant user pages

+239 -57
+31
src/app.css
··· 199 199 color: var(--text-secondary); 200 200 } 201 201 202 + /* Enhanced empty state for non-drydown users */ 203 + .empty-profile-state { 204 + text-align: center; 205 + padding: 3rem 2rem; 206 + background: var(--surface-2); 207 + border: 1px solid var(--border-color); 208 + border-radius: var(--radius-lg); 209 + margin: 2rem 0; 210 + } 211 + 212 + .empty-profile-state-title { 213 + font-size: 1.25rem; 214 + font-weight: 600; 215 + margin-bottom: 1rem; 216 + color: var(--text-primary); 217 + } 218 + 219 + .empty-profile-state-description { 220 + font-size: 0.95rem; 221 + color: var(--text-secondary); 222 + line-height: 1.6; 223 + margin-bottom: 1.5rem; 224 + max-width: 480px; 225 + margin-left: auto; 226 + margin-right: auto; 227 + } 228 + 229 + .empty-profile-state-cta { 230 + margin-top: 1.5rem; 231 + } 232 + 202 233 /* Review Card Styles */ 203 234 .review-card { 204 235 border: 1px solid var(--card-border);
+46
src/components/EmptyProfileState.tsx
··· 1 + /** 2 + * EmptyProfileState Component 3 + * 4 + * Displays enhanced empty state for user profiles with no reviews. 5 + * Distinguishes between existing Drydown users (who haven't posted reviews yet) 6 + * and non-users (who can be invited to try the app). 7 + */ 8 + 9 + interface EmptyProfileStateProps { 10 + profileHandle: string 11 + profileDid: string 12 + isLikelyNonUser: boolean 13 + onInviteClick: () => void 14 + } 15 + 16 + export function EmptyProfileState({ 17 + profileHandle, 18 + isLikelyNonUser, 19 + onInviteClick, 20 + }: EmptyProfileStateProps) { 21 + // Simple empty state for existing users with no reviews yet 22 + if (!isLikelyNonUser) { 23 + return ( 24 + <div className="reviews-empty"> 25 + <p>No reviews yet.</p> 26 + </div> 27 + ) 28 + } 29 + 30 + // Enhanced empty state for non-users with invite CTA 31 + return ( 32 + <div className="empty-profile-state"> 33 + <h2 className="empty-profile-state-title"> 34 + This user hasn't tried Drydown yet 35 + </h2> 36 + <p className="empty-profile-state-description"> 37 + Drydown is an app for creating detailed fragrance reviews that capture 38 + how scents evolve over time. Reviews are stored in your personal data 39 + server and can be shared across the AT Protocol network. 40 + </p> 41 + <div className="empty-profile-state-cta"> 42 + <button onClick={onInviteClick}>Invite @{profileHandle}</button> 43 + </div> 44 + </div> 45 + ) 46 + }
+40 -9
src/components/ProfileHousesPage.tsx
··· 6 6 import { Header } from './Header' 7 7 import { Footer } from './Footer' 8 8 import { TabBar } from './TabBar' 9 + import { EmptyProfileState } from './EmptyProfileState' 9 10 import { resolveIdentity } from '../utils/resolveIdentity' 10 11 import { batchResolveAtUris } from '../utils/atUriUtils' 11 12 import { cache } from '../services/cache' 12 13 import { getReviewDisplayScore } from '../utils/reviewUtils' 14 + import { useInviteButton } from '../hooks/useInviteButton' 13 15 import { useService } from '../contexts/ServiceContext' 14 16 import { DEFAULT_SERVICE } from '../config/services' 15 17 ··· 32 34 33 35 export function ProfileHousesPage({ handle, session, userProfile, onLogout }: ProfileHousesPageProps) { 34 36 const { userService } = useService() 37 + const { invite } = useInviteButton() 35 38 const [houses, setHouses] = useState<HouseStat[]>([]) 36 39 const [isLoading, setIsLoading] = useState(true) 37 40 const [profile, setProfile] = useState<{ displayName?: string, handle: string, did: string, avatar?: string } | null>(null) 38 41 const [error, setError] = useState<string | null>(null) 39 42 const [houseFilter, setHouseFilter] = useState<HouseFilter>('all') 43 + const [isLikelyNonUser, setIsLikelyNonUser] = useState(false) 40 44 41 45 useEffect(() => { 42 46 async function loadProfileAndHouses() { ··· 61 65 avatar: profileData.avatar 62 66 }) 63 67 64 - // Fetch reviews and fragrances in parallel 65 - const [reviewRecords, fragranceRecords] = await Promise.all([ 68 + // Fetch reviews, fragrances, and houses in parallel 69 + const [reviewRecords, fragranceRecords, houseRecords] = await Promise.all([ 66 70 pdsClient.social.drydown.review.list({ repo: did }), 67 71 pdsClient.social.drydown.fragrance.list({ repo: did }), 72 + pdsClient.social.drydown.house.list({ repo: did }), 68 73 ]) 69 74 75 + // Detect if user has ANY drydown data 76 + const isNonUser = 77 + reviewRecords.records.length === 0 && 78 + fragranceRecords.records.length === 0 && 79 + houseRecords.records.length === 0 80 + 81 + setIsLikelyNonUser(isNonUser) 82 + 70 83 // Collect all house URIs used by fragrances 71 84 const houseUris = [ 72 85 ...new Set( ··· 143 156 return <div class="container">Profile not found</div> 144 157 } 145 158 159 + // Invite handler for non-users 160 + const handleInvite = () => { 161 + if (profile) { 162 + invite(profile.handle) 163 + } 164 + } 165 + 146 166 return ( 147 167 <div class="profile-page page-container"> 148 168 <SEO ··· 188 208 </div> 189 209 </header> 190 210 191 - <TabBar 192 - tabs={[ 193 - { label: 'Reviews', href: `/profile/${handle}/reviews` }, 194 - { label: 'Houses' }, 195 - ]} 196 - /> 211 + {isLikelyNonUser ? ( 212 + <EmptyProfileState 213 + profileHandle={profile.handle} 214 + profileDid={profile.did} 215 + isLikelyNonUser={isLikelyNonUser} 216 + onInviteClick={handleInvite} 217 + /> 218 + ) : ( 219 + <> 220 + <TabBar 221 + tabs={[ 222 + { label: 'Reviews', href: `/profile/${handle}/reviews` }, 223 + { label: 'Houses' }, 224 + ]} 225 + /> 197 226 198 - {houses.length > 0 && (() => { 227 + {houses.length > 0 && (() => { 199 228 // Calculate stats 200 229 const totalHouses = houses.length 201 230 const maintainedHouses = houses.filter(house => { ··· 314 343 </div> 315 344 ) 316 345 })()} 346 + </> 347 + )} 317 348 318 349 <Footer /> 319 350 </div>
+75 -47
src/components/ProfilePage.tsx
··· 8 8 import { Footer } from './Footer' 9 9 import { ReviewList } from './ReviewList' 10 10 import { TabBar } from './TabBar' 11 + import { EmptyProfileState } from './EmptyProfileState' 11 12 import { resolveIdentity } from '../utils/resolveIdentity' 12 13 import { batchResolveAtUris } from '../utils/atUriUtils' 13 14 import { cache, TTL } from '../services/cache' 14 15 import { getReviewDisplayScore, type UserPreferencesForScoring } from '../utils/reviewUtils' 15 16 import { useUserPreferences } from '../hooks/useUserPreferences' 17 + import { useInviteButton } from '../hooks/useInviteButton' 16 18 import { useService } from '../contexts/ServiceContext' 17 19 import { DEFAULT_SERVICE } from '../config/services' 18 20 import { fetchUserPreferences, calculateCompatibilityScore, getCompatibilityLabel } from '../utils/preferenceUtils' ··· 38 40 export function ProfilePage({ handle, session, userProfile, onLogout }: ProfilePageProps) { 39 41 const [, setLocation] = useLocation() 40 42 const { userService } = useService() 43 + const { invite } = useInviteButton() 41 44 const [reviews, setReviews] = useState<Array<{ uri: string; value: any }>>([]) 42 45 const [fragrances, setFragrances] = useState<Map<string, { name: string, houseName?: string }>>(new Map()) 43 46 const [isLoading, setIsLoading] = useState(true) 44 47 const [profile, setProfile] = useState<{ displayName?: string, handle: string, did: string, avatar?: string } | null>(null) 45 48 const [error, setError] = useState<string | null>(null) 46 49 const [profileUserPreferences, setProfileUserPreferences] = useState<UserPreferencesForScoring | null>(null) 50 + const [isLikelyNonUser, setIsLikelyNonUser] = useState(false) 47 51 const { preferences } = useUserPreferences(session) 48 52 49 53 useEffect(() => { ··· 69 73 avatar: profileData.avatar 70 74 }) 71 75 72 - // Fetch reviews, fragrances, and profile preferences in parallel 73 - const [reviewRecords, fragranceRecords, profilePrefs] = await Promise.all([ 76 + // Fetch reviews, fragrances, houses, and profile preferences in parallel 77 + const [reviewRecords, fragranceRecords, houseRecords, profilePrefs] = await Promise.all([ 74 78 pdsClient.social.drydown.review.list({ repo: did }), 75 79 pdsClient.social.drydown.fragrance.list({ repo: did }), 80 + pdsClient.social.drydown.house.list({ repo: did }), 76 81 fetchUserPreferences(did, pdsUrl), 77 82 ]) 78 83 84 + // Detect if user has ANY drydown data 85 + const isNonUser = 86 + reviewRecords.records.length === 0 && 87 + fragranceRecords.records.length === 0 && 88 + houseRecords.records.length === 0 89 + 90 + setIsLikelyNonUser(isNonUser) 79 91 setReviews(reviewRecords.records) 80 92 setProfileUserPreferences(profilePrefs) 81 93 ··· 230 242 compatibilityLabel = getCompatibilityLabel(compatibilityScore) 231 243 } 232 244 245 + // Invite handler for non-users 246 + const handleInvite = () => { 247 + if (profile) { 248 + invite(profile.handle) 249 + } 250 + } 251 + 233 252 return ( 234 253 <div class="profile-page page-container"> 235 254 <SEO ··· 275 294 </div> 276 295 </header> 277 296 278 - <TabBar 279 - tabs={[ 280 - { label: 'Reviews' }, 281 - { label: 'Houses', href: `/profile/${handle}/houses` }, 282 - ]} 283 - /> 297 + {!isLikelyNonUser && ( 298 + <> 299 + <TabBar 300 + tabs={[ 301 + { label: 'Reviews' }, 302 + { label: 'Houses', href: `/profile/${handle}/houses` }, 303 + ]} 304 + /> 284 305 285 - <div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: '1rem', marginBottom: '2rem' }}> 286 - <div class="score-item" style={{ gridColumn: `span ${showCompatibility ? 2 : 3}` }}> 287 - <div className="score-label">Total Reviews</div> 288 - <div className="score-value">{numReviews}</div> 289 - </div> 290 - <div class="score-item" style={{ gridColumn: `span ${showCompatibility ? 2 : 3}` }}> 291 - <div className="score-label">Avg Rating</div> 292 - <div className="score-value">{numReviews > 0 ? `${renderStars(avgRating)} (${avgRating.toFixed(1)})` : 'N/A'}</div> 293 - </div> 294 - {showCompatibility && ( 295 - <CompatibilityScore 296 - score={compatibilityScore} 297 - label={compatibilityLabel} 298 - /> 299 - )} 300 - <div class="score-item" style={{ gridColumn: 'span 3' }}> 301 - <div className="score-label">Favorite Fragrance</div> 302 - <div className="score-value"> 303 - <div style={{ fontSize: '1.1rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> 304 - {favoriteFragrance} 305 - </div> 306 - {favoriteScore > 0 && ( 307 - <div style={{ fontSize: '0.9rem', opacity: 0.6, marginTop: '0.25rem' }}> 308 - {renderStars(favoriteScore)} ({favoriteScore.toFixed(1)}) 306 + <div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: '1rem', marginBottom: '2rem' }}> 307 + <div class="score-item" style={{ gridColumn: `span ${showCompatibility ? 2 : 3}` }}> 308 + <div className="score-label">Total Reviews</div> 309 + <div className="score-value">{numReviews}</div> 310 + </div> 311 + <div class="score-item" style={{ gridColumn: `span ${showCompatibility ? 2 : 3}` }}> 312 + <div className="score-label">Avg Rating</div> 313 + <div className="score-value">{numReviews > 0 ? `${renderStars(avgRating)} (${avgRating.toFixed(1)})` : 'N/A'}</div> 314 + </div> 315 + {showCompatibility && ( 316 + <CompatibilityScore 317 + score={compatibilityScore} 318 + label={compatibilityLabel} 319 + /> 320 + )} 321 + <div class="score-item" style={{ gridColumn: 'span 3' }}> 322 + <div className="score-label">Favorite Fragrance</div> 323 + <div className="score-value"> 324 + <div style={{ fontSize: '1.1rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> 325 + {favoriteFragrance} 309 326 </div> 310 - )} 327 + {favoriteScore > 0 && ( 328 + <div style={{ fontSize: '0.9rem', opacity: 0.6, marginTop: '0.25rem' }}> 329 + {renderStars(favoriteScore)} ({favoriteScore.toFixed(1)}) 330 + </div> 331 + )} 332 + </div> 311 333 </div> 312 - </div> 313 - <div class="score-item" style={{ gridColumn: 'span 3' }}> 314 - <div className="score-label">Least Favorite Fragrance</div> 315 - <div className="score-value"> 316 - <div style={{ fontSize: '1.1rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> 317 - {leastFavoriteFragrance} 318 - </div> 319 - {leastFavoriteScore > 0 && ( 320 - <div style={{ fontSize: '0.9rem', opacity: 0.6, marginTop: '0.25rem' }}> 321 - {renderStars(leastFavoriteScore)} ({leastFavoriteScore.toFixed(1)}) 334 + <div class="score-item" style={{ gridColumn: 'span 3' }}> 335 + <div className="score-label">Least Favorite Fragrance</div> 336 + <div className="score-value"> 337 + <div style={{ fontSize: '1.1rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> 338 + {leastFavoriteFragrance} 322 339 </div> 323 - )} 340 + {leastFavoriteScore > 0 && ( 341 + <div style={{ fontSize: '0.9rem', opacity: 0.6, marginTop: '0.25rem' }}> 342 + {renderStars(leastFavoriteScore)} ({leastFavoriteScore.toFixed(1)}) 343 + </div> 344 + )} 345 + </div> 324 346 </div> 325 347 </div> 326 - </div> 327 348 328 - <h2>Reviews</h2> 349 + <h2>Reviews</h2> 350 + </> 351 + )} 329 352 {reviews.length === 0 ? ( 330 - <p>No reviews found for this user.</p> 353 + <EmptyProfileState 354 + profileHandle={profile.handle} 355 + profileDid={profile.did} 356 + isLikelyNonUser={isLikelyNonUser} 357 + onInviteClick={handleInvite} 358 + /> 331 359 ) : ( 332 360 <ReviewList 333 361 reviews={reviews}
+1 -1
src/config/services.ts
··· 26 26 { 27 27 id: 'blacksky', 28 28 name: 'Blacksky', 29 - composeUrl: (text) => `https://blacksky.app/intent/compose?text=${encodeURIComponent(text)}`, 29 + composeUrl: (text) => `https://blacksky.community/intent/compose?text=${encodeURIComponent(text)}`, 30 30 // Blacksky uses DIDs in profile URLs, not handles 31 31 profileUrl: (handle, did) => did 32 32 ? `https://blacksky.community/profile/${did}`
+46
src/hooks/useInviteButton.ts
··· 1 + /** 2 + * Invite Button Hook 3 + * 4 + * Generates personal recommendation messages to invite users to try Drydown. 5 + * Uses the logged-in user's service (not the target user's service). 6 + */ 7 + 8 + import { useService } from '../contexts/ServiceContext' 9 + import { DEFAULT_SERVICE } from '../config/services' 10 + 11 + interface InviteButtonReturn { 12 + /** Opens the compose intent URL with invitation message */ 13 + invite: (targetHandle: string) => void 14 + /** Name of the service being used for inviting */ 15 + serviceName: string 16 + /** Whether inviting is available (always true - opens compose window) */ 17 + canInvite: boolean 18 + } 19 + 20 + /** 21 + * Hook for inviting users to try Drydown via AT Protocol service 22 + * 23 + * @returns InviteButtonReturn with invite function, service name, and availability 24 + */ 25 + export function useInviteButton(): InviteButtonReturn { 26 + const { userService } = useService() 27 + 28 + // Use logged-in user's service, or default 29 + const service = userService || DEFAULT_SERVICE 30 + 31 + const invite = (targetHandle: string) => { 32 + // Generate personal recommendation message 33 + const message = `Hey @${targetHandle}, you should check out @drydown.social! 34 + 35 + It's an app for creating detailed fragrance reviews that capture how scents evolve over time. I think you'd enjoy it!` 36 + 37 + const url = service.composeUrl(message) 38 + window.open(url, '_blank', 'noopener,noreferrer') 39 + } 40 + 41 + return { 42 + invite, 43 + serviceName: service.name, 44 + canInvite: true, // Always available - opens compose window 45 + } 46 + }