diff --git a/nextjs-typescript-starter/app/components/FriendsTripsList.tsx b/nextjs-typescript-starter/app/components/FriendsTripsList.tsx new file mode 100644 index 0000000..ce0413d --- /dev/null +++ b/nextjs-typescript-starter/app/components/FriendsTripsList.tsx @@ -0,0 +1,66 @@ +"use client"; + +import Link from "next/link"; +import type { Trip } from "app/components/TripCarousel"; +import type { Share } from "app/sharesStore"; +import type { Friend } from "app/friendsStore"; + +export default function FriendsTripsList({ + friends, + trips, + shares, + compact = false, + onUninvite, +}: { + friends: Friend[]; + trips: Trip[]; + shares: Share[]; + compact?: boolean; + onUninvite?: (friendId: string, tripId: string) => void; +}) { + const tripTitle = (id: string) => trips.find((t) => t.id === id)?.name ?? `Trip ${id}`; + + // Build friend -> [tripIds] map using only trips that exist + const byFriend = new Map(); + for (const f of friends) byFriend.set(f.id, { friendId: f.id, friendName: f.name, tripIds: [] }); + for (const s of shares) { + if (!byFriend.has(s.friendId)) continue; + if (trips.some((t) => t.id === s.tripId)) { + byFriend.get(s.friendId)!.tripIds.push(s.tripId); + } + } + const groups = Array.from(byFriend.values()).filter((g) => g.tripIds.length > 0); + + if (groups.length === 0) { + return
No shared trips yet
; + } + + return ( +
+ {groups.map((g) => ( +
+
+ {g.friendName} +
+
    + {g.tripIds.map((tid) => ( +
  • + + {tripTitle(tid)} + + {onUninvite && ( + + )} +
  • + ))} +
+
+ ))} +
+ ); +} diff --git a/nextjs-typescript-starter/app/components/InviteDialog.tsx b/nextjs-typescript-starter/app/components/InviteDialog.tsx index 782e8e0..de03a47 100644 --- a/nextjs-typescript-starter/app/components/InviteDialog.tsx +++ b/nextjs-typescript-starter/app/components/InviteDialog.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useId, useMemo, useState } from 'react'; +import { useEffect, useId, useState } from 'react'; +import type { Trip } from 'app/components/TripCarousel'; import type { AccessLevel } from 'app/sharesStore'; type Props = { @@ -10,17 +11,13 @@ type Props = { onInvited?: (result: { tripId: string; access: AccessLevel }) => void; }; -const sampleTrips = [ - { id: '1', title: 'Cancún Trip 🌴' }, - { id: '2', title: 'NYC Weekend 🗽' }, - { id: '3', title: 'Banff Ski Trip ⛷️' }, -]; - export default function InviteDialog({ open, onClose, friend, onInvited }: Props) { const [tripId, setTripId] = useState(''); const [access, setAccess] = useState('view'); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const [trips, setTrips] = useState([]); + const [tripsLoading, setTripsLoading] = useState(false); const titleId = useId(); useEffect(() => { @@ -28,11 +25,26 @@ export default function InviteDialog({ open, onClose, friend, onInvited }: Props setTripId(''); setAccess('view'); setError(null); + // Load real trips every time the dialog opens + let abort = false; + (async () => { + setTripsLoading(true); + try { + const res = await fetch('/api/trips', { cache: 'no-store' }); + const data = await res.json(); + if (!abort) setTrips(Array.isArray(data.trips) ? data.trips : []); + } catch { + if (!abort) setTrips([]); + } finally { + if (!abort) setTripsLoading(false); + } + })(); + return () => { + abort = true; + }; } }, [open]); - const trips = sampleTrips; - if (!open) return null; async function handleInvite() { @@ -78,8 +90,12 @@ export default function InviteDialog({ open, onClose, friend, onInvited }: Props className="w-full rounded-md bg-stone-800 border border-stone-700 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-400" > - {trips.map((t) => ( - + {tripsLoading && } + {!tripsLoading && trips.length === 0 && ( + + )} + {!tripsLoading && trips.map((t) => ( + ))} diff --git a/nextjs-typescript-starter/app/components/SideNav.tsx b/nextjs-typescript-starter/app/components/SideNav.tsx index 5a43ae2..0f8353c 100644 --- a/nextjs-typescript-starter/app/components/SideNav.tsx +++ b/nextjs-typescript-starter/app/components/SideNav.tsx @@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from "react"; import type { Trip } from "app/components/TripCarousel"; import type { Share } from "app/sharesStore"; import type { Friend } from "app/friendsStore"; +import FriendsTripsList from "./FriendsTripsList"; function NavLink({ href, children, onClick }: { href: string; children: React.ReactNode; onClick?: () => void }) { const pathname = usePathname(); @@ -47,6 +48,15 @@ export default function SideNav({ open, onClose }: { open: boolean; onClose: () }; }, []); const [trips, setTrips] = useState([]); + const reloadTrips = async () => { + try { + const res = await fetch('/api/trips', { cache: 'no-store' }); + const data = await res.json(); + setTrips(Array.isArray(data.trips) ? data.trips : []); + } catch { + setTrips([]); + } + }; useEffect(() => { let abort = false; (async () => { @@ -58,7 +68,9 @@ export default function SideNav({ open, onClose }: { open: boolean; onClose: () if (!abort) setTrips([]); } })(); - return () => { abort = true; }; + const onTripsUpdated = () => { if (!abort) void reloadTrips(); }; + window.addEventListener('trips:updated', onTripsUpdated as EventListener); + return () => { abort = true; window.removeEventListener('trips:updated', onTripsUpdated as EventListener); }; }, []); const [shares, setShares] = useState([]); @@ -82,18 +94,7 @@ export default function SideNav({ open, onClose }: { open: boolean; onClose: () return () => { abort = true; }; }, []); - const tripTitle = (id: string) => trips.find(t => t.id === id)?.name ?? `Trip ${id}`; - const friendsWithShares = useMemo(() => { - const byFriend = new Map(); - for (const f of friends) byFriend.set(f.id, { friendId: f.id, friendName: f.name, tripIds: [] }); - for (const s of shares) { - if (!byFriend.has(s.friendId)) continue; - if (trips.some(t => t.id === s.tripId)) { - byFriend.get(s.friendId)!.tripIds.push(s.tripId); - } - } - return Array.from(byFriend.values()).filter(g => g.tripIds.length > 0); - }, [friends, shares, trips]); + const hasShares = useMemo(() => shares.some(s => trips.some(t => t.id === s.tripId)), [shares, trips]); return (
@@ -105,20 +106,9 @@ export default function SideNav({ open, onClose }: { open: boolean; onClose: () Trips Friends {/* Friends -> trips grouping */} - {friendsWithShares.length > 0 && ( + {hasShares && (
- {friendsWithShares.map((g) => ( -
-
{g.friendName}
-
    - {g.tripIds.map((tid) => ( -
  • - {tripTitle(tid)} -
  • - ))} -
-
- ))} +
)} @@ -147,20 +137,9 @@ export default function SideNav({ open, onClose }: { open: boolean; onClose: () Trips Friends {/* Friends -> trips grouping */} - {friendsWithShares.length > 0 && ( + {hasShares && (
- {friendsWithShares.map((g) => ( -
-
{g.friendName}
-
    - {g.tripIds.map((tid) => ( -
  • - {tripTitle(tid)} -
  • - ))} -
-
- ))} +
)} diff --git a/nextjs-typescript-starter/app/dashboard/page.tsx b/nextjs-typescript-starter/app/dashboard/page.tsx index 14113ed..3f91540 100644 --- a/nextjs-typescript-starter/app/dashboard/page.tsx +++ b/nextjs-typescript-starter/app/dashboard/page.tsx @@ -36,7 +36,18 @@ export default function DashboardPage() { if (!abort) setTripsLoading(false); } })(); - return () => { abort = true; }; + const onTripsUpdated = async () => { + if (abort) return; + try { + const res = await fetch('/api/trips', { cache: 'no-store' }); + const data = await res.json(); + if (!abort) setTrips(Array.isArray(data.trips) ? data.trips : []); + } catch { + if (!abort) setTrips([]); + } + }; + window.addEventListener('trips:updated', onTripsUpdated as EventListener); + return () => { abort = true; window.removeEventListener('trips:updated', onTripsUpdated as EventListener); }; }, []); type ViewMode = "carousel" | "grid" | "list"; diff --git a/nextjs-typescript-starter/app/friends/page.tsx b/nextjs-typescript-starter/app/friends/page.tsx index 8669078..f92898d 100644 --- a/nextjs-typescript-starter/app/friends/page.tsx +++ b/nextjs-typescript-starter/app/friends/page.tsx @@ -6,6 +6,7 @@ import SideNavShell from "app/components/SideNavShell"; import type { Share } from "app/sharesStore"; import type { Trip } from "app/components/TripCarousel"; import type { Friend } from "app/friendsStore"; +import FriendsTripsList from "app/components/FriendsTripsList"; export default function FriendsPage() { // Live friends list from API (can add more via search+add) @@ -225,53 +226,13 @@ export default function FriendsPage() {
-
- {friends - .filter((f) => f.name.toLowerCase().includes(search.trim().toLowerCase())) - .map((f) => { - const fShares = shares.filter(s => s.friendId === f.id); - // Deduplicate and keep only trip ids that exist - const uniqueTripIds = Array.from( - new Set(fShares.map((s) => s.tripId).filter((tid) => trips.some((t) => t.id === tid))) - ); - return ( -
-

{f.name}

-
    - {uniqueTripIds.length > 0 ? ( - uniqueTripIds.map((tid) => { - const sh = fShares.find((s) => s.tripId === tid); - const access = sh?.access ?? 'view'; - return ( -
  • -
    - {tripTitle(tid)} - {access} -
    -
    - - View - - -
    -
  • - ); - }) - ) : ( -
  • No shared trips yet
  • - )} -
-
- ); - })} +
+ f.name.toLowerCase().includes(search.trim().toLowerCase()))} + trips={trips} + shares={shares} + onUninvite={(fid, tid) => void uninvite(fid, tid)} + />
{loading && ( diff --git a/nextjs-typescript-starter/app/trips/[id]/page.tsx b/nextjs-typescript-starter/app/trips/[id]/page.tsx index 57d18dd..a3d846a 100644 --- a/nextjs-typescript-starter/app/trips/[id]/page.tsx +++ b/nextjs-typescript-starter/app/trips/[id]/page.tsx @@ -7,6 +7,9 @@ import SideNavShell from 'app/components/SideNavShell'; type TripItem = { id: string; text: string; done: boolean; addedBy?: string }; type Trip = { id: string; name: string; items: TripItem[]; shareToken: string }; +type AccessLevel = 'view' | 'suggest' | 'edit'; +type Share = { id: string; friendId: string; tripId: string; access: AccessLevel }; +type Friend = { id: string; name: string }; export default function TripPage() { const params = useParams<{ id: string }>(); @@ -23,6 +26,10 @@ export default function TripPage() { const [nameInput, setNameInput] = useState(''); const [editingItemId, setEditingItemId] = useState(null); const [itemInput, setItemInput] = useState(''); + const [accessOpen, setAccessOpen] = useState(false); + const [accessLoading, setAccessLoading] = useState(false); + const [shares, setShares] = useState([]); + const [friends, setFriends] = useState([]); async function load(showSpinner: boolean = true) { @@ -61,6 +68,28 @@ export default function TripPage() { void loadShare(); }, [tripId]); + useEffect(() => { + if (!accessOpen) return; + void loadAccess(); + }, [accessOpen]); + + async function loadAccess() { + if (!tripId) return; + setAccessLoading(true); + try { + const [sRes, fRes] = await Promise.all([ + fetch(`/api/shares?tripId=${encodeURIComponent(tripId)}`, { cache: 'no-store' }), + fetch('/api/friends', { cache: 'no-store' }), + ]); + const sData = await sRes.json().catch(() => ({} as any)); + const fData = await fRes.json().catch(() => ({} as any)); + if (sRes.ok) setShares(Array.isArray(sData.shares) ? sData.shares : []); + if (fRes.ok) setFriends(Array.isArray(fData.friends) ? fData.friends : []); + } finally { + setAccessLoading(false); + } + } + async function add() { const t = text.trim(); if (!t) return; @@ -105,6 +134,7 @@ export default function TripPage() { if (!confirm(`Delete "${trip.name}"? This cannot be undone.`)) return; const res = await fetch(`/api/trips/${tripId}`, { method: 'DELETE' }); if (res.ok || res.status === 204) { + try { window.dispatchEvent(new CustomEvent('trips:updated')); } catch {} router.push('/trips'); } } @@ -192,6 +222,14 @@ export default function TripPage() { > Copy link +
+ {accessOpen && ( +
+
setAccessOpen(false)} /> +
+
+

Who has access

+ +
+
+
+ Anyone with the link can add items. +
+
+ {accessLoading ? ( +
Loading…
+ ) : shares.length === 0 ? ( +
Not shared with any friends yet.
+ ) : ( +
    + {shares.map((s) => { + const friendName = friends.find((f) => f.id === s.friendId)?.name || s.friendId; + return ( +
  • + {friendName} + {s.access} +
  • + ); + })} +
+ )} +
+
+
+
+ )} {/* Toast error */} {error && (
diff --git a/nextjs-typescript-starter/app/trips/new/page.tsx b/nextjs-typescript-starter/app/trips/new/page.tsx index 7e11127..2734b08 100644 --- a/nextjs-typescript-starter/app/trips/new/page.tsx +++ b/nextjs-typescript-starter/app/trips/new/page.tsx @@ -58,6 +58,8 @@ export default function NewTripPage() { } // Optionally clear draft after saving try { localStorage.removeItem("packpal_trip_draft"); } catch {} + // Notify other parts of the app that trips changed + try { window.dispatchEvent(new CustomEvent('trips:updated')); } catch {} // Go to dashboard so it's visible in the list/carousel router.push("/dashboard"); } catch (e) { diff --git a/nextjs-typescript-starter/app/trips/page.tsx b/nextjs-typescript-starter/app/trips/page.tsx index e9ce60f..bf872ed 100644 --- a/nextjs-typescript-starter/app/trips/page.tsx +++ b/nextjs-typescript-starter/app/trips/page.tsx @@ -81,6 +81,7 @@ function TripsContent() { if (res.ok) { setName(''); await load(); + try { window.dispatchEvent(new CustomEvent('trips:updated')); } catch {} } } @@ -91,6 +92,7 @@ function TripsContent() { const res = await fetch(`/api/trips/${id}`, { method: 'DELETE' }); if (res.ok || res.status === 204) { await load(); + try { window.dispatchEvent(new CustomEvent('trips:updated')); } catch {} } } finally { setDeletingId(null);