refactoring: TypeScript migration, security fixes,

This commit is contained in:
Maurice
2026-03-27 18:40:18 +01:00
parent 510475a46f
commit 8396a75223
150 changed files with 8116 additions and 8467 deletions
+168
View File
@@ -0,0 +1,168 @@
import { assignmentsApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { Assignment, AssignmentsMap } from '../../types'
import { getApiErrorMessage } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface AssignmentsSlice {
assignPlaceToDay: (tripId: number | string, dayId: number | string, placeId: number | string, position?: number | null) => Promise<Assignment | undefined>
removeAssignment: (tripId: number | string, dayId: number | string, assignmentId: number) => Promise<void>
reorderAssignments: (tripId: number | string, dayId: number | string, orderedIds: number[]) => Promise<void>
moveAssignment: (tripId: number | string, assignmentId: number, fromDayId: number | string, toDayId: number | string, toOrderIndex?: number | null) => Promise<void>
setAssignments: (assignments: AssignmentsMap) => void
}
export const createAssignmentsSlice = (set: SetState, get: GetState): AssignmentsSlice => ({
assignPlaceToDay: async (tripId, dayId, placeId, position) => {
const state = get()
const place = state.places.find(p => p.id === parseInt(String(placeId)))
if (!place) return
const tempId = Date.now() * -1
const current = [...(state.assignments[String(dayId)] || [])]
const insertIdx = position != null ? position : current.length
const tempAssignment: Assignment = {
id: tempId,
day_id: parseInt(String(dayId)),
order_index: insertIdx,
notes: null,
place,
}
current.splice(insertIdx, 0, tempAssignment)
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: current,
}
}))
try {
const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId })
const newAssignment: Assignment = {
...data.assignment,
place: data.assignment.place || place,
order_index: position != null ? insertIdx : data.assignment.order_index,
}
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: state.assignments[String(dayId)].map(
a => a.id === tempId ? newAssignment : a
),
}
}))
if (position != null) {
const updated = get().assignments[String(dayId)] || []
const orderedIds = updated.map(a => a.id).filter(id => id > 0)
if (orderedIds.length > 0) {
try {
await assignmentsApi.reorder(tripId, dayId, orderedIds)
set(state => {
const items = state.assignments[String(dayId)] || []
const reordered = orderedIds.map((id, idx) => {
const item = items.find(a => a.id === id)
return item ? { ...item, order_index: idx } : null
}).filter((item): item is Assignment => item !== null)
return {
assignments: {
...state.assignments,
[String(dayId)]: reordered,
}
}
})
} catch {}
}
}
return data.assignment
} catch (err: unknown) {
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== tempId),
}
}))
throw new Error(getApiErrorMessage(err, 'Error assigning place'))
}
},
removeAssignment: async (tripId, dayId, assignmentId) => {
const prevAssignments = get().assignments
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== assignmentId),
}
}))
try {
await assignmentsApi.delete(tripId, dayId, assignmentId)
} catch (err: unknown) {
set({ assignments: prevAssignments })
throw new Error(getApiErrorMessage(err, 'Error removing assignment'))
}
},
reorderAssignments: async (tripId, dayId, orderedIds) => {
const prevAssignments = get().assignments
const dayItems = get().assignments[String(dayId)] || []
const reordered = orderedIds.map((id, idx) => {
const item = dayItems.find(a => a.id === id)
return item ? { ...item, order_index: idx } : null
}).filter((item): item is Assignment => item !== null)
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: reordered,
}
}))
try {
await assignmentsApi.reorder(tripId, dayId, orderedIds)
} catch (err: unknown) {
set({ assignments: prevAssignments })
throw new Error(getApiErrorMessage(err, 'Error reordering'))
}
},
moveAssignment: async (tripId, assignmentId, fromDayId, toDayId, toOrderIndex = null) => {
const state = get()
const prevAssignments = state.assignments
const assignment = (state.assignments[String(fromDayId)] || []).find(a => a.id === assignmentId)
if (!assignment) return
const toItems = (state.assignments[String(toDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const insertAt = toOrderIndex !== null ? toOrderIndex : toItems.length
const newToItems = [...toItems]
newToItems.splice(insertAt, 0, { ...assignment, day_id: parseInt(String(toDayId)) })
newToItems.forEach((a, i) => { a.order_index = i })
set(s => ({
assignments: {
...s.assignments,
[String(fromDayId)]: s.assignments[String(fromDayId)].filter(a => a.id !== assignmentId),
[String(toDayId)]: newToItems,
}
}))
try {
await assignmentsApi.move(tripId, assignmentId, toDayId, insertAt)
if (newToItems.length > 1) {
await assignmentsApi.reorder(tripId, toDayId, newToItems.map(a => a.id))
}
} catch (err: unknown) {
set({ assignments: prevAssignments })
throw new Error(getApiErrorMessage(err, 'Error moving assignment'))
}
},
setAssignments: (assignments) => {
set({ assignments })
},
})
+82
View File
@@ -0,0 +1,82 @@
import { budgetApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { BudgetItem, BudgetMember } from '../../types'
import { getApiErrorMessage } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface BudgetSlice {
loadBudgetItems: (tripId: number | string) => Promise<void>
addBudgetItem: (tripId: number | string, data: Partial<BudgetItem>) => Promise<BudgetItem>
updateBudgetItem: (tripId: number | string, id: number, data: Partial<BudgetItem>) => Promise<BudgetItem>
deleteBudgetItem: (tripId: number | string, id: number) => Promise<void>
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: BudgetMember[]; item: BudgetItem }>
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
}
export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => ({
loadBudgetItems: async (tripId) => {
try {
const data = await budgetApi.list(tripId)
set({ budgetItems: data.items })
} catch (err: unknown) {
console.error('Failed to load budget items:', err)
}
},
addBudgetItem: async (tripId, data) => {
try {
const result = await budgetApi.create(tripId, data)
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
return result.item
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error adding budget item'))
}
},
updateBudgetItem: async (tripId, id, data) => {
try {
const result = await budgetApi.update(tripId, id, data)
set(state => ({
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
}))
return result.item
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating budget item'))
}
},
deleteBudgetItem: async (tripId, id) => {
const prev = get().budgetItems
set(state => ({ budgetItems: state.budgetItems.filter(item => item.id !== id) }))
try {
await budgetApi.delete(tripId, id)
} catch (err: unknown) {
set({ budgetItems: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting budget item'))
}
},
setBudgetItemMembers: async (tripId, itemId, userIds) => {
const result = await budgetApi.setMembers(tripId, itemId, userIds);
set(state => ({
budgetItems: state.budgetItems.map(item =>
item.id === itemId ? { ...item, members: result.members, persons: result.item.persons } : item
)
}));
return result;
},
toggleBudgetMemberPaid: async (tripId, itemId, userId, paid) => {
await budgetApi.togglePaid(tripId, itemId, userId, paid);
set(state => ({
budgetItems: state.budgetItems.map(item =>
item.id === itemId
? { ...item, members: (item.members || []).map(m => m.user_id === userId ? { ...m, paid } : m) }
: item
)
}));
},
})
+135
View File
@@ -0,0 +1,135 @@
import { daysApi, dayNotesApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { DayNote } from '../../types'
import { getApiErrorMessage } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface DayNotesSlice {
updateDayNotes: (tripId: number | string, dayId: number | string, notes: string) => Promise<void>
updateDayTitle: (tripId: number | string, dayId: number | string, title: string) => Promise<void>
addDayNote: (tripId: number | string, dayId: number | string, data: Partial<DayNote>) => Promise<DayNote>
updateDayNote: (tripId: number | string, dayId: number | string, id: number, data: Partial<DayNote>) => Promise<DayNote>
deleteDayNote: (tripId: number | string, dayId: number | string, id: number) => Promise<void>
moveDayNote: (tripId: number | string, fromDayId: number | string, toDayId: number | string, noteId: number, sort_order?: number) => Promise<void>
}
export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice => ({
updateDayNotes: async (tripId, dayId, notes) => {
try {
await daysApi.update(tripId, dayId, { notes })
set(state => ({
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, notes } : d)
}))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating notes'))
}
},
updateDayTitle: async (tripId, dayId, title) => {
try {
await daysApi.update(tripId, dayId, { title })
set(state => ({
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, title } : d)
}))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating day name'))
}
},
addDayNote: async (tripId, dayId, data) => {
const tempId = Date.now() * -1
const tempNote: DayNote = { id: tempId, day_id: dayId as number, ...data, created_at: new Date().toISOString() } as DayNote
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: [...(state.dayNotes[String(dayId)] || []), tempNote],
}
}))
try {
const result = await dayNotesApi.create(tripId, dayId, data)
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === tempId ? result.note : n),
}
}))
return result.note
} catch (err: unknown) {
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== tempId),
}
}))
throw new Error(getApiErrorMessage(err, 'Error adding note'))
}
},
updateDayNote: async (tripId, dayId, id, data) => {
try {
const result = await dayNotesApi.update(tripId, dayId, id, data)
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === id ? result.note : n),
}
}))
return result.note
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating note'))
}
},
deleteDayNote: async (tripId, dayId, id) => {
const prev = get().dayNotes
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== id),
}
}))
try {
await dayNotesApi.delete(tripId, dayId, id)
} catch (err: unknown) {
set({ dayNotes: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting note'))
}
},
moveDayNote: async (tripId, fromDayId, toDayId, noteId, sort_order = 9999) => {
const state = get()
const note = (state.dayNotes[String(fromDayId)] || []).find(n => n.id === noteId)
if (!note) return
set(s => ({
dayNotes: {
...s.dayNotes,
[String(fromDayId)]: (s.dayNotes[String(fromDayId)] || []).filter(n => n.id !== noteId),
}
}))
try {
await dayNotesApi.delete(tripId, fromDayId, noteId)
const result = await dayNotesApi.create(tripId, toDayId, {
text: note.text, time: note.time, icon: note.icon, sort_order,
})
set(s => ({
dayNotes: {
...s.dayNotes,
[String(toDayId)]: [...(s.dayNotes[String(toDayId)] || []), result.note],
}
}))
} catch (err: unknown) {
set(s => ({
dayNotes: {
...s.dayNotes,
[String(fromDayId)]: [...(s.dayNotes[String(fromDayId)] || []), note],
}
}))
throw new Error(getApiErrorMessage(err, 'Error moving note'))
}
},
})
+44
View File
@@ -0,0 +1,44 @@
import { filesApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { TripFile } from '../../types'
import { getApiErrorMessage } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface FilesSlice {
loadFiles: (tripId: number | string) => Promise<void>
addFile: (tripId: number | string, formData: FormData) => Promise<TripFile>
deleteFile: (tripId: number | string, id: number) => Promise<void>
}
export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({
loadFiles: async (tripId) => {
try {
const data = await filesApi.list(tripId)
set({ files: data.files })
} catch (err: unknown) {
console.error('Failed to load files:', err)
}
},
addFile: async (tripId, formData) => {
try {
const data = await filesApi.upload(tripId, formData)
set(state => ({ files: [data.file, ...state.files] }))
return data.file
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error uploading file'))
}
},
deleteFile: async (tripId, id) => {
try {
await filesApi.delete(tripId, id)
set(state => ({ files: state.files.filter(f => f.id !== id) }))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error deleting file'))
}
},
})
+67
View File
@@ -0,0 +1,67 @@
import { packingApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { PackingItem } from '../../types'
import { getApiErrorMessage } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface PackingSlice {
addPackingItem: (tripId: number | string, data: Partial<PackingItem>) => Promise<PackingItem>
updatePackingItem: (tripId: number | string, id: number, data: Partial<PackingItem>) => Promise<PackingItem>
deletePackingItem: (tripId: number | string, id: number) => Promise<void>
togglePackingItem: (tripId: number | string, id: number, checked: boolean) => Promise<void>
}
export const createPackingSlice = (set: SetState, get: GetState): PackingSlice => ({
addPackingItem: async (tripId, data) => {
try {
const result = await packingApi.create(tripId, data)
set(state => ({ packingItems: [...state.packingItems, result.item] }))
return result.item
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error adding item'))
}
},
updatePackingItem: async (tripId, id, data) => {
try {
const result = await packingApi.update(tripId, id, data)
set(state => ({
packingItems: state.packingItems.map(item => item.id === id ? result.item : item)
}))
return result.item
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating item'))
}
},
deletePackingItem: async (tripId, id) => {
const prev = get().packingItems
set(state => ({ packingItems: state.packingItems.filter(item => item.id !== id) }))
try {
await packingApi.delete(tripId, id)
} catch (err: unknown) {
set({ packingItems: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting item'))
}
},
togglePackingItem: async (tripId, id, checked) => {
set(state => ({
packingItems: state.packingItems.map(item =>
item.id === id ? { ...item, checked: checked ? 1 : 0 } : item
)
}))
try {
await packingApi.update(tripId, id, { checked })
} catch {
set(state => ({
packingItems: state.packingItems.map(item =>
item.id === id ? { ...item, checked: checked ? 0 : 1 } : item
)
}))
}
},
})
+71
View File
@@ -0,0 +1,71 @@
import { placesApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { Place, Assignment } from '../../types'
import { getApiErrorMessage } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface PlacesSlice {
refreshPlaces: (tripId: number | string) => Promise<void>
addPlace: (tripId: number | string, placeData: Partial<Place>) => Promise<Place>
updatePlace: (tripId: number | string, placeId: number, placeData: Partial<Place>) => Promise<Place>
deletePlace: (tripId: number | string, placeId: number) => Promise<void>
}
export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => ({
refreshPlaces: async (tripId) => {
try {
const data = await placesApi.list(tripId)
set({ places: data.places })
} catch (err: unknown) {
console.error('Failed to refresh places:', err)
}
},
addPlace: async (tripId, placeData) => {
try {
const data = await placesApi.create(tripId, placeData)
set(state => ({ places: [data.place, ...state.places] }))
return data.place
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error adding place'))
}
},
updatePlace: async (tripId, placeId, placeData) => {
try {
const data = await placesApi.update(tripId, placeId, placeData)
set(state => ({
places: state.places.map(p => p.id === placeId ? data.place : p),
assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [
dayId,
items.map((a: Assignment) => a.place?.id === placeId ? { ...a, place: { ...data.place, place_time: a.place.place_time, end_time: a.place.end_time } } : a)
])
),
}))
return data.place
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating place'))
}
},
deletePlace: async (tripId, placeId) => {
try {
await placesApi.delete(tripId, placeId)
set(state => ({
places: state.places.filter(p => p.id !== placeId),
assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [
dayId,
items.filter((a: Assignment) => a.place?.id !== placeId)
])
),
}))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error deleting place'))
}
},
})
@@ -0,0 +1,239 @@
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { Assignment, Place, Day, DayNote, PackingItem, BudgetItem, BudgetMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
/**
* Applies a remote WebSocket event to the local Zustand store, keeping state in sync across collaborators.
* Each event type maps to an immutable state update (create/update/delete) for the relevant entity.
*/
export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
const { type, ...payload } = event
set(state => {
switch (type) {
// Places
case 'place:created':
if (state.places.some(p => p.id === (payload.place as Place).id)) return {}
return { places: [payload.place as Place, ...state.places] }
case 'place:updated':
return {
places: state.places.map(p => p.id === (payload.place as Place).id ? payload.place as Place : p),
assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [
dayId,
items.map(a => a.place?.id === (payload.place as Place).id ? { ...a, place: payload.place as Place } : a)
])
),
}
case 'place:deleted':
return {
places: state.places.filter(p => p.id !== payload.placeId),
assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [
dayId,
items.filter(a => a.place?.id !== payload.placeId)
])
),
}
// Assignments
case 'assignment:created': {
const dayKey = String((payload.assignment as Assignment).day_id)
const existing = (state.assignments[dayKey] || [])
const placeId = (payload.assignment as Assignment).place?.id || (payload.assignment as Assignment).place_id
if (existing.some(a => a.id === (payload.assignment as Assignment).id || (placeId && a.place?.id === placeId))) {
const hasTempVersion = existing.some(a => a.id < 0 && a.place?.id === placeId)
if (hasTempVersion) {
return {
assignments: {
...state.assignments,
[dayKey]: existing.map(a => (a.id < 0 && a.place?.id === placeId) ? payload.assignment as Assignment : a),
}
}
}
return {}
}
return {
assignments: {
...state.assignments,
[dayKey]: [...existing, payload.assignment as Assignment],
}
}
}
case 'assignment:updated': {
const dayKey = String((payload.assignment as Assignment).day_id)
return {
assignments: {
...state.assignments,
[dayKey]: (state.assignments[dayKey] || []).map(a =>
a.id === (payload.assignment as Assignment).id ? { ...a, ...(payload.assignment as Assignment) } : a
),
}
}
}
case 'assignment:deleted': {
const dayKey = String(payload.dayId)
return {
assignments: {
...state.assignments,
[dayKey]: (state.assignments[dayKey] || []).filter(a => a.id !== payload.assignmentId),
}
}
}
case 'assignment:moved': {
const oldKey = String(payload.oldDayId)
const newKey = String(payload.newDayId)
const movedAssignment = payload.assignment as Assignment
return {
assignments: {
...state.assignments,
[oldKey]: (state.assignments[oldKey] || []).filter(a => a.id !== movedAssignment.id),
[newKey]: [...(state.assignments[newKey] || []).filter(a => a.id !== movedAssignment.id), movedAssignment],
}
}
}
case 'assignment:reordered': {
const dayKey = String(payload.dayId)
const currentItems = state.assignments[dayKey] || []
const orderedIds: number[] = payload.orderedIds || []
const reordered = orderedIds.map((id, idx) => {
const item = currentItems.find(a => a.id === id)
return item ? { ...item, order_index: idx } : null
}).filter((item): item is Assignment => item !== null)
return {
assignments: {
...state.assignments,
[dayKey]: reordered,
}
}
}
// Days
case 'day:created':
if (state.days.some(d => d.id === (payload.day as Day).id)) return {}
return { days: [...state.days, payload.day as Day] }
case 'day:updated':
return {
days: state.days.map(d => d.id === (payload.day as Day).id ? payload.day as Day : d),
}
case 'day:deleted': {
const removedDayId = String(payload.dayId)
const newAssignments = { ...state.assignments }
delete newAssignments[removedDayId]
const newDayNotes = { ...state.dayNotes }
delete newDayNotes[removedDayId]
return {
days: state.days.filter(d => d.id !== payload.dayId),
assignments: newAssignments,
dayNotes: newDayNotes,
}
}
// Day Notes
case 'dayNote:created': {
const dayKey = String(payload.dayId)
const existingNotes = (state.dayNotes[dayKey] || [])
if (existingNotes.some(n => n.id === (payload.note as DayNote).id)) return {}
return {
dayNotes: {
...state.dayNotes,
[dayKey]: [...existingNotes, payload.note as DayNote],
}
}
}
case 'dayNote:updated': {
const dayKey = String(payload.dayId)
return {
dayNotes: {
...state.dayNotes,
[dayKey]: (state.dayNotes[dayKey] || []).map(n => n.id === (payload.note as DayNote).id ? payload.note as DayNote : n),
}
}
}
case 'dayNote:deleted': {
const dayKey = String(payload.dayId)
return {
dayNotes: {
...state.dayNotes,
[dayKey]: (state.dayNotes[dayKey] || []).filter(n => n.id !== payload.noteId),
}
}
}
// Packing
case 'packing:created':
if (state.packingItems.some(i => i.id === (payload.item as PackingItem).id)) return {}
return { packingItems: [...state.packingItems, payload.item as PackingItem] }
case 'packing:updated':
return {
packingItems: state.packingItems.map(i => i.id === (payload.item as PackingItem).id ? payload.item as PackingItem : i),
}
case 'packing:deleted':
return {
packingItems: state.packingItems.filter(i => i.id !== payload.itemId),
}
// Budget
case 'budget:created':
if (state.budgetItems.some(i => i.id === (payload.item as BudgetItem).id)) return {}
return { budgetItems: [...state.budgetItems, payload.item as BudgetItem] }
case 'budget:updated':
return {
budgetItems: state.budgetItems.map(i => i.id === (payload.item as BudgetItem).id ? payload.item as BudgetItem : i),
}
case 'budget:deleted':
return {
budgetItems: state.budgetItems.filter(i => i.id !== payload.itemId),
}
case 'budget:members-updated':
return {
budgetItems: state.budgetItems.map(i =>
i.id === payload.itemId ? { ...i, members: payload.members as BudgetMember[], persons: payload.persons as number } : i
),
}
case 'budget:member-paid-updated':
return {
budgetItems: state.budgetItems.map(i =>
i.id === payload.itemId
? { ...i, members: (i.members || []).map(m => m.user_id === payload.userId ? { ...m, paid: payload.paid } : m) }
: i
),
}
// Reservations
case 'reservation:created':
if (state.reservations.some(r => r.id === (payload.reservation as Reservation).id)) return {}
return { reservations: [payload.reservation as Reservation, ...state.reservations] }
case 'reservation:updated':
return {
reservations: state.reservations.map(r => r.id === (payload.reservation as Reservation).id ? payload.reservation as Reservation : r),
}
case 'reservation:deleted':
return {
reservations: state.reservations.filter(r => r.id !== payload.reservationId),
}
// Trip
case 'trip:updated':
return { trip: payload.trip as Trip }
// Files
case 'file:created':
if (state.files.some(f => f.id === (payload.file as TripFile).id)) return {}
return { files: [payload.file as TripFile, ...state.files] }
case 'file:updated':
return {
files: state.files.map(f => f.id === (payload.file as TripFile).id ? payload.file as TripFile : f),
}
case 'file:deleted':
return {
files: state.files.filter(f => f.id !== payload.fileId),
}
default:
return {}
}
})
}
@@ -0,0 +1,73 @@
import { reservationsApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { Reservation } from '../../types'
import { getApiErrorMessage } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface ReservationsSlice {
loadReservations: (tripId: number | string) => Promise<void>
addReservation: (tripId: number | string, data: Partial<Reservation>) => Promise<Reservation>
updateReservation: (tripId: number | string, id: number, data: Partial<Reservation>) => Promise<Reservation>
toggleReservationStatus: (tripId: number | string, id: number) => Promise<void>
deleteReservation: (tripId: number | string, id: number) => Promise<void>
}
export const createReservationsSlice = (set: SetState, get: GetState): ReservationsSlice => ({
loadReservations: async (tripId) => {
try {
const data = await reservationsApi.list(tripId)
set({ reservations: data.reservations })
} catch (err: unknown) {
console.error('Failed to load reservations:', err)
}
},
addReservation: async (tripId, data) => {
try {
const result = await reservationsApi.create(tripId, data)
set(state => ({ reservations: [result.reservation, ...state.reservations] }))
return result.reservation
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error creating reservation'))
}
},
updateReservation: async (tripId, id, data) => {
try {
const result = await reservationsApi.update(tripId, id, data)
set(state => ({
reservations: state.reservations.map(r => r.id === id ? result.reservation : r)
}))
return result.reservation
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating reservation'))
}
},
toggleReservationStatus: async (tripId, id) => {
const prev = get().reservations
const current = prev.find(r => r.id === id)
if (!current) return
const newStatus: 'pending' | 'confirmed' = current.status === 'confirmed' ? 'pending' : 'confirmed'
set(state => ({
reservations: state.reservations.map(r => r.id === id ? { ...r, status: newStatus } : r)
}))
try {
await reservationsApi.update(tripId, id, { status: newStatus })
} catch {
set({ reservations: prev })
}
},
deleteReservation: async (tripId, id) => {
try {
await reservationsApi.delete(tripId, id)
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error deleting reservation'))
}
},
})