diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 00000000..11215320 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,5 @@ +# Playwright E2E (FE7) +e2e/.tmp/ +test-results/ +playwright-report/ +playwright/.cache/ diff --git a/client/e2e/auth.setup.ts b/client/e2e/auth.setup.ts new file mode 100644 index 00000000..46a34229 --- /dev/null +++ b/client/e2e/auth.setup.ts @@ -0,0 +1,42 @@ +import { test as setup, expect } from '@playwright/test' + +// Relative to the config dir (client/), matching `storageState` in +// playwright.config.ts. Playwright runs from the client workspace root. +const stateFile = 'e2e/.tmp/state.json' + +// Credentials match e2e/server-launch.mjs (ADMIN_EMAIL/ADMIN_PASSWORD). The +// seeded admin is created with must_change_password=1, so the first login goes +// through the forced change-password step before reaching the dashboard. +const EMAIL = 'e2e@trek.local' +const SEED_PW = 'E2eTest12345!' +const NEW_PW = 'E2eChanged12345!' + +setup('authenticate the seeded admin (incl. forced password change)', async ({ page }) => { + await page.goto('/login') + await page.locator('input[type="email"]').fill(EMAIL) + await page.locator('input[type="password"]').fill(SEED_PW) + await page.locator('button[type="submit"]').click() + + // must_change_password=1 → the change-password step renders two password + // fields (new + confirm). Selector-agnostic of the UI language. + const pw = page.locator('input[type="password"]') + await expect(pw).toHaveCount(2) + await pw.nth(0).fill(NEW_PW) + await pw.nth(1).fill(NEW_PW) + await page.locator('button[type="submit"]').click() + + await page.waitForURL('**/dashboard', { timeout: 30_000 }) + + // Dismiss the first-run "Welcome to TREK" system-notice modal(s). It renders + // asynchronously (after the notices fetch), so wait for it before clicking. + // Dismissal is recorded server-side against this user, so clearing it here + // keeps it cleared for every authenticated flow in the run (shared test DB). + const ok = page.getByRole('button', { name: 'OK', exact: true }) + await ok.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {}) + for (let i = 0; i < 8 && (await ok.isVisible().catch(() => false)); i++) { + await ok.click() + await page.waitForTimeout(400) + } + + await page.context().storageState({ path: stateFile }) +}) diff --git a/client/e2e/create-trip.spec.ts b/client/e2e/create-trip.spec.ts new file mode 100644 index 00000000..6c264c92 --- /dev/null +++ b/client/e2e/create-trip.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from '@playwright/test' + +// Trip lifecycle (core): from the dashboard, open the new-trip modal, name the +// trip, submit, and confirm it shows up on the dashboard. Exercises the whole +// authenticated stack — dashboard → TripFormModal → POST /api/trips → store → +// re-render — against the real backend + isolated test DB. +test('create a trip and see it on the dashboard', async ({ page }) => { + await page.goto('/dashboard') + + // The "+ New Trip" card is always rendered in the default (planned) filter. + await page.locator('.add-trip-card').click() + + // Scope to the shared Modal (.modal-backdrop). Its form has no in-form submit + // button (the primary action lives in the footer), so click it explicitly + // rather than pressing Enter. The Create button is the slate primary button; + // Cancel is the bordered one. + const modal = page.locator('.modal-backdrop') + await expect(modal).toBeVisible() + + const title = `E2E Trip ${Date.now()}` + await modal.locator('input[type="text"]').first().fill(title) + await modal.getByRole('button', { name: 'Create New Trip' }).click() + + await expect(page.getByText(title).first()).toBeVisible({ timeout: 15_000 }) +}) diff --git a/client/e2e/dashboard.spec.ts b/client/e2e/dashboard.spec.ts new file mode 100644 index 00000000..d906e34a --- /dev/null +++ b/client/e2e/dashboard.spec.ts @@ -0,0 +1,10 @@ +import { test, expect } from '@playwright/test' + +// Authenticated smoke: the stored session lands on the dashboard and the +// app chrome (navbar) renders instead of bouncing back to /login. +test('authenticated session reaches the dashboard', async ({ page }) => { + await page.goto('/dashboard') + await expect(page).toHaveURL(/\/dashboard/) + // The shared Navbar shows the TREK brand once authenticated. + await expect(page.getByRole('img', { name: 'TREK' }).first()).toBeVisible() +}) diff --git a/client/e2e/login.public.spec.ts b/client/e2e/login.public.spec.ts new file mode 100644 index 00000000..36fc67d3 --- /dev/null +++ b/client/e2e/login.public.spec.ts @@ -0,0 +1,8 @@ +import { test, expect } from '@playwright/test' + +// Infra smoke + first unauthenticated flow: the app boots, the backend is +// reachable through the Vite proxy, and the login screen renders its form. +test('login screen renders with a password field', async ({ page }) => { + await page.goto('/login') + await expect(page.locator('input[type="password"]')).toBeVisible() +}) diff --git a/client/e2e/server-launch.mjs b/client/e2e/server-launch.mjs new file mode 100644 index 00000000..c9cd1067 --- /dev/null +++ b/client/e2e/server-launch.mjs @@ -0,0 +1,43 @@ +// Boots the TREK backend for the Playwright E2E run against a fresh, isolated +// SQLite database. The DB file is deleted first so every run starts clean, then +// the server's own startup seeds a known admin from ADMIN_EMAIL/ADMIN_PASSWORD. +// +// The server is built once and launched as a SINGLE node process (not the +// watch-mode `npm run dev`, which spawns tsc -w + node --watch grandchildren +// that survive Playwright's teardown and then linger on :3001 with stale DB +// state). A single child is killed cleanly when Playwright tears the run down. +import { rmSync } from 'node:fs' +import { spawn, execSync } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const dbFile = path.join(here, '.tmp', 'e2e.db') +const serverDir = path.join(here, '..', '..', 'server') + +for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) { + try { rmSync(f, { force: true }) } catch {} +} + +// Build once (no watcher) — the resulting process is a single killable node. +execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' }) + +const env = { + ...process.env, + TREK_DB_FILE: dbFile, + ADMIN_EMAIL: 'e2e@trek.local', + ADMIN_PASSWORD: 'E2eTest12345!', + PORT: '3001', + NODE_ENV: 'development', +} + +const child = spawn(process.execPath, ['--require', 'tsconfig-paths/register', 'dist/index.js'], { + cwd: serverDir, + env, + stdio: 'inherit', +}) +const stop = () => { try { child.kill() } catch {} } +process.on('SIGINT', stop) +process.on('SIGTERM', stop) +process.on('exit', stop) +child.on('exit', code => process.exit(code ?? 0)) diff --git a/client/e2e/trip-planner.spec.ts b/client/e2e/trip-planner.spec.ts new file mode 100644 index 00000000..9a4b8b2c --- /dev/null +++ b/client/e2e/trip-planner.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/test' + +// Open a trip into the planner: create a trip, open it from the dashboard, and +// confirm the trip planner (TripPlannerPage — the app's largest page) actually +// mounts, proving the day-plan/map shell renders rather than crashing on load. +test('open a trip and land in the planner with a map', async ({ page }) => { + await page.goto('/dashboard') + + // Create a trip to open. + await page.locator('.add-trip-card').click() + const modal = page.locator('.modal-backdrop') + await expect(modal).toBeVisible() + const title = `E2E Planner ${Date.now()}` + await modal.locator('input[type="text"]').first().fill(title) + await modal.getByRole('button', { name: 'Create New Trip' }).click() + + // Open it from the dashboard. + await page.getByText(title).first().click() + + await expect(page).toHaveURL(/\/trips\/\d+/) + // The planner shows a Leaflet map once mounted (past the splash screen). + await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 }) +}) diff --git a/client/package.json b/client/package.json index 3a99ea90..2736a134 100644 --- a/client/package.json +++ b/client/package.json @@ -14,12 +14,15 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "lint": "eslint .", + "lint:pages": "node scripts/check-page-pattern.mjs", + "e2e": "playwright test", + "e2e:report": "playwright show-report", "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"", "format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"" }, "dependencies": { + "@react-pdf/renderer": "^4.5.1", "@trek/shared": "*", - "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", "dexie": "^4.4.2", "heic-to": "^1.4.2", @@ -27,11 +30,11 @@ "lucide-react": "^0.344.0", "mapbox-gl": "^3.22.0", "marked": "^18.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", "react-dropzone": "^14.4.1", - "react-leaflet": "^4.2.1", - "react-leaflet-cluster": "^2.1.0", + "react-leaflet": "^5.0.0", + "react-leaflet-cluster": "^4.1.3", "react-markdown": "^10.1.0", "react-router-dom": "^6.22.2", "react-window": "^2.2.7", @@ -43,33 +46,34 @@ "zustand": "^4.5.2" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/leaflet": "^1.9.8", - "@types/react": "^18.2.61", - "@types/react-dom": "^18.2.19", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.2.1", "@vitest/coverage-v8": "^3.2.4", "autoprefixer": "^10.4.18", + "eslint": "^10.2.1", + "eslint-config-flat-gitignore": "^2.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", "fake-indexeddb": "^6.2.5", "jsdom": "^29.0.1", "msw": "^2.13.0", "postcss": "^8.4.35", + "prettier": "^3.8.3", + "prettier-plugin-organize-imports": "^4.3.0", + "prettier-plugin-tailwindcss": "^0.8.0", "sharp": "^0.33.0", "tailwindcss": "^3.4.1", "typescript": "^6.0.2", "vite": "^5.1.4", "vite-plugin-pwa": "^0.21.0", - "vitest": "^3.2.4", - "@trivago/prettier-plugin-sort-imports": "^6.0.2", - "prettier": "^3.8.3", - "prettier-plugin-organize-imports": "^4.3.0", - "prettier-plugin-tailwindcss": "^0.8.0", - "eslint": "^10.2.1", - "eslint-config-flat-gitignore": "^2.3.0", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2" + "vitest": "^3.2.4" } } diff --git a/client/playwright.config.ts b/client/playwright.config.ts new file mode 100644 index 00000000..e6868a43 --- /dev/null +++ b/client/playwright.config.ts @@ -0,0 +1,57 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * E2E harness for TREK's critical user flows (FE7). + * + * Two web servers are orchestrated: the Express/Nest backend on :3001 against an + * isolated throwaway SQLite DB (e2e/server-launch.mjs sets TREK_DB_FILE + seeds a + * known admin), and the Vite dev server on :5173 which proxies /api, /uploads, + * /ws to the backend. Tests run serially against one worker so they share the + * single seeded database deterministically. + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + timeout: 45_000, + expect: { timeout: 15_000 }, + reporter: [['list']], + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + // Unauthenticated flows (login, register, public share) — no stored session. + { name: 'public', testMatch: /\.public\.spec\.ts/, use: { ...devices['Desktop Chrome'] } }, + // One-time login that persists a session for the authenticated flows. + { name: 'setup', testMatch: /auth\.setup\.ts/ }, + { + name: 'app', + testMatch: /\.spec\.ts/, + testIgnore: /(\.public\.spec\.ts|auth\.setup\.ts)/, + use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' }, + dependencies: ['setup'], + }, + ], + webServer: [ + { + // Always start our own backend (never reuse) so the isolated test DB is + // reset + reseeded on every run, regardless of any stray dev server. + command: 'node e2e/server-launch.mjs', + port: 3001, + reuseExistingServer: false, + timeout: 180_000, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'npm run dev', + port: 5173, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + ], +}) diff --git a/client/scripts/check-page-pattern.mjs b/client/scripts/check-page-pattern.mjs new file mode 100644 index 00000000..5d7d789c --- /dev/null +++ b/client/scripts/check-page-pattern.mjs @@ -0,0 +1,44 @@ +// Guards the "Page = wiring container + data hook" convention (see +// src/pages/PATTERN.md). A *Page.tsx default-export component should wire a +// co-located use() hook into JSX — it must not own state/effects itself. +// +// We scan only the default-export component body (from `export default function` +// up to the next top-level `function` declaration or EOF), so presentational +// sub-components and helper hooks living in the same file are not flagged. +// Context hooks like useTranslation/useParams are fine; the smell is stateful +// logic — useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef. +import { readdirSync, readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages') +const BANNED = ['useState', 'useReducer', 'useEffect', 'useLayoutEffect', 'useMemo', 'useCallback', 'useRef'] +const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*\\(`) + +const violations = [] +for (const file of readdirSync(pagesDir)) { + if (!file.endsWith('Page.tsx') || file.endsWith('.test.tsx')) continue + const src = readFileSync(join(pagesDir, file), 'utf8') + const lines = src.split('\n') + const start = lines.findIndex(l => /export default function/.test(l)) + if (start === -1) continue + // The page body ends at the next top-level declaration (a `function` at + // column 0) — everything after that is a sub-component or helper. + let end = lines.length + for (let i = start + 1; i < lines.length; i++) { + if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break } + } + for (let i = start; i < end; i++) { + if (bannedRe.test(lines[i])) { + violations.push(`${file}:${i + 1} ${lines[i].trim()}`) + } + } +} + +if (violations.length > 0) { + console.error('Page-pattern violations — move this state/effect logic into the page\'s use() hook:\n') + for (const v of violations) console.error(' ' + v) + console.error(`\n${violations.length} violation(s). See src/pages/PATTERN.md.`) + process.exit(1) +} +console.log('Page pattern OK — no state/effect logic in page containers.') diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 81d40904..2d8b6ae3 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1,7 +1,62 @@ import axios, { AxiosInstance } from 'axios' -import type { WeatherResult } from '@trek/shared' +import type { z } from 'zod' +import { + weatherResultSchema, type WeatherResult, + inAppListResultSchema, type InAppListResult, + unreadCountResultSchema, type UnreadCountResult, + type NotificationRespondRequest, + type SettingUpsertRequest, type SettingsBulkRequest, + type JourneyCreateRequest, type JourneyAddTripRequest, + type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest, + type JourneyShareLinkRequest, + type RegisterRequest, type LoginRequest, type ForgotPasswordRequest, + type ResetPasswordRequest, type ChangePasswordRequest, + type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest, + type TripAddMemberRequest, type AssignmentReorderRequest, + type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest, + type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, + type DayCreateRequest, type DayUpdateRequest, + type PlaceCreateRequest, type PlaceUpdateRequest, + type ReservationCreateRequest, type ReservationUpdateRequest, + type AccommodationCreateRequest, type AccommodationUpdateRequest, + type BudgetCreateItemRequest, type BudgetUpdateItemRequest, + type PackingCreateItemRequest, type PackingUpdateItemRequest, + type TodoCreateItemRequest, type TodoUpdateItemRequest, + type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, + type PlaceBulkDeleteRequest, + type DayNoteCreateRequest, type DayNoteUpdateRequest, + type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest, + type PackingCategoryAssigneesRequest, + type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest, + type TodoCategoryAssigneesRequest, + type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest, + type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest, + type FileUpdateRequest, type FileLinkRequest, + type CreateTagRequest, type UpdateTagRequest, + type CreateCategoryRequest, type UpdateCategoryRequest, + type PlaceImportListRequest, +} from '@trek/shared' import { getSocketId } from './websocket' import { isReachable, probeNow } from '../sync/connectivity' + +/** + * Validate a response payload against its @trek/shared Zod schema — but only in + * dev, and never throwing. A drift between the server contract and the client's + * expected shape is surfaced as a console warning during development; in + * production (and on any mismatch) the data passes through untouched, so adding + * validation can never break a working call. This is the typed-request helper + * the FE adopts per domain as each backend module lands on @trek/shared. + */ +const API_DEV = Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV) +export function parseInDev(schema: S, data: unknown, label: string): z.infer { + if (API_DEV) { + const result = schema.safeParse(data) + if (!result.success) { + console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues) + } + } + return data as z.infer +} const RATE_LIMIT_MESSAGES: Record = { en: 'Too many attempts. Please try again later.', de: 'Zu viele Versuche. Bitte versuchen Sie es später erneut.', @@ -154,12 +209,12 @@ apiClient.interceptors.response.use( ) export const authApi = { - register: (data: { username: string; email: string; password: string; invite_token?: string }) => apiClient.post('/auth/register', data).then(r => r.data), + register: (data: RegisterRequest) => apiClient.post('/auth/register', data).then(r => r.data), validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data), - login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data), - verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data), + login: (data: LoginRequest) => apiClient.post('/auth/login', data).then(r => r.data), + verifyMfaLogin: (data: MfaVerifyLoginRequest) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data), mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data), - mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }), + mfaEnable: (data: MfaEnableRequest) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }), mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data), me: () => apiClient.get('/auth/me').then(r => r.data), updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data), @@ -173,14 +228,14 @@ export const authApi = { updateAppSettings: (data: Record) => apiClient.put('/auth/app-settings', data).then(r => r.data), validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data), travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data), - changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data), - forgotPassword: (data: { email: string }) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }), - resetPassword: (data: { token: string; new_password: string; mfa_code?: string }) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }), + changePassword: (data: ChangePasswordRequest) => apiClient.put('/auth/me/password', data).then(r => r.data), + forgotPassword: (data: ForgotPasswordRequest) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }), + resetPassword: (data: ResetPasswordRequest) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }), deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data), demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data), mcpTokens: { list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data), - create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data), + create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data), delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data), }, } @@ -226,32 +281,32 @@ export const oauthApi = { export const tripsApi = { list: (params?: Record) => apiClient.get('/trips', { params }).then(r => r.data), - create: (data: Record) => apiClient.post('/trips', data).then(r => r.data), + create: (data: TripCreateRequest) => apiClient.post('/trips', data).then(r => r.data), get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data), - update: (id: number | string, data: Record) => apiClient.put(`/trips/${id}`, data).then(r => r.data), + 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), 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), - addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data), + addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier } satisfies TripAddMemberRequest).then(r => r.data), removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data), - copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data), + copy: (id: number | string, data?: TripCopyRequest) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data), bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data), } export const daysApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data), - update: (tripId: number | string, dayId: number | string, data: Record) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data), + create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data), + update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data), delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data), } export const placesApi = { list: (tripId: number | string, params?: Record) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data), + create: (tripId: number | string, data: PlaceCreateRequest) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data), get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data), - update: (tripId: number | string, id: number | string, data: Record) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data), + update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data), searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data), importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => { @@ -270,64 +325,64 @@ export const placesApi = { return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) }, importGoogleList: (tripId: number | string, url: string) => - apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data), + apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data), importNaverList: (tripId: number | string, url: string) => apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data), bulkDelete: (tripId: number | string, ids: number[]) => - apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data), + apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data), } export const assignmentsApi = { list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data), - create: (tripId: number | string, dayId: number | string, data: { place_id: number | string }) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data), + create: (tripId: number | string, dayId: number | string, data: AssignmentCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data), delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data), - reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data), + reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds } satisfies AssignmentReorderRequest).then(r => r.data), move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data), update: (tripId: number | string, dayId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data), getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data), - setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data), - updateTime: (tripId: number | string, id: number, times: Record) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data), + setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data), + updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data), } export const packingApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data), - bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data), - update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data), + create: (tripId: number | string, data: PackingCreateItemRequest) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data), + bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items } satisfies PackingImportRequest).then(r => r.data), + update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data), - reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data), + reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data), getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data), - setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data), + setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data), applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data), saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data), - setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds }).then(r => r.data), + setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data), listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data), - createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data), - updateBag: (tripId: number | string, bagId: number, data: Record) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data), + createBag: (tripId: number | string, data: PackingCreateBagRequest) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data), + updateBag: (tripId: number | string, bagId: number, data: PackingUpdateBagRequest) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data), deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data), } export const todoApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data), - update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data), + create: (tripId: number | string, data: TodoCreateItemRequest) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: TodoUpdateItemRequest) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data), - reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds }).then(r => r.data), + reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds } satisfies TodoReorderRequest).then(r => r.data), getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data), - setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data), + setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies TodoCategoryAssigneesRequest).then(r => r.data), } export const tagsApi = { list: () => apiClient.get('/tags').then(r => r.data), - create: (data: Record) => apiClient.post('/tags', data).then(r => r.data), - update: (id: number, data: Record) => apiClient.put(`/tags/${id}`, data).then(r => r.data), + create: (data: CreateTagRequest) => apiClient.post('/tags', data).then(r => r.data), + update: (id: number, data: UpdateTagRequest) => apiClient.put(`/tags/${id}`, data).then(r => r.data), delete: (id: number) => apiClient.delete(`/tags/${id}`).then(r => r.data), } export const categoriesApi = { list: () => apiClient.get('/categories').then(r => r.data), - create: (data: Record) => apiClient.post('/categories', data).then(r => r.data), - update: (id: number, data: Record) => apiClient.put(`/categories/${id}`, data).then(r => r.data), + create: (data: CreateCategoryRequest) => apiClient.post('/categories', data).then(r => r.data), + update: (id: number, data: UpdateCategoryRequest) => apiClient.put(`/categories/${id}`, data).then(r => r.data), delete: (id: number) => apiClient.delete(`/categories/${id}`).then(r => r.data), } @@ -390,7 +445,7 @@ export const addonsApi = { export const journeyApi = { list: () => apiClient.get('/journeys').then(r => r.data), - create: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => apiClient.post('/journeys', data).then(r => r.data), + create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data), get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data), update: (id: number, data: Record) => apiClient.patch(`/journeys/${id}`, data).then(r => r.data), delete: (id: number) => apiClient.delete(`/journeys/${id}`).then(r => r.data), @@ -399,7 +454,7 @@ export const journeyApi = { availableTrips: () => apiClient.get('/journeys/available-trips').then(r => r.data), // Trips (sync sources) - addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId }).then(r => r.data), + addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId } satisfies JourneyAddTripRequest).then(r => r.data), removeTrip: (id: number, tripId: number) => apiClient.delete(`/journeys/${id}/trips/${tripId}`).then(r => r.data), // Entries @@ -407,7 +462,7 @@ export const journeyApi = { createEntry: (id: number, data: Record) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data), updateEntry: (entryId: number, data: Record) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data), deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data), - reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data), + reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds } satisfies JourneyReorderEntriesRequest).then(r => r.data), // Photos uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) => @@ -424,7 +479,7 @@ export const journeyApi = { onUploadProgress: opts?.onUploadProgress, signal: opts?.signal, }).then(r => r.data), - addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), + addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data), addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data), @@ -446,7 +501,7 @@ export const journeyApi = { // Share getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data), - createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data), + createShareLink: (id: number, perms: JourneyShareLinkRequest) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data), deleteShareLink: (id: number) => apiClient.delete(`/journeys/${id}/share-link`).then(r => r.data), getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data), } @@ -468,15 +523,15 @@ export const airportsApi = { export const budgetApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data), - update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data), + create: (tripId: number | string, data: BudgetCreateItemRequest) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: BudgetUpdateItemRequest) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data), - setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data), - togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data), + setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds } satisfies BudgetUpdateMembersRequest).then(r => r.data), + togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data), perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data), settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data), reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data), - reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories }).then(r => r.data), + reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data), } export const filesApi = { @@ -484,13 +539,13 @@ export const filesApi = { upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), - update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: FileUpdateRequest) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data), toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data), restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data), permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data), emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data), - addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data), + addLink: (tripId: number | string, fileId: number, data: FileLinkRequest) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data), removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data), getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data), } @@ -498,15 +553,15 @@ export const filesApi = { export const reservationsApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data), upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data), - update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data), + create: (tripId: number | string, data: ReservationCreateRequest) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data), updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data), } export const weatherApi = { - get: (lat: number, lng: number, date: string): Promise => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data), - getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data), + get: (lat: number, lng: number, date: string): Promise => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')), + getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')), } export const configApi = { @@ -516,40 +571,46 @@ export const configApi = { export const settingsApi = { get: () => apiClient.get('/settings').then(r => r.data), - set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data), - setBulk: (settings: Record) => apiClient.post('/settings/bulk', { settings }).then(r => r.data), + set: (key: string, value: unknown) => { + const body: SettingUpsertRequest = { key, value } + return apiClient.put('/settings', body).then(r => r.data) + }, + setBulk: (settings: Record) => { + const body: SettingsBulkRequest = { settings } + return apiClient.post('/settings/bulk', body).then(r => r.data) + }, } export const accommodationsApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data), - create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data), - update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data), + create: (tripId: number | string, data: AccommodationCreateRequest) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: AccommodationUpdateRequest) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data), } export const dayNotesApi = { list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data), - create: (tripId: number | string, dayId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data), - update: (tripId: number | string, dayId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data), + create: (tripId: number | string, dayId: number | string, data: DayNoteCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data), + update: (tripId: number | string, dayId: number | string, id: number, data: DayNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data), delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data), } export const collabApi = { getNotes: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data), - createNote: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data), - updateNote: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data), + createNote: (tripId: number | string, data: CollabNoteCreateRequest) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data), + updateNote: (tripId: number | string, id: number, data: CollabNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data), deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data), uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data), getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data), - createPoll: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data), - votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data), + createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data), + votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data), closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data), deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data), getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data), - sendMessage: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data), + sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data), deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data), - reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji }).then(r => r.data), + reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data), linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data), } @@ -596,10 +657,10 @@ export const notificationsApi = { } export const inAppNotificationsApi = { - list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) => - apiClient.get('/notifications/in-app', { params }).then(r => r.data), - unreadCount: () => - apiClient.get('/notifications/in-app/unread-count').then(r => r.data), + list: (params?: { limit?: number; offset?: number; unread_only?: boolean }): Promise => + apiClient.get('/notifications/in-app', { params }).then(r => parseInDev(inAppListResultSchema, r.data, 'notifications.list')), + unreadCount: (): Promise => + apiClient.get('/notifications/in-app/unread-count').then(r => parseInDev(unreadCountResultSchema, r.data, 'notifications.unreadCount')), markRead: (id: number) => apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data), markUnread: (id: number) => @@ -610,7 +671,7 @@ export const inAppNotificationsApi = { apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data), deleteAll: () => apiClient.delete('/notifications/in-app/all').then(r => r.data), - respond: (id: number, response: 'positive' | 'negative') => + respond: (id: number, response: NotificationRespondRequest['response']) => apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data), } diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index 1b2cab37..6fe80bdf 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -771,12 +771,40 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
- {categoryNames.map((cat, ci) => { - const items = grouped.get(cat) || [] - const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0) - const color = categoryColor(cat) + {categoryNames.map(cat => ( + + ))} +
- return ( + +
+ + ) +} + +function BudgetCategoryTable({ cat, grouped, categoryColor, canEdit, editingCat, setEditingCat, + dragCat, setDragCat, dragOverCat, setDragOverCat, dragItem, setDragItem, dragOverItem, setDragOverItem, + dragItemCat, setDragItemCat, categoryNames, reorderBudgetCategories, reorderBudgetItems, + handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleUpdateField, handleAddItem, + tripId, currency, locale, t, fmt, hasMultipleMembers, tripMembers, setBudgetItemMembers, toggleBudgetMemberPaid, th, td }: any) { + const items = grouped.get(cat) || [] + const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0) + const color = categoryColor(cat) + return (
- ) - })} - + ) +} +function BudgetSummary({ theme, currency, locale, grandTotal, hasMultipleMembers, budgetItems, + settlement, settlementOpen, setSettlementOpen, pieSegments, isDark, tripId, t, fmt }: any) { + return (
-
-
) } diff --git a/client/src/components/Collab/CollabChat.tsx b/client/src/components/Collab/CollabChat.tsx index 2735029b..2eb6395e 100644 --- a/client/src/components/Collab/CollabChat.tsx +++ b/client/src/components/Collab/CollabChat.tsx @@ -353,6 +353,99 @@ interface CollabChatProps { } export default function CollabChat({ tripId, currentUser }: CollabChatProps) { + const S = useCollabChat(tripId, currentUser) + const { t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = S + if (loading) { + return ( +
+
+ +
+ ) + } + return ( +
+ + {/* Composer */} +
+ {/* Reply preview */} + {replyTo && ( +
+ + + {replyTo.username}: {(replyTo.text || '').slice(0, 60)} + + +
+ )} + +
+ {/* Emoji button */} + {canEdit && ( + + )} + +