diff --git a/src/app/api/trips/[id]/canvas/route.ts b/src/app/api/trips/[id]/canvas/route.ts new file mode 100644 index 0000000..3bf0d32 --- /dev/null +++ b/src/app/api/trips/[id]/canvas/route.ts @@ -0,0 +1,147 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { pushShapesToCanvas } from '@/lib/canvas-sync'; + +/** + * POST /api/trips/[id]/canvas + * + * Auto-creates an rspace community for the trip and populates it + * with initial shapes from the trip's structured data. + * Stores the canvasSlug back on the Trip record. + */ +export async function POST( + _request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const trip = await prisma.trip.findUnique({ + where: { id: params.id }, + include: { + destinations: { orderBy: { sortOrder: 'asc' } }, + itineraryItems: { orderBy: [{ date: 'asc' }, { sortOrder: 'asc' }] }, + bookings: { orderBy: { startDate: 'asc' } }, + packingItems: { orderBy: [{ category: 'asc' }, { sortOrder: 'asc' }] }, + }, + }); + + if (!trip) { + return NextResponse.json({ error: 'Trip not found' }, { status: 404 }); + } + + // If canvas already exists, just re-push shapes + const canvasSlug = trip.canvasSlug || trip.slug; + + // Build shapes from trip data + const shapes: Record[] = []; + let x = 50; + let y = 50; + + // Itinerary shape (top-left) + if (trip.itineraryItems.length > 0) { + shapes.push({ + type: 'folk-itinerary', + x, y, + width: 400, height: 500, + tripTitle: trip.title, + items: trip.itineraryItems.map((item) => ({ + title: item.title, + date: item.date?.toISOString().split('T')[0] || '', + startTime: item.startTime || '', + endTime: item.endTime || '', + category: item.category, + description: item.description || '', + })), + }); + x += 450; + } + + // Destination shapes (across the top) + for (const dest of trip.destinations) { + shapes.push({ + type: 'folk-destination', + x, y, + width: 300, height: 250, + destName: dest.name, + country: dest.country || '', + lat: dest.lat || 0, + lng: dest.lng || 0, + arrivalDate: dest.arrivalDate?.toISOString().split('T')[0] || '', + departureDate: dest.departureDate?.toISOString().split('T')[0] || '', + notes: dest.notes || '', + }); + x += 350; + } + + // Budget shape (second row, left) + x = 50; + y = 600; + if (trip.budgetTotal) { + shapes.push({ + type: 'folk-budget', + x, y, + width: 400, height: 400, + budgetTotal: trip.budgetTotal, + currency: trip.budgetCurrency, + expenses: [], + }); + x += 450; + } + + // Booking shapes (second row) + for (const booking of trip.bookings) { + shapes.push({ + type: 'folk-booking', + x, y, + width: 320, height: 280, + bookingType: booking.type, + provider: booking.provider || '', + confirmationNumber: booking.confirmationNumber || '', + details: booking.details || '', + cost: booking.cost || 0, + currency: booking.currency, + startDate: booking.startDate?.toISOString().split('T')[0] || '', + endDate: booking.endDate?.toISOString().split('T')[0] || '', + bookingStatus: booking.status, + }); + x += 370; + } + + // Packing list shape (third row) + if (trip.packingItems.length > 0) { + shapes.push({ + type: 'folk-packing-list', + x: 50, y: 1050, + width: 350, height: 400, + items: trip.packingItems.map((item) => ({ + name: item.name, + category: item.category || 'General', + packed: item.packed, + quantity: item.quantity, + })), + }); + } + + // Push shapes to rspace canvas + await pushShapesToCanvas(canvasSlug, shapes); + + // Update trip with canvas slug if not set + if (!trip.canvasSlug) { + await prisma.trip.update({ + where: { id: trip.id }, + data: { canvasSlug }, + }); + } + + return NextResponse.json({ + canvasSlug, + shapesCreated: shapes.length, + canvasUrl: `https://${canvasSlug}.rspace.online`, + }); + } catch (error) { + console.error('Create canvas error:', error); + return NextResponse.json( + { error: 'Failed to create canvas' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/trips/[id]/sync/route.ts b/src/app/api/trips/[id]/sync/route.ts new file mode 100644 index 0000000..4c5826d --- /dev/null +++ b/src/app/api/trips/[id]/sync/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; + +/** + * POST /api/trips/[id]/sync + * + * Receives shape update events from the rspace canvas (via postMessage → CanvasEmbed → fetch) + * and updates the corresponding DB records. + * + * Body: { shapeId: string, type: 'shape-updated' | 'shape-deleted', data: Record } + */ +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const body = await request.json(); + const { shapeId, type, data } = body; + + if (!shapeId || !type) { + return NextResponse.json( + { error: 'Missing shapeId or type' }, + { status: 400 } + ); + } + + const trip = await prisma.trip.findUnique({ where: { id: params.id } }); + if (!trip) { + return NextResponse.json({ error: 'Trip not found' }, { status: 404 }); + } + + // Determine which entity this shape maps to by checking canvasShapeId + const shapeType = data?.type as string | undefined; + + if (type === 'shape-deleted') { + // Clear canvasShapeId references (don't delete the DB record - user can re-link) + await Promise.all([ + prisma.destination.updateMany({ + where: { tripId: trip.id, canvasShapeId: shapeId }, + data: { canvasShapeId: null }, + }), + prisma.itineraryItem.updateMany({ + where: { tripId: trip.id, canvasShapeId: shapeId }, + data: { canvasShapeId: null }, + }), + prisma.booking.updateMany({ + where: { tripId: trip.id, canvasShapeId: shapeId }, + data: { canvasShapeId: null }, + }), + ]); + + return NextResponse.json({ ok: true, action: 'unlinked' }); + } + + // shape-updated: try to match and update the corresponding DB record + if (shapeType === 'folk-destination') { + const dest = await prisma.destination.findFirst({ + where: { tripId: trip.id, canvasShapeId: shapeId }, + }); + + if (dest) { + await prisma.destination.update({ + where: { id: dest.id }, + data: { + name: (data.destName as string) || dest.name, + country: (data.country as string) || dest.country, + lat: typeof data.lat === 'number' ? data.lat : dest.lat, + lng: typeof data.lng === 'number' ? data.lng : dest.lng, + notes: (data.notes as string) ?? dest.notes, + }, + }); + return NextResponse.json({ ok: true, action: 'updated', entity: 'destination', id: dest.id }); + } + } + + if (shapeType === 'folk-booking') { + const booking = await prisma.booking.findFirst({ + where: { tripId: trip.id, canvasShapeId: shapeId }, + }); + + if (booking) { + await prisma.booking.update({ + where: { id: booking.id }, + data: { + provider: (data.provider as string) || booking.provider, + confirmationNumber: (data.confirmationNumber as string) || booking.confirmationNumber, + cost: typeof data.cost === 'number' ? data.cost : booking.cost, + details: (data.details as string) || booking.details, + }, + }); + return NextResponse.json({ ok: true, action: 'updated', entity: 'booking', id: booking.id }); + } + } + + // No matching DB record found - that's OK, not all canvas shapes are linked + return NextResponse.json({ ok: true, action: 'no-match' }); + } catch (error) { + console.error('Canvas sync error:', error); + return NextResponse.json( + { error: 'Failed to sync canvas update' }, + { status: 500 } + ); + } +} diff --git a/src/app/trips/[id]/page.tsx b/src/app/trips/[id]/page.tsx index 7822c50..5bd63ef 100644 --- a/src/app/trips/[id]/page.tsx +++ b/src/app/trips/[id]/page.tsx @@ -1,10 +1,11 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { useParams } from 'next/navigation'; import Link from 'next/link'; import { format } from 'date-fns'; import { CanvasEmbed } from '@/components/CanvasEmbed'; +import { CanvasShapeMessage } from '@/lib/canvas-sync'; interface Trip { id: string; @@ -116,8 +117,9 @@ export default function TripDashboard() { const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState('overview'); const [showCanvas, setShowCanvas] = useState(false); + const [creatingCanvas, setCreatingCanvas] = useState(false); - useEffect(() => { + const fetchTrip = useCallback(() => { fetch(`/api/trips/${params.id}`) .then((res) => res.json()) .then(setTrip) @@ -125,6 +127,45 @@ export default function TripDashboard() { .finally(() => setLoading(false)); }, [params.id]); + useEffect(() => { + fetchTrip(); + }, [fetchTrip]); + + const handleShapeUpdate = useCallback( + (message: CanvasShapeMessage) => { + if (!trip) return; + fetch(`/api/trips/${trip.id}/sync`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + shapeId: message.shapeId, + type: message.type, + data: message.data, + }), + }) + .then((res) => { + if (res.ok) fetchTrip(); // Refresh trip data after sync + }) + .catch(console.error); + }, + [trip, fetchTrip] + ); + + const handleCreateCanvas = async () => { + if (!trip) return; + setCreatingCanvas(true); + try { + const res = await fetch(`/api/trips/${trip.id}/canvas`, { method: 'POST' }); + if (res.ok) { + fetchTrip(); // Refresh to get canvasSlug + } + } catch (err) { + console.error('Failed to create canvas:', err); + } finally { + setCreatingCanvas(false); + } + }; + if (loading) { return (
@@ -440,6 +481,7 @@ export default function TripDashboard() {
@@ -449,9 +491,16 @@ export default function TripDashboard() {

No canvas linked yet.

-

- A collaborative canvas will be created on rSpace when you're ready to plan visually with your travel partners. +

+ Create a collaborative canvas on rSpace to plan visually with your travel partners.

+
)}