From 8c941b52f9d6e4c50a703b9558b16322fd1cd309 Mon Sep 17 00:00:00 2001 From: Azalea Date: Sun, 21 Jun 2026 07:43:39 +0000 Subject: [PATCH] [+] Unsplash --- client/src/api/client.ts | 3 +- .../components/Trips/TripFormModal.test.tsx | 39 +++++- client/src/components/Trips/TripFormModal.tsx | 115 +++++++++++++++++- client/src/pages/TripPlannerPage.tsx | 8 +- client/vite.config.js | 20 +-- server/src/nest/trips/trips.controller.ts | 21 ++++ server/src/nest/trips/trips.service.ts | 5 + server/src/services/placeService.ts | 32 +---- server/src/services/unsplashService.ts | 69 +++++++++++ .../tests/unit/services/placeService.test.ts | 21 +++- shared/src/i18n/en/dashboard.ts | 7 ++ 11 files changed, 288 insertions(+), 52 deletions(-) create mode 100644 server/src/services/unsplashService.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index b21832cb..4df17911 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -333,6 +333,7 @@ export const tripsApi = { update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data), delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data), uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), + searchCoverImages: (query: string) => apiClient.get('/trips/cover-images/search', { params: { query } }).then(r => r.data), archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data), unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data), getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data), @@ -808,4 +809,4 @@ export const inAppNotificationsApi = { apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data), } -export default apiClient \ No newline at end of file +export default apiClient diff --git a/client/src/components/Trips/TripFormModal.test.tsx b/client/src/components/Trips/TripFormModal.test.tsx index 3c8b390d..a744e5a3 100644 --- a/client/src/components/Trips/TripFormModal.test.tsx +++ b/client/src/components/Trips/TripFormModal.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-028 +// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-031 import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -310,4 +310,41 @@ describe('TripFormModal', () => { await screen.findByText('Number of days is required'); expect(onSave).not.toHaveBeenCalled(); }); + + it('FE-COMP-TRIPFORM-031: selects an Unsplash cover and saves it after trip creation', async () => { + const user = userEvent.setup(); + const onSave = vi.fn().mockResolvedValue({ trip: buildTrip({ id: 99 }) }); + let updateBody: unknown; + server.use( + http.get('/api/trips/cover-images/search', () => + HttpResponse.json({ + photos: [{ + id: 'unsplash-1', + url: 'https://images.example.com/regular.jpg', + thumb: 'https://images.example.com/thumb.jpg', + description: 'Mountain lake', + photographer: 'Alice', + link: 'https://unsplash.com/photos/unsplash-1', + }], + }) + ), + http.put('/api/trips/99', async ({ request }) => { + updateBody = await request.json(); + return HttpResponse.json({ trip: buildTrip({ id: 99, cover_image: 'https://images.example.com/regular.jpg' }) }); + }), + ); + + render(); + await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Alpine Trip'); + await user.type(screen.getByPlaceholderText('Search destination photos'), 'alps'); + await user.click(screen.getByRole('button', { name: /Search Unsplash/i })); + await user.click(await screen.findByRole('button', { name: /Use Unsplash photo by Alice/i })); + + const submitBtn = screen.getAllByText('Create New Trip').find(el => el.closest('button'))!; + await user.click(submitBtn.closest('button')!); + + await waitFor(() => { + expect(updateBody).toMatchObject({ cover_image: 'https://images.example.com/regular.jpg' }); + }); + }); }); diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx index 291428b1..b08f9186 100644 --- a/client/src/components/Trips/TripFormModal.tsx +++ b/client/src/components/Trips/TripFormModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' import Modal from '../shared/Modal' -import { Calendar, Camera, X, Clipboard, UserPlus, Bell } from 'lucide-react' +import { Calendar, Camera, Search, X, UserPlus, Bell } from 'lucide-react' import { tripsApi, authApi } from '../../api/client' import CustomSelect from '../shared/CustomSelect' import { useAuthStore } from '../../store/authStore' @@ -9,7 +9,7 @@ import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { CustomDatePicker } from '../shared/CustomDateTimePicker' import { normalizeImageFile } from '../../utils/convertHeic' -import type { Trip } from '../../types' +import { getApiErrorMessage, type Trip } from '../../types' import type { TripCreateRequest } from '@trek/shared' interface TripFormModalProps { @@ -22,6 +22,15 @@ interface TripFormModalProps { onCoverUpdate?: (tripId: number, coverUrl: string | null) => void } +interface CoverSearchPhoto { + id: string + url: string + thumb: string + description?: string | null + photographer?: string | null + link?: string | null +} + export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }: TripFormModalProps) { const isEditing = !!trip const fileRef = useRef(null) @@ -45,9 +54,14 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp const [customReminder, setCustomReminder] = useState(false) const [error, setError] = useState('') const [isLoading, setIsLoading] = useState(false) - const [coverPreview, setCoverPreview] = useState(null) - const [pendingCoverFile, setPendingCoverFile] = useState(null) + const [coverPreview, setCoverPreview] = useState(null) + const [pendingCoverFile, setPendingCoverFile] = useState(null) + const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState(null) const [uploadingCover, setUploadingCover] = useState(false) + const [coverSearchQuery, setCoverSearchQuery] = useState('') + const [coverSearchResults, setCoverSearchResults] = useState([]) + const [coverSearchError, setCoverSearchError] = useState('') + const [searchingCover, setSearchingCover] = useState(false) const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([]) const [selectedMembers, setSelectedMembers] = useState([]) const [existingMembers, setExistingMembers] = useState<{ id: number; username: string }[]>([]) @@ -66,12 +80,17 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp }) setCustomReminder(![0, 1, 3, 9].includes(rd)) setCoverPreview(trip.cover_image || null) + setCoverSearchQuery('') } else { setFormData({ title: '', description: '', start_date: '', end_date: '', reminder_days: tripRemindersEnabled ? 3 : 0, day_count: 7 }) setCustomReminder(false) setCoverPreview(null) + setCoverSearchQuery('') } setPendingCoverFile(null) + setPendingUnsplashUrl(null) + setCoverSearchResults([]) + setCoverSearchError('') setSelectedMembers([]) setError('') if (isOpen) { @@ -139,6 +158,13 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp // Cover upload failed but trip was created — surface it without blocking the create toast.error(t('dashboard.coverUploadError')) } + } else if (pendingUnsplashUrl && createdTrip?.id) { + try { + await tripsApi.update(createdTrip.id, { cover_image: pendingUnsplashUrl }) + onCoverUpdate?.(createdTrip.id, pendingUnsplashUrl) + } catch { + toast.error(t('dashboard.coverSaveError')) + } } onClose() } catch (err: unknown) { @@ -152,6 +178,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp if (!file) return // HEIC/HEIF from iOS can't be rendered or stored as-is — convert to JPEG first const normalized = await normalizeImageFile(file) + setPendingUnsplashUrl(null) if (isEditing && trip?.id) { // Existing trip: upload immediately uploadCoverNow(normalized) @@ -183,9 +210,51 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp } } + const handleCoverSearch = async () => { + const query = coverSearchQuery.trim() || formData.title.trim() + if (!query) { + setCoverSearchError(t('dashboard.unsplashQueryRequired')) + return + } + setSearchingCover(true) + setCoverSearchError('') + try { + const data = await tripsApi.searchCoverImages(query) + const photos = data.photos || [] + setCoverSearchResults(photos) + if (photos.length === 0) setCoverSearchError(t('dashboard.unsplashNoResults')) + } catch (err: unknown) { + setCoverSearchError(getApiErrorMessage(err, t('dashboard.coverSearchError'))) + } finally { + setSearchingCover(false) + } + } + + const handleUnsplashSelect = async (photo: CoverSearchPhoto) => { + if (!photo.url) return + setPendingCoverFile(null) + if (isEditing && trip?.id) { + setUploadingCover(true) + try { + await tripsApi.update(trip.id, { cover_image: photo.url }) + setCoverPreview(photo.url) + onCoverUpdate?.(trip.id, photo.url) + toast.success(t('dashboard.coverSaved')) + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, t('dashboard.coverSaveError'))) + } finally { + setUploadingCover(false) + } + } else { + setPendingUnsplashUrl(photo.url) + setCoverPreview(photo.url) + } + } + const handleRemoveCover = async () => { - if (pendingCoverFile) { + if (pendingCoverFile || pendingUnsplashUrl) { setPendingCoverFile(null) + setPendingUnsplashUrl(null) setCoverPreview(null) return } @@ -288,6 +357,42 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')} )} +
+ setCoverSearchQuery(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleCoverSearch() } }} + placeholder={t('dashboard.unsplashSearchPlaceholder')} + className={inputCls} + /> + +
+ {coverSearchError &&

{coverSearchError}

} + {coverSearchResults.length > 0 && ( +
+ {coverSearchResults.map(photo => ( + + ))} +
+ )} }
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 268e781a..7db2f8f2 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -697,7 +697,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
{ setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} /> - setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} /> + setShowTripForm(false)} + onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} + trip={trip} + onCoverUpdate={(_, coverUrl) => useTripStore.setState(state => ({ trip: state.trip ? { ...state.trip, cover_image: coverUrl } : state.trip }))} + /> setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} /> { if (importReviewActive) { advanceImportReview() } else { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) } }} onSave={async (data) => { const r = await handleSaveReservation(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingReservation} prefill={reservationPrefill} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} /> {showTransportModal && { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />} diff --git a/client/vite.config.js b/client/vite.config.js index 35f486b8..1c6d8d4e 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -2,6 +2,8 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { VitePWA } from 'vite-plugin-pwa' +const backendTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3001' + export default defineConfig({ plugins: [ react(), @@ -126,42 +128,42 @@ export default defineConfig({ port: 5173, proxy: { '/api': { - target: 'http://localhost:3001', + target: backendTarget, changeOrigin: true, }, '/uploads': { - target: 'http://localhost:3001', + target: backendTarget, changeOrigin: true, }, '/ws': { - target: 'http://localhost:3001', + target: backendTarget, ws: true, }, '/mcp': { - target: 'http://localhost:3001', + target: backendTarget, changeOrigin: true, }, // OAuth 2.1 endpoints handled by backend (SDK authorize handler + token/revoke) // /oauth/authorize goes to backend so the SDK can redirect to /oauth/consent // /oauth/consent is served by Vite as a SPA route (no proxy entry needed) '/oauth/authorize': { - target: 'http://localhost:3001', + target: backendTarget, changeOrigin: true, }, '/oauth/token': { - target: 'http://localhost:3001', + target: backendTarget, changeOrigin: true, }, '/oauth/register': { - target: 'http://localhost:3001', + target: backendTarget, changeOrigin: true, }, '/oauth/revoke': { - target: 'http://localhost:3001', + target: backendTarget, changeOrigin: true, }, '/.well-known': { - target: 'http://localhost:3001', + target: backendTarget, changeOrigin: true, }, } diff --git a/server/src/nest/trips/trips.controller.ts b/server/src/nest/trips/trips.controller.ts index 749b2b07..a82ead1d 100644 --- a/server/src/nest/trips/trips.controller.ts +++ b/server/src/nest/trips/trips.controller.ts @@ -71,6 +71,21 @@ export class TripsController { return { trips: this.trips.list(user.id, archived === '1' ? 1 : 0) }; } + @Get('cover-images/search') + async coverImages(@CurrentUser() user: User, @Query('query') query?: string) { + try { + const result = await this.trips.searchCoverImages(user.id, query || ''); + if ('error' in result) { + throw new HttpException({ error: result.error }, result.status); + } + return { photos: result.photos }; + } catch (err: unknown) { + if (err instanceof HttpException) throw err; + console.error('Unsplash cover image error:', err); + throw new HttpException({ error: 'Error searching for cover images' }, 500); + } + } + @Post() @HttpCode(201) create(@CurrentUser() user: User, @Body() body: Record, @Req() req: Request) { @@ -122,8 +137,14 @@ export class TripsController { if (editFields.some((f) => body[f] !== undefined) && !this.trips.can('trip_edit', user.role, ownerId, user.id, isMember)) { throw new HttpException({ error: 'No permission to edit this trip' }, 403); } + const oldCover = body.cover_image !== undefined + ? (this.trips.getRaw(id) as { cover_image: string | null } | undefined)?.cover_image + : undefined; try { const result = this.trips.update(id, user.id, body, user.role); + if (body.cover_image !== undefined && body.cover_image !== oldCover) { + this.trips.deleteOldCover(oldCover); + } if (Object.keys(result.changes).length > 0) { writeAudit({ userId: user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(id), trip: result.newTitle, ...(result.ownerEmail ? { owner: result.ownerEmail } : {}), ...result.changes } }); if (result.isAdminEdit && result.ownerEmail) logInfo(`Admin ${user.email} edited trip "${result.newTitle}" owned by ${result.ownerEmail}`); diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index f0fa8a09..34ac2d18 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -11,6 +11,7 @@ import { listItems as listTodoItems } from '../../services/todoService'; import { listBudgetItems } from '../../services/budgetService'; import { listReservations } from '../../services/reservationService'; import { listFiles } from '../../services/fileService'; +import { searchUnsplashPhotos } from '../../services/unsplashService'; /** * Thin Nest wrapper around the existing trip service + the per-domain list @@ -49,6 +50,10 @@ export class TripsService { return tripSvc.getTripRaw(tripId); } + searchCoverImages(userId: number, query: string) { + return searchUnsplashPhotos(userId, query, 9); + } + getOwner(tripId: string) { return tripSvc.getTripOwner(tripId); } diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts index 0f8c51a8..a63b2c1a 100644 --- a/server/src/services/placeService.ts +++ b/server/src/services/placeService.ts @@ -15,6 +15,7 @@ import { } from './kmlImport'; import { enrichImportedPlaces, type EnrichablePlace } from './placeEnrichment'; import * as placePhotoCache from './placePhotoCache'; +import { searchUnsplashPhotos } from './unsplashService'; // Reclaim a deleted place's cached marker photo if nothing else references it. // The cache key is the Google place_id, or — for coordinate-only places — the @@ -42,11 +43,6 @@ interface PlaceWithCategory extends Place { category_icon: string | null; } -interface UnsplashSearchResponse { - results?: { id: string; urls?: { regular?: string; thumb?: string }; description?: string; alt_description?: string; user?: { name?: string }; links?: { html?: string } }[]; - errors?: string[]; -} - export interface PlaceImportResult { places: any[]; count: number; @@ -947,29 +943,5 @@ export async function searchPlaceImage(tripId: string, placeId: string, userId: const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Place | undefined; if (!place) return { error: 'Place not found', status: 404 }; - const user = db.prepare('SELECT unsplash_api_key FROM users WHERE id = ?').get(userId) as { unsplash_api_key: string | null } | undefined; - if (!user || !user.unsplash_api_key) { - return { error: 'No Unsplash API key configured', status: 400 }; - } - - const query = encodeURIComponent(place.name + (place.address ? ' ' + place.address : '')); - const response = await fetch( - `https://api.unsplash.com/search/photos?query=${query}&per_page=5&client_id=${user.unsplash_api_key}`, - ); - const data = await response.json() as UnsplashSearchResponse; - - if (!response.ok) { - return { error: data.errors?.[0] || 'Unsplash API error', status: response.status }; - } - - const photos = (data.results || []).map((p: NonNullable[number]) => ({ - id: p.id, - url: p.urls?.regular, - thumb: p.urls?.thumb, - description: p.description || p.alt_description, - photographer: p.user?.name, - link: p.links?.html, - })); - - return { photos }; + return searchUnsplashPhotos(userId, place.name + (place.address ? ' ' + place.address : ''), 5); } diff --git a/server/src/services/unsplashService.ts b/server/src/services/unsplashService.ts new file mode 100644 index 00000000..1709a586 --- /dev/null +++ b/server/src/services/unsplashService.ts @@ -0,0 +1,69 @@ +interface UnsplashSearchResponse { + results?: { + id: string; + urls?: { regular?: string; small?: string; thumb?: string }; + description?: string | null; + alt_description?: string | null; + user?: { name?: string }; + links?: { html?: string }; + }[]; + errors?: string[]; + error?: string; +} + +export interface UnsplashPhoto { + id: string; + url: string; + thumb: string; + description: string | null; + photographer: string | null; + link: string | null; +} + +export async function searchUnsplashPhotos(_userId: number, query: string, perPage = 9) { + const trimmed = query.trim(); + if (!trimmed) { + return { error: 'Search query is required', status: 400 }; + } + + const params = new URLSearchParams({ + page: '1', + query: trimmed, + per_page: String(perPage), + }); + const response = await fetch(`https://unsplash.com/napi/search/photos?${params.toString()}`, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0', + Accept: '*/*', + 'Accept-Language': 'en-US', + Referer: `https://unsplash.com/s/photos/${encodeURIComponent(trimmed)}`, + 'client-geo-region': 'global', + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Site': 'same-origin', + }, + }); + let data: UnsplashSearchResponse; + try { + data = await response.json() as UnsplashSearchResponse; + } catch { + return { error: 'Unsplash search unavailable', status: response.ok ? 502 : response.status }; + } + + if (!response.ok) { + return { error: data.errors?.[0] || data.error || 'Unsplash search unavailable', status: response.status }; + } + + const photos: UnsplashPhoto[] = (data.results || []) + .map((p) => ({ + id: p.id, + url: p.urls?.regular || '', + thumb: p.urls?.small || p.urls?.thumb || p.urls?.regular || '', + description: p.description || p.alt_description || null, + photographer: p.user?.name || null, + link: p.links?.html || null, + })) + .filter((p) => p.url && p.thumb) + .slice(0, perPage); + + return { photos }; +} diff --git a/server/tests/unit/services/placeService.test.ts b/server/tests/unit/services/placeService.test.ts index ad0b0016..4bf54f6b 100644 --- a/server/tests/unit/services/placeService.test.ts +++ b/server/tests/unit/services/placeService.test.ts @@ -1,7 +1,7 @@ /** * Unit tests for placeService — PLACE-SVC-001 through PLACE-SVC-025. * Uses a real in-memory SQLite DB so SQL logic is exercised faithfully. - * Skips importGpx / importGoogleList / searchPlaceImage (require external I/O). + * External fetches are mocked where needed. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -541,20 +541,31 @@ describe('searchPlaceImage', () => { expect(result.status).toBe(404); }); - it('PLACE-SVC-031 — returns 400 when user has no Unsplash API key', async () => { + it('PLACE-SVC-031 — searches Unsplash without a stored API key', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + results: [ + { id: 'photo1', urls: { regular: 'https://img.example.com/1', thumb: 'https://img.example.com/t1' }, description: 'Tower', user: { name: 'Photographer' }, links: { html: 'https://unsplash.com/1' } }, + ], + }), + status: 200, + })); + const result = await searchPlaceImage(String(trip.id), String(place.id), user.id) as any; - expect(result.error).toMatch(/No Unsplash API key/); - expect(result.status).toBe(400); + expect(result.photos).toHaveLength(1); + const [url] = (fetch as any).mock.calls[0]; + expect(url).toContain('https://unsplash.com/napi/search/photos?'); + expect(url).not.toContain('client_id='); }); it('PLACE-SVC-032 — returns photos when Unsplash API responds successfully', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any; - testDb.prepare('UPDATE users SET unsplash_api_key = ? WHERE id = ?').run('test-unsplash-key', user.id); const mockPhotos = [ { id: 'photo1', urls: { regular: 'https://img.example.com/1', thumb: 'https://img.example.com/t1' }, description: 'Tower', user: { name: 'Photographer' }, links: { html: 'https://unsplash.com/1' } }, diff --git a/shared/src/i18n/en/dashboard.ts b/shared/src/i18n/en/dashboard.ts index ac6eeaba..30d91562 100644 --- a/shared/src/i18n/en/dashboard.ts +++ b/shared/src/i18n/en/dashboard.ts @@ -88,7 +88,14 @@ const dashboard: TranslationStrings = { 'dashboard.addMember': 'Add member', 'dashboard.coverSaved': 'Cover image saved', 'dashboard.coverUploadError': 'Failed to upload', + 'dashboard.coverSaveError': 'Failed to save cover image', 'dashboard.coverRemoveError': 'Failed to remove', + 'dashboard.searchUnsplash': 'Search Unsplash', + 'dashboard.unsplashSearchPlaceholder': 'Search destination photos', + 'dashboard.unsplashQueryRequired': 'Enter a search term', + 'dashboard.unsplashNoResults': 'No images found', + 'dashboard.coverSearchError': 'Failed to search Unsplash', + 'dashboard.useUnsplashPhoto': 'Use Unsplash photo by {photographer}', 'dashboard.titleRequired': 'Title is required', 'dashboard.endDateError': 'End date must be after start date', 'dashboard.greeting.morning': 'Good morning,',