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

Merge pull request #6 from taurean/feature/migrate-fragrances

Feature/migrate fragrances

authored by taurean.bryant.land and committed by

GitHub 312c986e 1fba6c7a

+930 -82
+47
src/api/fragrances.ts
··· 1 import type { Agent } from '@atproto/api' 2 import type { Fragrance, AtUri } from '@/types/lexicon-types' 3 4 const COLLECTION = 'social.drydown.fragrance' 5 ··· 94 rkey 95 }) 96 }
··· 1 import type { Agent } from '@atproto/api' 2 import type { Fragrance, AtUri } from '@/types/lexicon-types' 3 + import type { AtpBaseClient } from '../client/index' 4 5 const COLLECTION = 'social.drydown.fragrance' 6 ··· 95 rkey 96 }) 97 } 98 + 99 + /** 100 + * Bulk update house reference for multiple fragrances 101 + * Returns success count and any failed URIs 102 + */ 103 + export async function bulkUpdateFragranceHouse( 104 + client: AtpBaseClient, 105 + did: string, 106 + fragranceUris: AtUri[], 107 + newHouseUri: AtUri 108 + ): Promise<{ successCount: number; failedUris: AtUri[]; errors: string[] }> { 109 + const failedUris: AtUri[] = [] 110 + const errors: string[] = [] 111 + let successCount = 0 112 + 113 + for (const uri of fragranceUris) { 114 + const rkey = uri.split('/').pop()! 115 + try { 116 + // Use the generated client methods instead of raw .call() 117 + const getResponse = await client.social.drydown.fragrance.get({ 118 + repo: did, 119 + rkey 120 + }) 121 + 122 + const updated = { 123 + ...getResponse.value, 124 + house: newHouseUri, 125 + updatedAt: new Date().toISOString() 126 + } 127 + 128 + await client.social.drydown.fragrance.put({ 129 + repo: did, 130 + rkey 131 + }, updated) 132 + 133 + successCount++ 134 + } catch (e) { 135 + const errorMsg = e instanceof Error ? e.message : String(e) 136 + console.error(`Failed to update fragrance ${uri}:`, errorMsg) 137 + failedUris.push(uri) 138 + errors.push(`${rkey}: ${errorMsg}`) 139 + } 140 + } 141 + 142 + return { successCount, failedUris, errors } 143 + }
+3 -3
src/api/houses.ts
··· 1 import type { Agent } from '@atproto/api' 2 import type { House, AtUri } from '@/types/lexicon-types' 3 4 const COLLECTION = 'social.drydown.house' 5 ··· 76 * Delete house (only if no fragrances reference it - validated client-side first) 77 */ 78 export async function deleteHouse( 79 - agent: Agent, 80 did: string, 81 uri: AtUri 82 ): Promise<void> { 83 const rkey = uri.split('/').pop()! 84 85 - await agent.com.atproto.repo.deleteRecord({ 86 repo: did, 87 - collection: COLLECTION, 88 rkey 89 }) 90 }
··· 1 import type { Agent } from '@atproto/api' 2 import type { House, AtUri } from '@/types/lexicon-types' 3 + import type { AtpBaseClient } from '../client/index' 4 5 const COLLECTION = 'social.drydown.house' 6 ··· 77 * Delete house (only if no fragrances reference it - validated client-side first) 78 */ 79 export async function deleteHouse( 80 + client: AtpBaseClient, 81 did: string, 82 uri: AtUri 83 ): Promise<void> { 84 const rkey = uri.split('/').pop()! 85 86 + await client.social.drydown.house.delete({ 87 repo: did, 88 rkey 89 }) 90 }
+52
src/api/reviews.ts
···
··· 1 + import type { AtUri } from '@/types/lexicon-types' 2 + import type { AtpBaseClient } from '../client/index' 3 + 4 + /** 5 + * Update review's fragrance connection (migration) 6 + * Only updates the fragrance field and updatedAt timestamp 7 + */ 8 + export async function updateReviewFragrance( 9 + client: AtpBaseClient, 10 + did: string, 11 + reviewUri: AtUri, 12 + newFragranceUri: AtUri 13 + ): Promise<void> { 14 + const rkey = reviewUri.split('/').pop()! 15 + 16 + // Fetch current review 17 + const getResponse = await client.social.drydown.review.get({ 18 + repo: did, 19 + rkey 20 + }) 21 + 22 + // Update only fragrance field 23 + const updated = { 24 + ...getResponse.value, 25 + fragrance: newFragranceUri, 26 + updatedAt: new Date().toISOString() 27 + } 28 + 29 + // Write back 30 + await client.social.drydown.review.put({ 31 + repo: did, 32 + rkey 33 + }, updated) 34 + } 35 + 36 + /** 37 + * List all reviews for a user 38 + */ 39 + export async function listReviews( 40 + client: AtpBaseClient, 41 + did: string 42 + ): Promise<Array<{ uri: string; value: any }>> { 43 + const response = await client.social.drydown.review.list({ 44 + repo: did, 45 + limit: 100 46 + }) 47 + 48 + return response.records.map((r: any) => ({ 49 + uri: r.uri, 50 + value: r.value 51 + })) 52 + }
+101 -6
src/app.css
··· 92 } 93 94 header .user-info { 95 font-size: 0.9rem; 96 color: var(--text-secondary); 97 } 98 99 /* Profile Avatar */ ··· 606 margin: 0 auto; 607 } 608 609 - .login-form-button { 610 - width: 100%; 611 padding: 0.75rem 1rem; 612 font-size: 1rem; 613 font-weight: 500; ··· 620 text-transform: lowercase; 621 } 622 623 - .login-form-button:hover:not(:disabled) { 624 background: #1a1a1a; 625 } 626 627 - .login-form-button:disabled { 628 opacity: 0.6; 629 cursor: not-allowed; 630 } 631 632 @media (prefers-color-scheme: dark) { 633 - .login-form-button { 634 background: #404040; 635 color: #ffffff; 636 } 637 638 - .login-form-button:hover:not(:disabled) { 639 background: #525252; 640 } 641 } 642 643 .login-form-providers { 644 text-align: center; 645 font-size: 0.875rem; ··· 720 .app-disclaimer a:hover { 721 text-decoration-thickness: 2px; 722 }
··· 92 } 93 94 header .user-info { 95 + display: flex; 96 + gap: 1rem; 97 + align-items: center; 98 font-size: 0.9rem; 99 color: var(--text-secondary); 100 + } 101 + 102 + header .user-info .btn-primary { 103 + font-size: 0.85rem; 104 + padding: 0.5rem 0.75rem; 105 } 106 107 /* Profile Avatar */ ··· 614 margin: 0 auto; 615 } 616 617 + /* Primary Button Style */ 618 + .btn-primary { 619 padding: 0.75rem 1rem; 620 font-size: 1rem; 621 font-weight: 500; ··· 628 text-transform: lowercase; 629 } 630 631 + .btn-primary:hover:not(:disabled) { 632 background: #1a1a1a; 633 } 634 635 + .btn-primary:disabled { 636 opacity: 0.6; 637 cursor: not-allowed; 638 } 639 640 @media (prefers-color-scheme: dark) { 641 + .btn-primary { 642 background: #404040; 643 color: #ffffff; 644 } 645 646 + .btn-primary:hover:not(:disabled) { 647 background: #525252; 648 } 649 } 650 651 + /* Secondary Button Style - Muted variant for back/cancel actions */ 652 + .btn-secondary { 653 + padding: 0.5rem 0.75rem; 654 + font-size: 0.875rem; 655 + font-weight: 400; 656 + color: var(--text-secondary); 657 + background: transparent; 658 + border: 1px solid var(--border-color); 659 + border-radius: 6px; 660 + cursor: pointer; 661 + transition: all 0.2s ease; 662 + text-transform: lowercase; 663 + } 664 + 665 + .btn-secondary:hover:not(:disabled) { 666 + background: var(--card-bg); 667 + border-color: var(--text-color); 668 + color: var(--text-color); 669 + } 670 + 671 + .btn-secondary:disabled { 672 + opacity: 0.4; 673 + cursor: not-allowed; 674 + } 675 + 676 + /* Accent Button Style - Special actions like sharing to Bluesky */ 677 + .btn-accent { 678 + padding: 0.75rem 1rem; 679 + font-size: 1rem; 680 + font-weight: 500; 681 + color: #ffffff; 682 + background: #0085ff; 683 + border: none; 684 + border-radius: 8px; 685 + cursor: pointer; 686 + transition: background 0.2s ease; 687 + text-transform: lowercase; 688 + } 689 + 690 + .btn-accent:hover:not(:disabled) { 691 + background: #0070dd; 692 + } 693 + 694 + .btn-accent:disabled { 695 + opacity: 0.6; 696 + cursor: not-allowed; 697 + } 698 + 699 + /* Login form button uses primary style */ 700 + .login-form-button { 701 + width: 100%; 702 + } 703 + 704 + .login-form-button.btn-primary { 705 + /* Inherits all btn-primary styles */ 706 + } 707 + 708 .login-form-providers { 709 text-align: center; 710 font-size: 0.875rem; ··· 785 .app-disclaimer a:hover { 786 text-decoration-thickness: 2px; 787 } 788 + 789 + /* App Footer */ 790 + .app-footer { 791 + margin-top: 3rem; 792 + padding: 2rem 0 1rem; 793 + border-top: 1px solid var(--border-color); 794 + text-align: center; 795 + } 796 + 797 + .footer-links { 798 + font-size: 0.85rem; 799 + color: var(--text-secondary); 800 + line-height: 1.6; 801 + } 802 + 803 + .footer-links a { 804 + color: var(--text-secondary); 805 + text-decoration: none; 806 + transition: opacity 0.2s ease; 807 + } 808 + 809 + .footer-links a:hover { 810 + opacity: 0.7; 811 + text-decoration: underline; 812 + } 813 + 814 + .footer-separator { 815 + margin: 0 0.5rem; 816 + opacity: 0.5; 817 + }
+7 -5
src/app.tsx
··· 11 import { HousePage } from './components/HousePage' 12 import { EditHousePage } from './components/EditHousePage' 13 import { ProfileHousesPage } from './components/ProfileHousesPage' 14 import type { OAuthSession } from '@atproto/oauth-client-browser' 15 import { AtpBaseClient } from './client/index' 16 import { Route, Switch, Link } from 'wouter' ··· 57 ) : ( 58 <> 59 <header> 60 - <Link href="/" style={{ textDecoration: 'none', color: 'inherit' }}> 61 <h1>Drydown</h1> 62 </Link> 63 - <div class="user-info" style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}> 64 <Link href="/explore" style={{ textDecoration: 'none', color: 'inherit', opacity: 0.8 }}> 65 Explore 66 </Link> 67 <Link href={`/profile/${userProfile?.handle || session.sub}/reviews`} style={{ textDecoration: 'none', color: 'inherit' }}> 68 {userProfile?.displayName || userProfile?.handle || session.sub} 69 </Link> 70 </div> 71 </header> 72 73 <div class="card"> 74 {view === 'home' ? ( 75 - <> 76 <ReviewDashboard 77 session={session} 78 onCreateNew={handleCreateNew} ··· 81 setView('edit-review') 82 }} 83 /> 84 - <button onClick={onLogout} style={{ marginTop: '2rem', fontSize: '0.8rem', opacity: 0.8 }}>Sign Out</button> 85 - </> 86 ) : view === 'create-review' ? ( 87 <CreateReview 88 session={session} ··· 98 /> 99 )} 100 </div> 101 </> 102 )} 103 </>
··· 11 import { HousePage } from './components/HousePage' 12 import { EditHousePage } from './components/EditHousePage' 13 import { ProfileHousesPage } from './components/ProfileHousesPage' 14 + import { Footer } from './components/Footer' 15 import type { OAuthSession } from '@atproto/oauth-client-browser' 16 import { AtpBaseClient } from './client/index' 17 import { Route, Switch, Link } from 'wouter' ··· 58 ) : ( 59 <> 60 <header> 61 + <Link href="/" onClick={handleBackToDashboard} style={{ textDecoration: 'none', color: 'inherit' }}> 62 <h1>Drydown</h1> 63 </Link> 64 + <div class="user-info"> 65 <Link href="/explore" style={{ textDecoration: 'none', color: 'inherit', opacity: 0.8 }}> 66 Explore 67 </Link> 68 <Link href={`/profile/${userProfile?.handle || session.sub}/reviews`} style={{ textDecoration: 'none', color: 'inherit' }}> 69 {userProfile?.displayName || userProfile?.handle || session.sub} 70 </Link> 71 + <button onClick={onLogout} class="btn-primary"> 72 + sign out 73 + </button> 74 </div> 75 </header> 76 77 <div class="card"> 78 {view === 'home' ? ( 79 <ReviewDashboard 80 session={session} 81 onCreateNew={handleCreateNew} ··· 84 setView('edit-review') 85 }} 86 /> 87 ) : view === 'create-review' ? ( 88 <CreateReview 89 session={session} ··· 99 /> 100 )} 101 </div> 102 + <Footer session={session} /> 103 </> 104 )} 105 </>
+56 -26
src/components/CreateReview.tsx
··· 4 import { AppDisclaimer } from './AppDisclaimer' 5 import type { OAuthSession } from '@atproto/oauth-client-browser' 6 import { DiscoveryService } from '@/services/discovery' 7 - import { calculateWeightedScore, encodeWeightedScore } from '../utils/reviewUtils' 8 9 // Define local interfaces based on the lexicon types for easier usage 10 interface House { ··· 40 const [openingRating, setOpeningRating] = useState<number>(0) 41 const [openingProjection, setOpeningProjection] = useState<number>(0) 42 const [isSubmitting, setIsSubmitting] = useState(false) 43 44 // Client & Discovery 45 const [atp, setAtp] = useState<AtpBaseClient | null>(null) ··· 49 console.log("OAuth Session:", session) // DEBUG 50 51 const baseClient = new AtpBaseClient(session.fetchHandler.bind(session)) 52 - 53 - setAtp(baseClient) 54 55 // Initialize Discovery Service with the BaseClient (which has .social and xrpc) 56 const disc = new DiscoveryService(baseClient, session.sub) 57 - 58 // Initial Load 59 await loadData(baseClient, disc) 60 ··· 72 return () => unsubscribe() 73 } 74 initClient() 75 }, [session]) 76 77 const loadData = async (client: AtpBaseClient, disc: DiscoveryService) => { ··· 180 } 181 } 182 183 - const checkAuth = async () => { 184 - if (!atp) return 185 - try { 186 - console.log("Checking auth against:", (atp as any).service) 187 - const res = await atp.call('app.bsky.feed.getTimeline', { limit: 1 }) 188 - console.log("Auth Check Success:", res) 189 - alert("Auth OK! Timeline fetched.") 190 - } catch (e: any) { 191 - console.error("Auth Check Failed", e) 192 - alert(`Auth Failed: ${e.message || e}`) 193 - } 194 - } 195 - 196 const handleSubmit = async (e: Event) => { 197 e.preventDefault() 198 if (!atp || !selectedFragranceUri) return ··· 213 weightedScore: encodeWeightedScore(initialScore) 214 } 215 216 - await atp.social.drydown.review.create( 217 { repo: session.sub }, 218 reviewWithScore 219 ) 220 - if ('Notification' in window && Notification.permission === 'default') { 221 - await Notification.requestPermission() 222 } 223 onSuccess() 224 } catch (e) { 225 console.error("Failed to submit review", e) ··· 236 237 return ( 238 <div class="create-review-container"> 239 - <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> 240 - <h2>Create Review</h2> 241 - <button type="button" onClick={checkAuth} style={{ fontSize: '0.8rem' }}>Check Auth</button> 242 - </div> 243 - <button onClick={onCancel} style={{ marginBottom: '1rem' }}>Back</button> 244 245 <form onSubmit={handleSubmit}> 246 <Combobox ··· 325 placeholder="Rate 1-5" 326 /> 327 </div> 328 </> 329 )} 330 331 <button 332 type="submit" 333 disabled={ 334 isSubmitting || 335 !selectedFragranceUri || ··· 339 openingProjection > 5 340 } 341 > 342 - {isSubmitting ? 'Starting Review...' : 'Start Reviewing'} 343 </button> 344 </form> 345
··· 4 import { AppDisclaimer } from './AppDisclaimer' 5 import type { OAuthSession } from '@atproto/oauth-client-browser' 6 import { DiscoveryService } from '@/services/discovery' 7 + import { calculateWeightedScore, encodeWeightedScore, formatNotificationWindow } from '../utils/reviewUtils' 8 + import { NotificationService } from '../services/notificationService' 9 10 // Define local interfaces based on the lexicon types for easier usage 11 interface House { ··· 41 const [openingRating, setOpeningRating] = useState<number>(0) 42 const [openingProjection, setOpeningProjection] = useState<number>(0) 43 const [isSubmitting, setIsSubmitting] = useState(false) 44 + 45 + // Notification opt-in state 46 + const [notifyStage2, setNotifyStage2] = useState(false) 47 + const [createdAtPreview, setCreatedAtPreview] = useState<string>('') 48 49 // Client & Discovery 50 const [atp, setAtp] = useState<AtpBaseClient | null>(null) ··· 54 console.log("OAuth Session:", session) // DEBUG 55 56 const baseClient = new AtpBaseClient(session.fetchHandler.bind(session)) 57 + 58 + setAtp(baseClient) 59 60 // Initialize Discovery Service with the BaseClient (which has .social and xrpc) 61 const disc = new DiscoveryService(baseClient, session.sub) 62 + 63 // Initial Load 64 await loadData(baseClient, disc) 65 ··· 77 return () => unsubscribe() 78 } 79 initClient() 80 + 81 + // Set preview timestamp for notification window display 82 + setCreatedAtPreview(new Date().toISOString()) 83 }, [session]) 84 85 const loadData = async (client: AtpBaseClient, disc: DiscoveryService) => { ··· 188 } 189 } 190 191 const handleSubmit = async (e: Event) => { 192 e.preventDefault() 193 if (!atp || !selectedFragranceUri) return ··· 208 weightedScore: encodeWeightedScore(initialScore) 209 } 210 211 + const result = await atp.social.drydown.review.create( 212 { repo: session.sub }, 213 reviewWithScore 214 ) 215 + 216 + // Handle notification opt-in (client-side only) 217 + if (notifyStage2) { 218 + const permissionGranted = await NotificationService.requestPermission() 219 + if (permissionGranted) { 220 + NotificationService.setPreference(result.uri, 'stage2', true) 221 + } 222 } 223 + 224 onSuccess() 225 } catch (e) { 226 console.error("Failed to submit review", e) ··· 237 238 return ( 239 <div class="create-review-container"> 240 + <h2>Create Review</h2> 241 + <button onClick={onCancel} class="btn-secondary" style={{ marginBottom: '1rem' }}>back</button> 242 243 <form onSubmit={handleSubmit}> 244 <Combobox ··· 323 placeholder="Rate 1-5" 324 /> 325 </div> 326 + 327 + {/* Notification opt-in for Stage 2 */} 328 + {'Notification' in window && ( 329 + <div style={{ 330 + marginTop: '2rem', 331 + marginBottom: '1.5rem', 332 + padding: '1rem', 333 + background: '#f0f7ff', 334 + borderRadius: '8px', 335 + border: '1px solid #d0e4ff' 336 + }}> 337 + <label style={{ display: 'flex', alignItems: 'flex-start', cursor: 'pointer' }}> 338 + <input 339 + type="checkbox" 340 + checked={notifyStage2} 341 + onChange={(e) => setNotifyStage2((e.target as HTMLInputElement).checked)} 342 + style={{ marginRight: '0.75rem', marginTop: '0.25rem', flexShrink: 0 }} 343 + /> 344 + <div> 345 + <div style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}> 346 + Notify me when Heart Notes are ready 347 + </div> 348 + <div style={{ fontSize: '0.85rem', color: '#555', lineHeight: '1.4' }}> 349 + {createdAtPreview && ( 350 + <>Get a notification when you can continue to Stage 2 ({formatNotificationWindow(createdAtPreview, 'stage2')})</> 351 + )} 352 + </div> 353 + </div> 354 + </label> 355 + </div> 356 + )} 357 </> 358 )} 359 360 <button 361 type="submit" 362 + class="btn-primary" 363 disabled={ 364 isSubmitting || 365 !selectedFragranceUri || ··· 369 openingProjection > 5 370 } 371 > 372 + {isSubmitting ? 'starting review...' : 'start reviewing'} 373 </button> 374 </form> 375
+230 -16
src/components/EditHousePage.tsx
··· 2 import { useLocation, Link } from 'wouter' 3 import { AtpBaseClient } from '../client/index' 4 import { AppDisclaimer } from './AppDisclaimer' 5 import type { OAuthSession } from '@atproto/oauth-client-browser' 6 7 interface EditHousePageProps { 8 handle: string ··· 17 const [isSubmitting, setIsSubmitting] = useState(false) 18 const [error, setError] = useState<string | null>(null) 19 20 useEffect(() => { 21 async function loadHouse() { 22 if (!session) { ··· 27 try { 28 setIsLoading(true) 29 const baseClient = new AtpBaseClient(session.fetchHandler.bind(session)) 30 - const res = await baseClient.call('com.atproto.repo.getRecord', { 31 repo: session.sub, 32 - collection: 'social.drydown.house', 33 rkey: rkey 34 }) 35 - 36 - if (res.data?.value?.name) { 37 - setHouseName(res.data.value.name as string) 38 } else { 39 setError("House not found or invalid format") 40 } 41 } catch (err) { 42 console.error('Failed to load house', err) 43 setError('Failed to load house data. Please try again.') ··· 58 59 try { 60 const baseClient = new AtpBaseClient(session.fetchHandler.bind(session)) 61 - 62 // Fetch the existing record to get its CID and current data 63 - const currentRes = await baseClient.call('com.atproto.repo.getRecord', { 64 repo: session.sub, 65 - collection: 'social.drydown.house', 66 rkey: rkey 67 }) 68 69 - const existingRecord = currentRes.data.value as any 70 71 - await baseClient.call('com.atproto.repo.putRecord', { 72 repo: session.sub, 73 - collection: 'social.drydown.house', 74 - rkey, 75 - record: { 76 - ...existingRecord, 77 - name: houseName.trim() 78 - } 79 }) 80 81 // Redirect back to house page on success ··· 87 } 88 } 89 90 if (isLoading) return <div className="page-container">Loading House...</div> 91 92 if (error) return ( ··· 140 </div> 141 </form> 142 143 <AppDisclaimer variant="minimal" /> 144 </div> 145 ) 146 }
··· 2 import { useLocation, Link } from 'wouter' 3 import { AtpBaseClient } from '../client/index' 4 import { AppDisclaimer } from './AppDisclaimer' 5 + import { Footer } from './Footer' 6 import type { OAuthSession } from '@atproto/oauth-client-browser' 7 + import { deleteHouse } from '../api/houses' 8 + import { bulkUpdateFragranceHouse } from '../api/fragrances' 9 + import { Combobox } from './Combobox' 10 + import { resolveIdentity } from '../utils/resolveIdentity' 11 + 12 + const MICROCOSM_API = "https://ufos-api.microcosm.blue" 13 14 interface EditHousePageProps { 15 handle: string ··· 24 const [isSubmitting, setIsSubmitting] = useState(false) 25 const [error, setError] = useState<string | null>(null) 26 27 + // Migration state 28 + const [allHouses, setAllHouses] = useState<Array<{ uri: string; name: string; ownerHandle: string; avatar?: string }>>([]) 29 + const [userOwnedFragrances, setUserOwnedFragrances] = useState<Array<{ uri: string; name: string }>>([]) 30 + const [selectedTargetHouseUri, setSelectedTargetHouseUri] = useState<string>('') 31 + const [showMigrateConfirmation, setShowMigrateConfirmation] = useState(false) 32 + const [isMigrating, setIsMigrating] = useState(false) 33 + 34 useEffect(() => { 35 async function loadHouse() { 36 if (!session) { ··· 41 try { 42 setIsLoading(true) 43 const baseClient = new AtpBaseClient(session.fetchHandler.bind(session)) 44 + const res = await baseClient.social.drydown.house.get({ 45 repo: session.sub, 46 rkey: rkey 47 }) 48 + 49 + if (res.value?.name) { 50 + setHouseName(res.value.name as string) 51 } else { 52 setError("House not found or invalid format") 53 } 54 + 55 + // Fetch ALL houses globally for migration (from Microcosm) 56 + const houseUri = `at://${session.sub}/social.drydown.house/${rkey}` 57 + const housesRes = await fetch(`${MICROCOSM_API}/records?collection=social.drydown.house&limit=2000`) 58 + if (housesRes.ok) { 59 + const allHousesData = await housesRes.json() 60 + 61 + // Resolve handles and avatars for each house owner and filter out current house 62 + const housesWithHandles = await Promise.all( 63 + allHousesData 64 + .filter((h: any) => { 65 + const uri = `at://${h.did}/${h.collection}/${h.rkey}` 66 + return uri !== houseUri && h.record?.name 67 + }) 68 + .map(async (h: any) => { 69 + try { 70 + const { profileData } = await resolveIdentity(h.did) 71 + return { 72 + uri: `at://${h.did}/${h.collection}/${h.rkey}`, 73 + name: h.record.name, 74 + ownerHandle: profileData.handle, 75 + avatar: profileData.avatar 76 + } 77 + } catch (e) { 78 + return { 79 + uri: `at://${h.did}/${h.collection}/${h.rkey}`, 80 + name: h.record.name, 81 + ownerHandle: h.did, 82 + avatar: undefined 83 + } 84 + } 85 + }) 86 + ) 87 + 88 + setAllHouses(housesWithHandles) 89 + } 90 + 91 + // Fetch user's fragrances owned by this house 92 + const fragsRes = await baseClient.social.drydown.fragrance.list({ repo: session.sub }) 93 + const ownedFrags = fragsRes.records 94 + .filter((f: any) => f.value.house === houseUri) 95 + .map((f: any) => ({ 96 + uri: f.uri, 97 + name: f.value.name 98 + })) 99 + setUserOwnedFragrances(ownedFrags) 100 } catch (err) { 101 console.error('Failed to load house', err) 102 setError('Failed to load house data. Please try again.') ··· 117 118 try { 119 const baseClient = new AtpBaseClient(session.fetchHandler.bind(session)) 120 + 121 // Fetch the existing record to get its CID and current data 122 + const currentRes = await baseClient.social.drydown.house.get({ 123 repo: session.sub, 124 rkey: rkey 125 }) 126 127 + const existingRecord = currentRes.value as any 128 129 + await baseClient.social.drydown.house.put({ 130 repo: session.sub, 131 + rkey 132 + }, { 133 + ...existingRecord, 134 + name: houseName.trim() 135 }) 136 137 // Redirect back to house page on success ··· 143 } 144 } 145 146 + async function handleMigrate() { 147 + if (!session || !selectedTargetHouseUri) return 148 + 149 + const targetHouse = allHouses.find(h => h.uri === selectedTargetHouseUri) 150 + if (!targetHouse) return 151 + 152 + setIsMigrating(true) 153 + try { 154 + const baseClient = new AtpBaseClient(session.fetchHandler.bind(session)) 155 + const fragranceUris = userOwnedFragrances.map(f => f.uri) 156 + 157 + const result = await bulkUpdateFragranceHouse( 158 + baseClient, 159 + session.sub, 160 + fragranceUris, 161 + selectedTargetHouseUri 162 + ) 163 + 164 + if (result.failedUris.length > 0) { 165 + const errorDetails = result.errors.join('\n') 166 + alert(`Partial migration: ${result.successCount} succeeded, ${result.failedUris.length} failed.\n\nErrors:\n${errorDetails}`) 167 + } else { 168 + alert(`Successfully migrated ${result.successCount} fragrance(s) to "${targetHouse.name}"`) 169 + } 170 + 171 + // Redirect to houses list 172 + setLocation(`/profile/${handle}/houses`) 173 + } catch (e) { 174 + console.error('Migration failed', e) 175 + const errorMsg = e instanceof Error ? e.message : 'Unknown error' 176 + setError(`Failed to migrate fragrances: ${errorMsg}`) 177 + } finally { 178 + setIsMigrating(false) 179 + setShowMigrateConfirmation(false) 180 + } 181 + } 182 + 183 + async function handleDelete() { 184 + if (!session) return 185 + 186 + const confirmed = window.confirm( 187 + `Delete "${houseName}"?\n\nThis action cannot be undone.` 188 + ) 189 + if (!confirmed) return 190 + 191 + try { 192 + const baseClient = new AtpBaseClient(session.fetchHandler.bind(session)) 193 + const houseUri = `at://${session.sub}/social.drydown.house/${rkey}` 194 + 195 + await deleteHouse(baseClient, session.sub, houseUri) 196 + 197 + alert('House deleted successfully') 198 + setLocation(`/profile/${handle}/houses`) 199 + } catch (e) { 200 + console.error('Failed to delete house', e) 201 + const errorMsg = e instanceof Error ? e.message : 'Unknown error' 202 + setError(`Failed to delete house: ${errorMsg}`) 203 + } 204 + } 205 + 206 if (isLoading) return <div className="page-container">Loading House...</div> 207 208 if (error) return ( ··· 256 </div> 257 </form> 258 259 + {/* Danger Zone */} 260 + <div style={{ 261 + marginTop: '3rem', 262 + padding: '1.5rem', 263 + border: '2px solid #dc2626', 264 + borderRadius: '8px', 265 + maxWidth: '600px' 266 + }}> 267 + <h3 style={{ marginTop: 0, color: '#dc2626', fontSize: '1.1rem' }}>Danger Zone</h3> 268 + 269 + {userOwnedFragrances.length > 0 ? ( 270 + <> 271 + <div style={{ marginBottom: '1rem' }}> 272 + <p style={{ fontSize: '0.9rem', color: '#666', marginBottom: '1rem' }}> 273 + You have <strong>{userOwnedFragrances.length} fragrance{userOwnedFragrances.length !== 1 ? 's' : ''}</strong> connected to this house. 274 + {allHouses.length > 0 ? ' Migrate them to another house before deleting.' : ' Delete them before deleting this house.'} 275 + </p> 276 + 277 + {!showMigrateConfirmation && allHouses.length > 0 && ( 278 + <div style={{ marginBottom: '1rem' }}> 279 + <Combobox 280 + label="Migrate fragrances to" 281 + placeholder="Search houses..." 282 + items={allHouses.map(h => ({ 283 + label: h.name, 284 + value: h.uri, 285 + avatar: h.avatar, 286 + subtitle: `@${h.ownerHandle}` 287 + }))} 288 + onSelect={(uri) => setSelectedTargetHouseUri(uri)} 289 + /> 290 + </div> 291 + )} 292 + 293 + {!showMigrateConfirmation && allHouses.length > 0 && ( 294 + <button 295 + onClick={() => setShowMigrateConfirmation(true)} 296 + disabled={!selectedTargetHouseUri || isMigrating} 297 + style={{ background: '#f59e0b', color: '#000', marginBottom: '1rem' }} 298 + > 299 + Migrate {userOwnedFragrances.length} Fragrance{userOwnedFragrances.length !== 1 ? 's' : ''} 300 + </button> 301 + )} 302 + 303 + {showMigrateConfirmation && ( 304 + <div style={{ 305 + background: '#fef3c7', 306 + padding: '1rem', 307 + borderRadius: '8px', 308 + marginBottom: '1rem' 309 + }}> 310 + <p style={{ marginTop: 0, fontWeight: 'bold' }}> 311 + Confirm Migration 312 + </p> 313 + <p style={{ fontSize: '0.85rem', marginBottom: '0.5rem' }}> 314 + Move {userOwnedFragrances.length} fragrance{userOwnedFragrances.length !== 1 ? 's' : ''} to "{allHouses.find(h => h.uri === selectedTargetHouseUri)?.name}" (@{allHouses.find(h => h.uri === selectedTargetHouseUri)?.ownerHandle})? 315 + </p> 316 + <ul style={{ fontSize: '0.85rem', marginBottom: '1rem', paddingLeft: '1.5rem' }}> 317 + {userOwnedFragrances.map((f, idx) => ( 318 + <li key={idx}>{f.name}</li> 319 + ))} 320 + </ul> 321 + <div style={{ display: 'flex', gap: '0.5rem' }}> 322 + <button 323 + onClick={handleMigrate} 324 + disabled={isMigrating} 325 + style={{ background: '#dc2626' }} 326 + > 327 + {isMigrating ? 'Migrating...' : 'Confirm Migration'} 328 + </button> 329 + <button 330 + onClick={() => setShowMigrateConfirmation(false)} 331 + disabled={isMigrating} 332 + style={{ background: 'transparent', color: 'inherit', border: '1px solid currentColor' }} 333 + > 334 + Cancel 335 + </button> 336 + </div> 337 + </div> 338 + )} 339 + </div> 340 + </> 341 + ) : ( 342 + <> 343 + <p style={{ fontSize: '0.9rem', color: '#666', marginBottom: '1rem' }}> 344 + This house has no fragrances. You can safely delete it. 345 + </p> 346 + <button 347 + onClick={handleDelete} 348 + style={{ background: '#dc2626' }} 349 + > 350 + Delete House 351 + </button> 352 + </> 353 + )} 354 + </div> 355 + 356 <AppDisclaimer variant="minimal" /> 357 + <Footer session={session} /> 358 </div> 359 ) 360 }
+207 -9
src/components/EditReview.tsx
··· 1 import { useState, useEffect } from 'preact/hooks' 2 import { AtpBaseClient } from '../client/index' 3 - import { calculateWeightedScore, encodeWeightedScore } from '../utils/reviewUtils' 4 import { resolveAtUri } from '../utils/atUriUtils' 5 import { AppDisclaimer } from './AppDisclaimer' 6 import type { OAuthSession } from '@atproto/oauth-client-browser' 7 import { WeatherService, type WeatherData } from '../services/weatherService' 8 - import { validateEditPermissions, validateStageUpdate } from '../utils/reviewValidation' 9 10 interface EditReviewProps { 11 session: OAuthSession ··· 15 } 16 17 export function EditReview({ session, reviewUri, onCancel, onSuccess }: EditReviewProps) { 18 const [review, setReview] = useState<any>(null) 19 const [editStage, setEditStage] = useState<'stage1' | 'stage2' | 'stage3'>('stage2') 20 const [fragranceName, setFragranceName] = useState<string>('') ··· 22 const [isLoading, setIsLoading] = useState(true) 23 const [isSubmitting, setIsSubmitting] = useState(false) 24 25 // Stage 1 state 26 const [openingRating, setOpeningRating] = useState<number>(0) 27 const [openingProjection, setOpeningProjection] = useState<number>(0) ··· 44 const [weatherLoading, setWeatherLoading] = useState(false) 45 const [weatherError, setWeatherError] = useState<string | null>(null) 46 47 const [atp, setAtp] = useState<AtpBaseClient | null>(null) 48 49 useEffect(() => { ··· 52 const baseClient = new AtpBaseClient(session.fetchHandler.bind(session)) 53 setAtp(baseClient) 54 55 const rkey = reviewUri.split('/').pop()! 56 const reviewData = await baseClient.social.drydown.review.get({ 57 repo: session.sub, ··· 63 // COMPUTE WHICH STAGE TO EDIT BASED ON PERMISSIONS 64 const permissions = validateEditPermissions(reviewValue) 65 66 - // After 24 hours, redirect back 67 - if (permissions.error) { 68 alert(permissions.error) 69 onCancel() 70 return ··· 110 setFragranceName('Unknown Fragrance') 111 setHouseName('Unknown House') 112 } 113 } catch (e) { 114 console.error('Failed to load review', e) 115 alert('Failed to load review. Please try again.') ··· 155 longevity, 156 overallRating, 157 text: text || undefined 158 } 159 160 // Add weather data if collected ··· 183 updatedReview 184 ) 185 186 return true 187 } catch (e) { 188 console.error('Failed to update review', e) ··· 221 async function handleSubmit(e: Event) { 222 e.preventDefault() 223 const success = await saveReview() 224 - if (success) onSuccess() 225 } 226 227 async function handleSaveAndShare(e: Event) { ··· 288 289 if (isLoading) return <div>Loading...</div> 290 291 const isStage1Valid = openingRating >= 1 && openingRating <= 5 && 292 openingProjection >= 1 && openingProjection <= 5 293 ··· 303 return ( 304 <div> 305 <h2>Update Review: {fragranceName}</h2> 306 - <button onClick={onCancel} style={{ marginBottom: '1rem' }}>Back</button> 307 308 {/* Show previous stage ratings (read-only) - only if not editing Stage 1 */} 309 {editStage !== 'stage1' && ( ··· 473 style={{ width: '100%', padding: '0.5rem' }} 474 /> 475 </div> 476 </> 477 ) : ( 478 <> ··· 564 565 <button 566 type="submit" 567 disabled={isSubmitting || (editStage === 'stage1' ? !isStage1Valid : editStage === 'stage2' ? !isStage2Valid : !isStage3Valid)} 568 > 569 - {isSubmitting ? 'Saving...' : (editStage === 'stage1' ? 'Save Stage 1' : editStage === 'stage2' ? 'Save Stage 2' : 'Complete Review')} 570 </button> 571 572 {editStage === 'stage3' && ( 573 <button 574 type="button" 575 onClick={handleSaveAndShare} 576 disabled={isSubmitting || !isStage3Valid} 577 - style={{ marginLeft: '1rem', background: '#0070ff' }} 578 > 579 - {isSubmitting ? 'Saving...' : 'Save + Share'} 580 </button> 581 )} 582 </form>
··· 1 import { useState, useEffect } from 'preact/hooks' 2 + import { useLocation } from 'wouter' 3 import { AtpBaseClient } from '../client/index' 4 + import { calculateWeightedScore, encodeWeightedScore, formatNotificationWindow } from '../utils/reviewUtils' 5 import { resolveAtUri } from '../utils/atUriUtils' 6 import { AppDisclaimer } from './AppDisclaimer' 7 import type { OAuthSession } from '@atproto/oauth-client-browser' 8 import { WeatherService, type WeatherData } from '../services/weatherService' 9 + import { validateEditPermissions, validateStageUpdate, canChangeFragrance } from '../utils/reviewValidation' 10 + import { updateReviewFragrance } from '../api/reviews' 11 + import { Combobox } from './Combobox' 12 + import { resolveIdentity } from '../utils/resolveIdentity' 13 + import { NotificationService } from '../services/notificationService' 14 15 interface EditReviewProps { 16 session: OAuthSession ··· 20 } 21 22 export function EditReview({ session, reviewUri, onCancel, onSuccess }: EditReviewProps) { 23 + const [, setLocation] = useLocation() 24 const [review, setReview] = useState<any>(null) 25 const [editStage, setEditStage] = useState<'stage1' | 'stage2' | 'stage3'>('stage2') 26 const [fragranceName, setFragranceName] = useState<string>('') ··· 28 const [isLoading, setIsLoading] = useState(true) 29 const [isSubmitting, setIsSubmitting] = useState(false) 30 31 + // Fragrance migration state 32 + const [userFragrances, setUserFragrances] = useState<Array<{uri: string, name: string, houseName: string}>>([]) 33 + const [selectedFragranceUri, setSelectedFragranceUri] = useState<string>('') 34 + const [showFragranceMigrationOnly, setShowFragranceMigrationOnly] = useState(false) 35 + const [userHandle, setUserHandle] = useState<string>('') 36 + 37 // Stage 1 state 38 const [openingRating, setOpeningRating] = useState<number>(0) 39 const [openingProjection, setOpeningProjection] = useState<number>(0) ··· 56 const [weatherLoading, setWeatherLoading] = useState(false) 57 const [weatherError, setWeatherError] = useState<string | null>(null) 58 59 + // Notification opt-in state 60 + const [notifyStage3, setNotifyStage3] = useState(false) 61 + 62 const [atp, setAtp] = useState<AtpBaseClient | null>(null) 63 64 useEffect(() => { ··· 67 const baseClient = new AtpBaseClient(session.fetchHandler.bind(session)) 68 setAtp(baseClient) 69 70 + // Resolve user handle 71 + const { profileData } = await resolveIdentity(session.sub) 72 + setUserHandle(profileData.handle) 73 + 74 const rkey = reviewUri.split('/').pop()! 75 const reviewData = await baseClient.social.drydown.review.get({ 76 repo: session.sub, ··· 82 // COMPUTE WHICH STAGE TO EDIT BASED ON PERMISSIONS 83 const permissions = validateEditPermissions(reviewValue) 84 85 + // After 24 hours, only allow fragrance migration 86 + if (permissions.error && canChangeFragrance(reviewValue)) { 87 + setShowFragranceMigrationOnly(true) 88 + } else if (permissions.error) { 89 alert(permissions.error) 90 onCancel() 91 return ··· 131 setFragranceName('Unknown Fragrance') 132 setHouseName('Unknown House') 133 } 134 + 135 + // Fetch all user's fragrances for fragrance selector 136 + try { 137 + const fragsResponse = await baseClient.social.drydown.fragrance.list({ 138 + repo: session.sub 139 + }) 140 + 141 + const fragrancesList = await Promise.all( 142 + fragsResponse.records.map(async (f: any) => { 143 + let houseName = 'Unknown House' 144 + if (f.value.house) { 145 + try { 146 + const houseRkey = f.value.house.split('/').pop()! 147 + const houseData = await baseClient.social.drydown.house.get({ 148 + repo: session.sub, 149 + rkey: houseRkey 150 + }) 151 + houseName = houseData.value.name 152 + } catch (e) { 153 + console.warn('Failed to fetch house for fragrance', e) 154 + } 155 + } 156 + return { 157 + uri: f.uri, 158 + name: f.value.name, 159 + houseName 160 + } 161 + }) 162 + ) 163 + setUserFragrances(fragrancesList) 164 + setSelectedFragranceUri(reviewValue.fragrance) 165 + } catch (e) { 166 + console.error('Failed to fetch user fragrances', e) 167 + } 168 } catch (e) { 169 console.error('Failed to load review', e) 170 alert('Failed to load review. Please try again.') ··· 210 longevity, 211 overallRating, 212 text: text || undefined 213 + } 214 + 215 + // Include fragrance change if selected (within 24h) 216 + if (selectedFragranceUri && selectedFragranceUri !== review.fragrance) { 217 + updates.fragrance = selectedFragranceUri 218 } 219 220 // Add weather data if collected ··· 243 updatedReview 244 ) 245 246 + // Handle notification opt-in for Stage 3 (client-side only) 247 + if (editStage === 'stage2' && notifyStage3) { 248 + const permissionGranted = await NotificationService.requestPermission() 249 + if (permissionGranted) { 250 + NotificationService.setPreference(reviewUri, 'stage3', true) 251 + } 252 + } 253 + 254 return true 255 } catch (e) { 256 console.error('Failed to update review', e) ··· 289 async function handleSubmit(e: Event) { 290 e.preventDefault() 291 const success = await saveReview() 292 + if (success) { 293 + // If fragrance was changed, navigate to new fragrance page 294 + if (selectedFragranceUri && selectedFragranceUri !== review.fragrance) { 295 + const fragranceRkey = selectedFragranceUri.split('/').pop()! 296 + setLocation(`/profile/${userHandle}/fragrance/${fragranceRkey}`) 297 + } else { 298 + onSuccess() 299 + } 300 + } 301 + } 302 + 303 + async function handleFragranceMigration() { 304 + if (!atp || !review || !selectedFragranceUri || selectedFragranceUri === review.fragrance) return 305 + 306 + const targetFragrance = userFragrances.find(f => f.uri === selectedFragranceUri) 307 + if (!targetFragrance) return 308 + 309 + const confirmed = window.confirm( 310 + `Reconnect this review from "${fragranceName}" to "${targetFragrance.name}"?\n\nThis action cannot be undone.` 311 + ) 312 + if (!confirmed) return 313 + 314 + try { 315 + setIsSubmitting(true) 316 + await updateReviewFragrance(atp, session.sub, reviewUri, selectedFragranceUri) 317 + 318 + // Navigate to new fragrance page 319 + const fragranceRkey = selectedFragranceUri.split('/').pop()! 320 + setLocation(`/profile/${userHandle}/fragrance/${fragranceRkey}`) 321 + } catch (e) { 322 + console.error('Failed to migrate review', e) 323 + const errorMsg = e instanceof Error ? e.message : 'Unknown error' 324 + alert(`Failed to reconnect review: ${errorMsg}`) 325 + } finally { 326 + setIsSubmitting(false) 327 + } 328 } 329 330 async function handleSaveAndShare(e: Event) { ··· 391 392 if (isLoading) return <div>Loading...</div> 393 394 + // Show fragrance migration UI only (past 24 hours) 395 + if (showFragranceMigrationOnly) { 396 + return ( 397 + <div> 398 + <h2>Reconnect Review</h2> 399 + <p style={{ marginBottom: '2rem', color: '#666' }}> 400 + This review is locked for editing after 24 hours, but you can 401 + reconnect it to a different fragrance. 402 + </p> 403 + 404 + <div style={{ marginBottom: '2rem' }}> 405 + <Combobox 406 + label="Select New Fragrance" 407 + placeholder="Search fragrances..." 408 + items={userFragrances.map(f => ({ 409 + label: f.name, 410 + value: f.uri, 411 + subtitle: f.houseName 412 + }))} 413 + onSelect={(uri) => setSelectedFragranceUri(uri)} 414 + /> 415 + {selectedFragranceUri && selectedFragranceUri !== review?.fragrance && ( 416 + <p style={{ fontSize: '0.85rem', color: '#0066cc', marginTop: '0.5rem' }}> 417 + Will reconnect from "{fragranceName}" to "{userFragrances.find(f => f.uri === selectedFragranceUri)?.name}" 418 + </p> 419 + )} 420 + </div> 421 + 422 + <button 423 + onClick={handleFragranceMigration} 424 + disabled={isSubmitting || !selectedFragranceUri || selectedFragranceUri === review?.fragrance} 425 + > 426 + {isSubmitting ? 'Reconnecting...' : 'Reconnect Review'} 427 + </button> 428 + <button onClick={onCancel} class="btn-secondary" style={{ marginLeft: '1rem' }}> 429 + cancel 430 + </button> 431 + 432 + <AppDisclaimer variant="minimal" /> 433 + </div> 434 + ) 435 + } 436 + 437 const isStage1Valid = openingRating >= 1 && openingRating <= 5 && 438 openingProjection >= 1 && openingProjection <= 5 439 ··· 449 return ( 450 <div> 451 <h2>Update Review: {fragranceName}</h2> 452 + <button onClick={onCancel} class="btn-secondary" style={{ marginBottom: '1rem' }}>back</button> 453 + 454 + {/* Fragrance Connection Selector */} 455 + {userFragrances.length > 0 && ( 456 + <div style={{ marginBottom: '2rem', padding: '1rem', background: '#f9f9f9', borderRadius: '8px' }}> 457 + <Combobox 458 + label="Fragrance Connection" 459 + placeholder="Search fragrances..." 460 + items={userFragrances.map(f => ({ 461 + label: f.name, 462 + value: f.uri, 463 + subtitle: f.houseName 464 + }))} 465 + onSelect={(uri) => setSelectedFragranceUri(uri)} 466 + /> 467 + {selectedFragranceUri && selectedFragranceUri !== review?.fragrance && ( 468 + <p style={{ fontSize: '0.85rem', color: '#0066cc', marginTop: '0.5rem' }}> 469 + Fragrance will be changed when you save 470 + </p> 471 + )} 472 + </div> 473 + )} 474 475 {/* Show previous stage ratings (read-only) - only if not editing Stage 1 */} 476 {editStage !== 'stage1' && ( ··· 640 style={{ width: '100%', padding: '0.5rem' }} 641 /> 642 </div> 643 + 644 + {/* Notification opt-in for Stage 3 */} 645 + {'Notification' in window && review && ( 646 + <div style={{ 647 + marginTop: '2rem', 648 + marginBottom: '1.5rem', 649 + padding: '1rem', 650 + background: '#f0f7ff', 651 + borderRadius: '8px', 652 + border: '1px solid #d0e4ff' 653 + }}> 654 + <label style={{ display: 'flex', alignItems: 'flex-start', cursor: 'pointer' }}> 655 + <input 656 + type="checkbox" 657 + checked={notifyStage3} 658 + onChange={(e) => setNotifyStage3((e.target as HTMLInputElement).checked)} 659 + style={{ marginRight: '0.75rem', marginTop: '0.25rem', flexShrink: 0 }} 660 + /> 661 + <div> 662 + <div style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}> 663 + Notify me when Final Review is ready 664 + </div> 665 + <div style={{ fontSize: '0.85rem', color: '#555', lineHeight: '1.4' }}> 666 + Get a notification when you can continue to Stage 3 ({formatNotificationWindow(review.createdAt, 'stage3')}) 667 + </div> 668 + </div> 669 + </label> 670 + </div> 671 + )} 672 </> 673 ) : ( 674 <> ··· 760 761 <button 762 type="submit" 763 + class="btn-primary" 764 disabled={isSubmitting || (editStage === 'stage1' ? !isStage1Valid : editStage === 'stage2' ? !isStage2Valid : !isStage3Valid)} 765 > 766 + {isSubmitting ? 'saving...' : (editStage === 'stage1' ? 'save stage 1' : editStage === 'stage2' ? 'save stage 2' : 'complete review')} 767 </button> 768 769 {editStage === 'stage3' && ( 770 <button 771 type="button" 772 + class="btn-accent" 773 onClick={handleSaveAndShare} 774 disabled={isSubmitting || !isStage3Valid} 775 + style={{ marginLeft: '1rem' }} 776 > 777 + {isSubmitting ? 'saving...' : 'save + share'} 778 </button> 779 )} 780 </form>
+2
src/components/ExplorePage.tsx
··· 2 import { Link, useLocation } from 'wouter' 3 import { SEO } from './SEO' 4 import { AppDisclaimer } from './AppDisclaimer' 5 import { ReviewCard, type AuthorInfo } from './ReviewCard' 6 import { resolveIdentity } from '../utils/resolveIdentity' 7 import { batchResolveAtUris } from '../utils/atUriUtils' ··· 152 )} 153 154 <AppDisclaimer variant="minimal" /> 155 </div> 156 ) 157 }
··· 2 import { Link, useLocation } from 'wouter' 3 import { SEO } from './SEO' 4 import { AppDisclaimer } from './AppDisclaimer' 5 + import { Footer } from './Footer' 6 import { ReviewCard, type AuthorInfo } from './ReviewCard' 7 import { resolveIdentity } from '../utils/resolveIdentity' 8 import { batchResolveAtUris } from '../utils/atUriUtils' ··· 153 )} 154 155 <AppDisclaimer variant="minimal" /> 156 + <Footer /> 157 </div> 158 ) 159 }
+36
src/components/Footer.tsx
···
··· 1 + import type { OAuthSession } from '@atproto/oauth-client-browser' 2 + 3 + interface FooterProps { 4 + session?: OAuthSession | null 5 + } 6 + 7 + export function Footer({ session }: FooterProps) { 8 + const userDid = session?.sub 9 + const userDataUrl = userDid ? `https://atproto.at/viewer?uri=${userDid}` : null 10 + 11 + return ( 12 + <footer className="app-footer"> 13 + <div className="footer-links"> 14 + <a 15 + href="https://bsky.app/profile/drydown.social" 16 + target="_blank" 17 + rel="noopener noreferrer" 18 + > 19 + @drydown.social 20 + </a> 21 + {userDataUrl && ( 22 + <> 23 + <span className="footer-separator">·</span> 24 + <a 25 + href={userDataUrl} 26 + target="_blank" 27 + rel="noopener noreferrer" 28 + > 29 + view where your data lives 30 + </a> 31 + </> 32 + )} 33 + </div> 34 + </footer> 35 + ) 36 + }
+10 -7
src/components/HousePage.tsx
··· 2 import { useLocation, Link } from 'wouter' 3 import type { OAuthSession } from '@atproto/oauth-client-browser' 4 import { SEO } from './SEO' 5 import { ReviewList } from './ReviewList' 6 import { resolveIdentity } from '../utils/resolveIdentity' 7 import { resolveAtUri } from '../utils/atUriUtils' ··· 27 const [error, setError] = useState<string | null>(null) 28 const [manager, setManager] = useState<{ handle: string, displayName?: string, avatar?: string } | null>(null) 29 const [houseDid, setHouseDid] = useState<string | null>(null) 30 - 31 // Analytics 32 const [totalRating, setTotalRating] = useState<number>(0) 33 const [avgProjection, setAvgProjection] = useState<number>(0) ··· 207 <h1>{houseName}</h1> 208 <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}> 209 {session && session.sub === houseDid && ( 210 - <Link 211 - href={`/profile/${handle}/house/${rkey}/edit`} 212 - className="interactive" 213 style={{ fontSize: '0.9rem', textDecoration: 'none', padding: '0.2rem 0.5rem', border: '1px solid var(--border-color)', borderRadius: '4px' }} 214 > 215 Edit House ··· 295 {reviews.length === 0 ? ( 296 <p>No reviews found for this house.</p> 297 ) : ( 298 - <ReviewList 299 - reviews={reviews} 300 - fragrances={fragrances} 301 onReviewClick={(review) => { 302 const authorDid = review.uri.split('/')[2] 303 const authorHandle = reviewers.get(authorDid)?.handle || authorDid ··· 308 }} 309 /> 310 )} 311 </div> 312 ) 313 }
··· 2 import { useLocation, Link } from 'wouter' 3 import type { OAuthSession } from '@atproto/oauth-client-browser' 4 import { SEO } from './SEO' 5 + import { Footer } from './Footer' 6 import { ReviewList } from './ReviewList' 7 import { resolveIdentity } from '../utils/resolveIdentity' 8 import { resolveAtUri } from '../utils/atUriUtils' ··· 28 const [error, setError] = useState<string | null>(null) 29 const [manager, setManager] = useState<{ handle: string, displayName?: string, avatar?: string } | null>(null) 30 const [houseDid, setHouseDid] = useState<string | null>(null) 31 + 32 // Analytics 33 const [totalRating, setTotalRating] = useState<number>(0) 34 const [avgProjection, setAvgProjection] = useState<number>(0) ··· 208 <h1>{houseName}</h1> 209 <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}> 210 {session && session.sub === houseDid && ( 211 + <Link 212 + href={`/profile/${handle}/house/${rkey}/edit`} 213 + className="interactive" 214 style={{ fontSize: '0.9rem', textDecoration: 'none', padding: '0.2rem 0.5rem', border: '1px solid var(--border-color)', borderRadius: '4px' }} 215 > 216 Edit House ··· 296 {reviews.length === 0 ? ( 297 <p>No reviews found for this house.</p> 298 ) : ( 299 + <ReviewList 300 + reviews={reviews} 301 + fragrances={fragrances} 302 onReviewClick={(review) => { 303 const authorDid = review.uri.split('/')[2] 304 const authorHandle = reviewers.get(authorDid)?.handle || authorDid ··· 309 }} 310 /> 311 )} 312 + 313 + <Footer session={session} /> 314 </div> 315 ) 316 }
+3
src/components/LandingPage.tsx
··· 3 import { SEO } from './SEO' 4 import { LoginForm } from './LoginForm' 5 import { AppDisclaimer } from './AppDisclaimer' 6 import { ReviewCard, type AuthorInfo } from './ReviewCard' 7 import { HouseCard } from './HouseCard' 8 import { resolveIdentity } from '../utils/resolveIdentity' ··· 242 )} 243 </> 244 )} 245 </div> 246 ) 247 }
··· 3 import { SEO } from './SEO' 4 import { LoginForm } from './LoginForm' 5 import { AppDisclaimer } from './AppDisclaimer' 6 + import { Footer } from './Footer' 7 import { ReviewCard, type AuthorInfo } from './ReviewCard' 8 import { HouseCard } from './HouseCard' 9 import { resolveIdentity } from '../utils/resolveIdentity' ··· 243 )} 244 </> 245 )} 246 + 247 + <Footer /> 248 </div> 249 ) 250 }
+1 -1
src/components/LoginForm.tsx
··· 90 prefix="@" 91 /> 92 {error && <p style={{ color: "red", textAlign: "center", marginTop: "0.5rem" }}>{error}</p>} 93 - <button type="submit" disabled={loading || !handle} class="login-form-button"> 94 {loading ? "redirecting..." : "sign in"} 95 </button> 96 <p class="login-form-providers">
··· 90 prefix="@" 91 /> 92 {error && <p style={{ color: "red", textAlign: "center", marginTop: "0.5rem" }}>{error}</p>} 93 + <button type="submit" disabled={loading || !handle} class="login-form-button btn-primary"> 94 {loading ? "redirecting..." : "sign in"} 95 </button> 96 <p class="login-form-providers">
+3
src/components/ProfileHousesPage.tsx
··· 2 import { Link } from 'wouter' 3 import { AtpBaseClient } from '../client/index' 4 import { SEO } from './SEO' 5 import { TabBar } from './TabBar' 6 import { resolveIdentity } from '../utils/resolveIdentity' 7 import { batchResolveAtUris } from '../utils/atUriUtils' ··· 290 </div> 291 ) 292 })()} 293 </div> 294 ) 295 }
··· 2 import { Link } from 'wouter' 3 import { AtpBaseClient } from '../client/index' 4 import { SEO } from './SEO' 5 + import { Footer } from './Footer' 6 import { TabBar } from './TabBar' 7 import { resolveIdentity } from '../utils/resolveIdentity' 8 import { batchResolveAtUris } from '../utils/atUriUtils' ··· 291 </div> 292 ) 293 })()} 294 + 295 + <Footer /> 296 </div> 297 ) 298 }
+3
src/components/ProfilePage.tsx
··· 3 import { useLocation, Link } from 'wouter' 4 import { AtpBaseClient } from '../client/index' 5 import { SEO } from './SEO' 6 import { ReviewList } from './ReviewList' 7 import { TabBar } from './TabBar' 8 import { resolveIdentity } from '../utils/resolveIdentity' ··· 288 }} 289 /> 290 )} 291 </div> 292 ) 293 }
··· 3 import { useLocation, Link } from 'wouter' 4 import { AtpBaseClient } from '../client/index' 5 import { SEO } from './SEO' 6 + import { Footer } from './Footer' 7 import { ReviewList } from './ReviewList' 8 import { TabBar } from './TabBar' 9 import { resolveIdentity } from '../utils/resolveIdentity' ··· 289 }} 290 /> 291 )} 292 + 293 + <Footer /> 294 </div> 295 ) 296 }
+15 -9
src/components/ReviewDashboard.tsx
··· 5 import type { OAuthSession } from '@atproto/oauth-client-browser' 6 import { cache, TTL } from '../services/cache' 7 import { getReviewActionState } from '../utils/reviewUtils' 8 9 interface ReviewDashboardProps { 10 session: OAuthSession ··· 34 // Don't notify on initial load for things already ready to avoid spam 35 notifiedStages.current[key] = true 36 } else if (!notifiedStages.current[key]) { 37 - notifiedStages.current[key] = true 38 - if ('Notification' in window && Notification.permission === 'granted') { 39 - const frag = fragrances.get(review.value.fragrance) 40 - if (frag) { 41 - new Notification('Optional Review Stage Available', { 42 - body: `You can now add more notes for ${frag.name} by ${frag.houseName || 'Unknown House'}.` 43 - }) 44 } 45 } 46 } ··· 104 return ( 105 <div> 106 <h2>Your Reviews</h2> 107 - <button onClick={onCreateNew} style={{ marginBottom: '2rem' }}> 108 - Create New Review 109 </button> 110 111 {isLoading ? (
··· 5 import type { OAuthSession } from '@atproto/oauth-client-browser' 6 import { cache, TTL } from '../services/cache' 7 import { getReviewActionState } from '../utils/reviewUtils' 8 + import { NotificationService } from '../services/notificationService' 9 10 interface ReviewDashboardProps { 11 session: OAuthSession ··· 35 // Don't notify on initial load for things already ready to avoid spam 36 notifiedStages.current[key] = true 37 } else if (!notifiedStages.current[key]) { 38 + // Check if user opted in for notifications for this specific review + stage 39 + const shouldNotify = NotificationService.shouldNotify(review.uri, action) 40 + 41 + if (shouldNotify) { 42 + notifiedStages.current[key] = true 43 + if ('Notification' in window && Notification.permission === 'granted') { 44 + const frag = fragrances.get(review.value.fragrance) 45 + if (frag) { 46 + new Notification('Optional Review Stage Available', { 47 + body: `You can now add more notes for ${frag.name} by ${frag.houseName || 'Unknown House'}.` 48 + }) 49 + } 50 } 51 } 52 } ··· 110 return ( 111 <div> 112 <h2>Your Reviews</h2> 113 + <button onClick={onCreateNew} class="btn-primary" style={{ marginBottom: '2rem' }}> 114 + create new review 115 </button> 116 117 {isLoading ? (
+3
src/components/SingleReviewPage.tsx
··· 3 import { Link, useLocation } from 'wouter' 4 import { AtpBaseClient } from '../client/index' 5 import { SEO } from '../components/SEO' 6 import { resolveIdentity } from '../utils/resolveIdentity' 7 import { resolveAtUri } from '../utils/atUriUtils' 8 import { getReviewActionState, getReviewDisplayScore } from '../utils/reviewUtils' ··· 432 </div> 433 )} 434 </div> 435 </div> 436 ) 437 }
··· 3 import { Link, useLocation } from 'wouter' 4 import { AtpBaseClient } from '../client/index' 5 import { SEO } from '../components/SEO' 6 + import { Footer } from '../components/Footer' 7 import { resolveIdentity } from '../utils/resolveIdentity' 8 import { resolveAtUri } from '../utils/atUriUtils' 9 import { getReviewActionState, getReviewDisplayScore } from '../utils/reviewUtils' ··· 433 </div> 434 )} 435 </div> 436 + 437 + <Footer session={session} /> 438 </div> 439 ) 440 }
+91
src/services/notificationService.ts
···
··· 1 + /** 2 + * Client-side notification preference management 3 + * Stores user's per-review notification opt-in choices in localStorage 4 + */ 5 + 6 + interface NotificationPreferences { 7 + [reviewUri: string]: { 8 + stage2?: boolean 9 + stage3?: boolean 10 + } 11 + } 12 + 13 + const STORAGE_KEY = 'drydown:notification-prefs' 14 + 15 + export class NotificationService { 16 + /** 17 + * Get notification preferences for a specific review 18 + */ 19 + static getPreferences(reviewUri: string): { stage2: boolean; stage3: boolean } { 20 + const prefs = this.getAllPreferences() 21 + const reviewPrefs = prefs[reviewUri] || {} 22 + return { 23 + stage2: reviewPrefs.stage2 || false, 24 + stage3: reviewPrefs.stage3 || false 25 + } 26 + } 27 + 28 + /** 29 + * Set notification preference for a specific review stage 30 + */ 31 + static setPreference(reviewUri: string, stage: 'stage2' | 'stage3', enabled: boolean): void { 32 + const prefs = this.getAllPreferences() 33 + 34 + if (!prefs[reviewUri]) { 35 + prefs[reviewUri] = {} 36 + } 37 + 38 + prefs[reviewUri][stage] = enabled 39 + 40 + localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)) 41 + } 42 + 43 + /** 44 + * Check if user should be notified for a specific review + stage 45 + */ 46 + static shouldNotify(reviewUri: string, stage: 'stage2' | 'stage3'): boolean { 47 + const prefs = this.getPreferences(reviewUri) 48 + return prefs[stage] || false 49 + } 50 + 51 + /** 52 + * Request browser notification permission if needed 53 + * Returns true if permission is granted or was already granted 54 + */ 55 + static async requestPermission(): Promise<boolean> { 56 + if (!('Notification' in window)) { 57 + return false 58 + } 59 + 60 + if (Notification.permission === 'granted') { 61 + return true 62 + } 63 + 64 + if (Notification.permission === 'default') { 65 + const result = await Notification.requestPermission() 66 + return result === 'granted' 67 + } 68 + 69 + return false 70 + } 71 + 72 + /** 73 + * Get all stored notification preferences 74 + */ 75 + private static getAllPreferences(): NotificationPreferences { 76 + try { 77 + const stored = localStorage.getItem(STORAGE_KEY) 78 + return stored ? JSON.parse(stored) : {} 79 + } catch (e) { 80 + console.error('Failed to parse notification preferences', e) 81 + return {} 82 + } 83 + } 84 + 85 + /** 86 + * Clear all notification preferences (for testing/debugging) 87 + */ 88 + static clearAll(): void { 89 + localStorage.removeItem(STORAGE_KEY) 90 + } 91 + }
+24
src/utils/reviewUtils.ts
··· 169 // Fallback: calculate from available ratings 170 return calculateWeightedScore(review) 171 }
··· 169 // Fallback: calculate from available ratings 170 return calculateWeightedScore(review) 171 } 172 + 173 + /** 174 + * Format notification time window for a specific stage 175 + * Returns formatted text like "in 1.5-4 hours" or specific times 176 + */ 177 + export function formatNotificationWindow(createdAt: string, stage: 'stage2' | 'stage3'): string { 178 + const created = new Date(createdAt) 179 + 180 + if (stage === 'stage2') { 181 + const start = new Date(created.getTime() + 1.5 * 60 * 60 * 1000) 182 + const end = new Date(created.getTime() + 4 * 60 * 60 * 1000) 183 + 184 + const startTime = start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) 185 + const endTime = end.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) 186 + 187 + return `${startTime} - ${endTime}` 188 + } else { 189 + // Stage 3: starts at 4 hours, no end time 190 + const start = new Date(created.getTime() + 4 * 60 * 60 * 1000) 191 + const startTime = start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) 192 + 193 + return `after ${startTime}` 194 + } 195 + }
+8
src/utils/reviewValidation.ts
··· 108 109 return { valid: true } 110 }
··· 108 109 return { valid: true } 110 } 111 + 112 + /** 113 + * Check if review's fragrance connection can be changed 114 + * This is allowed at ANY time, even after 24 hours when other edits are locked 115 + */ 116 + export function canChangeFragrance(_review: any): boolean { 117 + return true // Always allowed 118 + }
+28
src/validation/cascade.ts
··· 19 20 return { canDelete: true } 21 }
··· 19 20 return { canDelete: true } 21 } 22 + 23 + /** 24 + * Get count of fragrances owned by user that reference a house 25 + */ 26 + export function getOwnedFragranceCount( 27 + houseUri: AtUri, 28 + fragrances: Fragrance[], 29 + ownerDid: string 30 + ): number { 31 + return fragrances.filter(f => { 32 + const [, , fragDid] = (f.uri || '').split('/') 33 + return f.house === houseUri && fragDid === ownerDid 34 + }).length 35 + } 36 + 37 + /** 38 + * Get all fragrances owned by user that reference a house 39 + */ 40 + export function getOwnedFragrancesForHouse( 41 + houseUri: AtUri, 42 + fragrances: Fragrance[], 43 + ownerDid: string 44 + ): Fragrance[] { 45 + return fragrances.filter(f => { 46 + const [, , fragDid] = (f.uri || '').split('/') 47 + return f.house === houseUri && fragDid === ownerDid 48 + }) 49 + }