diff --git a/client/src/App.tsx b/client/src/App.tsx index 8eba608..80e8338 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -11,6 +11,7 @@ import AdminPage from './pages/AdminPage' import SettingsPage from './pages/SettingsPage' import VacayPage from './pages/VacayPage' import AtlasPage from './pages/AtlasPage' +import SharedTripPage from './pages/SharedTripPage' import { ToastContainer } from './components/shared/Toast' import { TranslationProvider, useTranslation } from './i18n' import DemoBanner from './components/Layout/DemoBanner' @@ -128,6 +129,7 @@ export default function App() { } /> } /> + } /> } /> ) => apiClient.put('/backup/auto-settings', settings).then(r => r.data), } +export const shareApi = { + getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/share-link`).then(r => r.data), + createLink: (tripId: number | string, perms?: Record) => apiClient.post(`/trips/${tripId}/share-link`, perms || {}).then(r => r.data), + deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/share-link`).then(r => r.data), + getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data), +} + export const notificationsApi = { getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data), updatePreferences: (prefs: Record) => apiClient.put('/notifications/preferences', prefs).then(r => r.data), diff --git a/client/src/components/Trips/TripMembersModal.tsx b/client/src/components/Trips/TripMembersModal.tsx index 9180936..f0deb44 100644 --- a/client/src/components/Trips/TripMembersModal.tsx +++ b/client/src/components/Trips/TripMembersModal.tsx @@ -1,9 +1,9 @@ import { useState, useEffect } from 'react' import Modal from '../shared/Modal' -import { tripsApi, authApi } from '../../api/client' +import { tripsApi, authApi, shareApi } from '../../api/client' import { useToast } from '../shared/Toast' import { useAuthStore } from '../../store/authStore' -import { Crown, UserMinus, UserPlus, Users, LogOut } from 'lucide-react' +import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check } from 'lucide-react' import { useTranslation } from '../../i18n' import { getApiErrorMessage } from '../../types' import CustomSelect from '../shared/CustomSelect' @@ -32,6 +32,129 @@ function Avatar({ username, avatarUrl, size = 32 }: AvatarProps) { ) } +function ShareLinkSection({ tripId, t }: { tripId: number; t: any }) { + const [shareToken, setShareToken] = useState(null) + const [loading, setLoading] = useState(true) + const [copied, setCopied] = useState(false) + const [perms, setPerms] = useState({ share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false }) + const toast = useToast() + + useEffect(() => { + shareApi.getLink(tripId).then(d => { + setShareToken(d.token) + if (d.token) setPerms({ share_map: d.share_map ?? true, share_bookings: d.share_bookings ?? true, share_packing: d.share_packing ?? false, share_budget: d.share_budget ?? false, share_collab: d.share_collab ?? false }) + setLoading(false) + }).catch(() => setLoading(false)) + }, [tripId]) + + const shareUrl = shareToken ? `${window.location.origin}/shared/${shareToken}` : null + + const handleCreate = async () => { + try { + const d = await shareApi.createLink(tripId, perms) + setShareToken(d.token) + } catch { toast.error(t('share.createError')) } + } + + const handleUpdatePerms = async (key: string, val: boolean) => { + const newPerms = { ...perms, [key]: val } + setPerms(newPerms) + if (shareToken) { + try { await shareApi.createLink(tripId, newPerms) } catch {} + } + } + + const handleDelete = async () => { + try { + await shareApi.deleteLink(tripId) + setShareToken(null) + } catch {} + } + + const handleCopy = () => { + if (shareUrl) { + navigator.clipboard.writeText(shareUrl) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + + if (loading) return null + + return ( +
+
+ + {t('share.linkTitle')} +
+

{t('share.linkHint')}

+ + {/* Permission checkboxes */} +
+ {[ + { key: 'share_map', label: t('share.permMap'), always: true }, + { key: 'share_bookings', label: t('share.permBookings') }, + { key: 'share_packing', label: t('share.permPacking') }, + { key: 'share_budget', label: t('share.permBudget') }, + { key: 'share_collab', label: t('share.permCollab') }, + ].map(opt => ( + + ))} +
+ + {shareUrl ? ( +
+
+ + +
+ +
+ ) : ( + + )} +
+ ) +} + interface TripMembersModalProps { isOpen: boolean onClose: () => void @@ -123,8 +246,12 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }: ] : [] return ( - -
+ +
+ + + {/* Left column: Members */} +
{/* Trip name */}
@@ -228,6 +355,13 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }: )}
+
+ + {/* Right column: Share Link */} +
+ +
+
diff --git a/client/src/components/shared/Modal.tsx b/client/src/components/shared/Modal.tsx index 7b39e1f..5b8124b 100644 --- a/client/src/components/shared/Modal.tsx +++ b/client/src/components/shared/Modal.tsx @@ -7,6 +7,7 @@ const sizeClasses: Record = { lg: 'max-w-lg', xl: 'max-w-2xl', '2xl': 'max-w-4xl', + '3xl': 'max-w-5xl', } interface ModalProps { diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 777971d..2d84bad 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -155,6 +155,18 @@ const de: Record = { 'admin.smtp.testSuccess': 'Test-E-Mail erfolgreich gesendet', 'admin.smtp.testFailed': 'Test-E-Mail fehlgeschlagen', 'dayplan.icsTooltip': 'Kalender exportieren (ICS)', + 'share.linkTitle': 'Öffentlicher Link', + 'share.linkHint': 'Erstelle einen Link den jeder ohne Login nutzen kann, um diese Reise anzuschauen. Nur lesen — keine Bearbeitung möglich.', + 'share.createLink': 'Link erstellen', + 'share.deleteLink': 'Link löschen', + 'share.createError': 'Link konnte nicht erstellt werden', + 'common.copy': 'Kopieren', + 'common.copied': 'Kopiert', + 'share.permMap': 'Karte & Plan', + 'share.permBookings': 'Buchungen', + 'share.permPacking': 'Packliste', + 'share.permBudget': 'Budget', + 'share.permCollab': 'Chat', 'settings.on': 'An', 'settings.off': 'Aus', 'settings.account': 'Konto', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index da14e70..98082a6 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -155,6 +155,18 @@ const en: Record = { 'admin.smtp.testSuccess': 'Test email sent successfully', 'admin.smtp.testFailed': 'Test email failed', 'dayplan.icsTooltip': 'Export calendar (ICS)', + 'share.linkTitle': 'Public Link', + 'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.', + 'share.createLink': 'Create link', + 'share.deleteLink': 'Delete link', + 'share.createError': 'Could not create link', + 'common.copy': 'Copy', + 'common.copied': 'Copied', + 'share.permMap': 'Map & Plan', + 'share.permBookings': 'Bookings', + 'share.permPacking': 'Packing', + 'share.permBudget': 'Budget', + 'share.permCollab': 'Chat', 'settings.on': 'On', 'settings.off': 'Off', 'settings.account': 'Account', diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx new file mode 100644 index 0000000..b325ea6 --- /dev/null +++ b/client/src/pages/SharedTripPage.tsx @@ -0,0 +1,392 @@ +import { useState, useEffect } from 'react' +import { useParams } from 'react-router-dom' +import { MapContainer, TileLayer, Marker, Tooltip, useMap } from 'react-leaflet' +import L from 'leaflet' +import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n' +import { useSettingsStore } from '../store/settingsStore' +import { shareApi } from '../api/client' +import { getCategoryIcon } from '../components/shared/categoryIcons' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react' + +const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise']) +const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship } + +function createMarkerIcon(place: any) { + const cat = place.category + const color = cat?.color || '#6366f1' + const CatIcon = getCategoryIcon(cat?.icon) + const iconSvg = renderToStaticMarkup(createElement(CatIcon, { size: 14, strokeWidth: 2, color: 'white' })) + return L.divIcon({ + className: '', + iconSize: [28, 28], + iconAnchor: [14, 14], + html: `
${iconSvg}
`, + }) +} + +function FitBoundsToPlaces({ places }: { places: any[] }) { + const map = useMap() + useEffect(() => { + if (places.length === 0) return + const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng])) + map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 }) + }, [places, map]) + return null +} + +export default function SharedTripPage() { + const { token } = useParams<{ token: string }>() + const { t, locale } = useTranslation() + const [data, setData] = useState(null) + const [error, setError] = useState(false) + const [selectedDay, setSelectedDay] = useState(null) + const [activeTab, setActiveTab] = useState('plan') + const { updateSetting } = useSettingsStore() + const [showLangPicker, setShowLangPicker] = useState(false) + + useEffect(() => { + if (!token) return + shareApi.getSharedTrip(token).then(setData).catch(() => setError(true)) + }, [token]) + + if (error) return ( +
+
+
🔒
+

Link expired or invalid

+

This shared trip link is no longer active.

+
+
+ ) + + if (!data) return ( +
+
+ +
+ ) + + const { trip, days, assignments, dayNotes, places, reservations, accommodations, packing, budget, categories, permissions, collab } = data + const sortedDays = [...(days || [])].sort((a: any, b: any) => a.day_number - b.day_number) + + // Map places + const mapPlaces = selectedDay + ? (assignments[String(selectedDay)] || []).map((a: any) => a.place).filter((p: any) => p?.lat && p?.lng) + : (places || []).filter((p: any) => p?.lat && p?.lng) + + const center = mapPlaces.length > 0 ? [mapPlaces[0].lat, mapPlaces[0].lng] : [48.85, 2.35] + + return ( +
+ {/* Header */} +
+ {/* Cover image background */} + {trip.cover_image && ( +
+ )} + {/* Background decoration */} +
+
+ + {/* Logo */} +
+ TREK +
+ +
Travel Resource & Exploration Kit
+ +

{trip.title}

+ + {trip.description && ( +
{trip.description}
+ )} + + {(trip.start_date || trip.end_date) && ( +
+ + {[trip.start_date, trip.end_date].filter(Boolean).map((d: string) => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })).join(' — ')} + + {days?.length > 0 && ·} + {days?.length > 0 && {days.length} days} +
+ )} + +
Read-only shared view
+ + {/* Language picker - top right */} +
+ + {showLangPicker && ( +
+ {SUPPORTED_LANGUAGES.map(lang => ( + + ))} +
+ )} +
+
+ +
+ {/* Tabs */} +
+ {[ + { id: 'plan', label: 'Plan', Icon: Map }, + ...(permissions?.share_bookings ? [{ id: 'bookings', label: 'Bookings', Icon: Ticket }] : []), + ...(permissions?.share_packing ? [{ id: 'packing', label: 'Packing', Icon: Luggage }] : []), + ...(permissions?.share_budget ? [{ id: 'budget', label: 'Budget', Icon: Wallet }] : []), + ...(permissions?.share_collab ? [{ id: 'collab', label: 'Chat', Icon: MessageCircle }] : []), + ].map(tab => ( + + ))} +
+ + {/* Map */} + {activeTab === 'plan' && (<> +
+ + + + {mapPlaces.map((p: any) => ( + + {p.name} + + ))} + +
+ + {/* Day Plan */} +
+ {sortedDays.map((day: any, di: number) => { + const da = assignments[String(day.id)] || [] + const notes = (dayNotes[String(day.id)] || []) + const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date) + const dayAccs = (accommodations || []).filter((a: any) => day.id >= a.start_day_id && day.id <= a.end_day_id) + + const merged = [ + ...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })), + ...notes.map((n: any) => ({ type: 'note', k: n.sort_order ?? 0, data: n })), + ...dayTransport.map((r: any) => ({ type: 'transport', k: r.day_plan_position ?? 999, data: r })), + ].sort((a, b) => a.k - b.k) + + return ( +
+
setSelectedDay(selectedDay === day.id ? null : day.id)} + style={{ padding: '12px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}> +
{di + 1}
+
+
{day.title || `Day ${day.day_number}`}
+ {day.date &&
{new Date(day.date + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
} +
+ {dayAccs.map((acc: any) => ( + + {acc.place_name} + + ))} + {da.length} {da.length === 1 ? 'place' : 'places'} +
+ + {selectedDay === day.id && merged.length > 0 && ( +
+ {merged.map((item: any, idx: number) => { + if (item.type === 'transport') { + const r = item.data + const TIcon = TRANSPORT_ICONS[r.type] || Ticket + const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) + const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : '' + let sub = '' + if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ') + else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ') + return ( +
+
+ +
+
+
{r.title}{time ? ` · ${time}` : ''}
+ {sub &&
{sub}
} +
+
+ ) + } + if (item.type === 'note') { + return ( +
+ +
+
{item.data.text}
+ {item.data.time &&
{item.data.time}
} +
+
+ ) + } + const place = item.data.place + if (!place) return null + const cat = categories?.find((c: any) => c.id === place.category_id) + return ( +
+
+ {place.image_url ? : } +
+
+
{place.name}
+ {(place.address || place.description) &&
{place.address || place.description}
} +
+ {place.place_time && {place.place_time}{place.end_time ? ` – ${place.end_time}` : ''}} +
+ ) + })} +
+ )} +
+ ) + })} +
+ )} + + {/* Bookings */} + {activeTab === 'bookings' && (reservations || []).length > 0 && ( +
+ {(reservations || []).map((r: any) => { + const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) + const TIcon = TRANSPORT_ICONS[r.type] || Ticket + const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : '' + const date = r.reservation_time ? new Date(r.reservation_time.includes('T') ? r.reservation_time : r.reservation_time + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' }) : '' + return ( +
+
+ +
+
+
{r.title}
+
+ {date && {date}} + {time && {time}} + {r.location && {r.location}} + {meta.airline && {meta.airline} {meta.flight_number || ''}} + {meta.train_number && {meta.train_number}} +
+
+ + {r.status} + +
+ ) + })} +
+ )} + + {/* Packing */} + {activeTab === 'packing' && (packing || []).length > 0 && ( +
+ {Object.entries((packing || []).reduce((g: any, i: any) => { const c = i.category || 'Other'; (g[c] = g[c] || []).push(i); return g }, {})).map(([cat, items]: [string, any]) => ( +
+
{cat}
+ {items.map((item: any) => ( +
+ {item.name} +
+ ))} +
+ ))} +
+ )} + + {/* Budget */} + {activeTab === 'budget' && (budget || []).length > 0 && (() => { + const grouped = (budget || []).reduce((g: any, i: any) => { const c = i.category || 'Other'; (g[c] = g[c] || []).push(i); return g }, {}) + const total = (budget || []).reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0) + return ( +
+ {/* Total card */} +
+
Total Budget
+
{total.toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || 'EUR'}
+
+ {/* By category */} + {Object.entries(grouped).map(([cat, items]: [string, any]) => ( +
+
+ {cat} + {items.reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0).toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || ''} +
+ {items.map((item: any) => ( +
+ {item.name} + {item.total_price ? Number(item.total_price).toLocaleString(locale, { minimumFractionDigits: 2 }) : '—'} +
+ ))} +
+ ))} +
+ ) + })()} + + {/* Collab Chat */} + {activeTab === 'collab' && (collab || []).length > 0 && ( +
+
+ + Chat · {(collab || []).length} messages +
+
+ {(collab || []).map((msg: any, i: number) => { + const prevMsg = i > 0 ? collab[i - 1] : null + const showDate = !prevMsg || new Date(msg.created_at).toDateString() !== new Date(prevMsg.created_at).toDateString() + return ( +
+ {showDate && ( +
+ {new Date(msg.created_at).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })} +
+ )} +
+
+ {msg.avatar ? : (msg.username || '?')[0].toUpperCase()} +
+
+
+ {msg.username} + {new Date(msg.created_at).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })} +
+
{msg.text}
+
+
+
+ ) + })} +
+
+ )} + + {/* Footer */} +
+
+ TREK + Shared via TREK +
+
Made with by Maurice · GitHub
+
+
+
+ ) +} diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 1b79064..3f468d7 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -356,6 +356,29 @@ function runMigrations(db: Database.Database): void { try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_collab_message INTEGER DEFAULT 1'); } catch {} try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_packing_tagged INTEGER DEFAULT 1'); } catch {} }, + () => { + // Public share links for read-only trip access + db.exec(`CREATE TABLE IF NOT EXISTS share_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL REFERENCES users(id), + share_map INTEGER DEFAULT 1, + share_bookings INTEGER DEFAULT 1, + share_packing INTEGER DEFAULT 0, + share_budget INTEGER DEFAULT 0, + share_collab INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`); + }, + () => { + // Add permission columns to share_tokens + try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_map INTEGER DEFAULT 1'); } catch {} + try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_bookings INTEGER DEFAULT 1'); } catch {} + try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_packing INTEGER DEFAULT 0'); } catch {} + try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_budget INTEGER DEFAULT 0'); } catch {} + try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_collab INTEGER DEFAULT 0'); } catch {} + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/index.ts b/server/src/index.ts index ab39bef..4906f44 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -81,6 +81,7 @@ app.use(express.urlencoded({ extended: true })); // Avatars are public (shown on login, sharing screens) app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars'))); +app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers'))); // All other uploads require authentication app.get('/uploads/:type/:filename', (req: Request, res: Response) => { @@ -163,6 +164,9 @@ app.use('/api/backup', backupRoutes); import notificationRoutes from './routes/notifications'; app.use('/api/notifications', notificationRoutes); +import shareRoutes from './routes/share'; +app.use('/api', shareRoutes); + // Serve static files in production if (process.env.NODE_ENV === 'production') { const publicPath = path.join(__dirname, '../public'); diff --git a/server/src/routes/share.ts b/server/src/routes/share.ts new file mode 100644 index 0000000..d36ca03 --- /dev/null +++ b/server/src/routes/share.ts @@ -0,0 +1,165 @@ +import express, { Request, Response } from 'express'; +import crypto from 'crypto'; +import { db, canAccessTrip } from '../db/database'; +import { authenticate } from '../middleware/auth'; +import { AuthRequest } from '../types'; +import { loadTagsByPlaceIds } from '../services/queryHelpers'; + +const router = express.Router(); + +// Create a share link for a trip (owner/member only) +router.post('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + + const { share_map = true, share_bookings = true, share_packing = false, share_budget = false, share_collab = false } = req.body || {}; + + // Check if token already exists + const existing = db.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(tripId) as { token: string } | undefined; + if (existing) { + // Update permissions + db.prepare('UPDATE share_tokens SET share_map = ?, share_bookings = ?, share_packing = ?, share_budget = ?, share_collab = ? WHERE trip_id = ?') + .run(share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, tripId); + return res.json({ token: existing.token }); + } + + const token = crypto.randomBytes(24).toString('base64url'); + db.prepare('INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, ?, ?, ?, ?, ?)') + .run(tripId, token, authReq.user.id, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0); + res.status(201).json({ token }); +}); + +// Get share link status +router.get('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + + const row = db.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any; + res.json(row ? { token: row.token, created_at: row.created_at, share_map: !!row.share_map, share_bookings: !!row.share_bookings, share_packing: !!row.share_packing, share_budget: !!row.share_budget, share_collab: !!row.share_collab } : { token: null }); +}); + +// Delete share link +router.delete('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + + db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId); + res.json({ success: true }); +}); + +// Public read-only trip data (no auth required) +router.get('/shared/:token', (req: Request, res: Response) => { + const { token } = req.params; + const shareRow = db.prepare('SELECT * FROM share_tokens WHERE token = ?').get(token) as any; + if (!shareRow) return res.status(404).json({ error: 'Invalid or expired link' }); + + const tripId = shareRow.trip_id; + + // Trip + const trip = db.prepare('SELECT id, title, description, start_date, end_date, cover_image, currency FROM trips WHERE id = ?').get(tripId); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + // Days with assignments + const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as any[]; + const dayIds = days.map(d => d.id); + + let assignments = {}; + let dayNotes = {}; + if (dayIds.length > 0) { + const ph = dayIds.map(() => '?').join(','); + const allAssignments = db.prepare(` + SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, + p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, + COALESCE(da.assignment_time, p.place_time) as place_time, + COALESCE(da.assignment_end_time, p.end_time) as end_time, + p.duration_minutes, p.notes as place_notes, p.image_url, p.transport_mode, + c.name as category_name, c.color as category_color, c.icon as category_icon + FROM day_assignments da + JOIN places p ON da.place_id = p.id + LEFT JOIN categories c ON p.category_id = c.id + WHERE da.day_id IN (${ph}) + ORDER BY da.order_index ASC + `).all(...dayIds); + + const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))]; + const tagsByPlace = loadTagsByPlaceIds(placeIds, { compact: true }); + + const byDay: Record = {}; + for (const a of allAssignments as any[]) { + if (!byDay[a.day_id]) byDay[a.day_id] = []; + byDay[a.day_id].push({ + id: a.id, day_id: a.day_id, order_index: a.order_index, notes: a.notes, + place: { + id: a.place_id, name: a.place_name, description: a.place_description, + lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id, + price: a.price, place_time: a.place_time, end_time: a.end_time, + image_url: a.image_url, transport_mode: a.transport_mode, + category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null, + tags: tagsByPlace[a.place_id] || [], + } + }); + } + assignments = byDay; + + const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC`).all(...dayIds); + const notesByDay: Record = {}; + for (const n of allNotes as any[]) { + if (!notesByDay[n.day_id]) notesByDay[n.day_id] = []; + notesByDay[n.day_id].push(n); + } + dayNotes = notesByDay; + } + + // Places + const places = db.prepare(` + SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon + FROM places p LEFT JOIN categories c ON p.category_id = c.id + WHERE p.trip_id = ? ORDER BY p.created_at DESC + `).all(tripId); + + // Reservations + const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId); + + // Accommodations + const accommodations = db.prepare(` + SELECT a.*, p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng + FROM day_accommodations a JOIN places p ON a.place_id = p.id + WHERE a.trip_id = ? + `).all(tripId); + + // Packing + const packing = db.prepare('SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC').all(tripId); + + // Budget + const budget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC').all(tripId); + + // Categories + const categories = db.prepare('SELECT * FROM categories').all(); + + const permissions = { + share_map: !!shareRow.share_map, + share_bookings: !!shareRow.share_bookings, + share_packing: !!shareRow.share_packing, + share_budget: !!shareRow.share_budget, + share_collab: !!shareRow.share_collab, + }; + + // Only include data the owner chose to share + const collabMessages = permissions.share_collab + ? db.prepare('SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? ORDER BY m.created_at ASC').all(tripId) + : []; + + res.json({ + trip, days, assignments, dayNotes, places, categories, permissions, + reservations: permissions.share_bookings ? reservations : [], + accommodations: permissions.share_bookings ? accommodations : [], + packing: permissions.share_packing ? packing : [], + budget: permissions.share_budget ? budget : [], + collab: collabMessages, + }); +}); + +export default router;