Add canvas creation and two-way sync API routes

- POST /api/trips/[id]/canvas: auto-creates rspace community, populates
  with trip shapes (itinerary, destinations, bookings, budget, packing)
- POST /api/trips/[id]/sync: receives shape updates from canvas postMessage
  bridge, updates corresponding DB records
- Trip dashboard: wires up onShapeUpdate callback, adds "Create Canvas" button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 12:20:58 -07:00
parent cc88cdd0d8
commit 5949973d13
3 changed files with 304 additions and 4 deletions

View File

@ -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<string, unknown>[] = [];
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 }
);
}
}

View File

@ -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<string, unknown> }
*/
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 }
);
}
}

View File

@ -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<Tab>('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 (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
@ -440,6 +481,7 @@ export default function TripDashboard() {
<CanvasEmbed
canvasSlug={trip.canvasSlug}
className="h-[600px]"
onShapeUpdate={handleShapeUpdate}
/>
</div>
</div>
@ -449,9 +491,16 @@ export default function TripDashboard() {
<div className="w-2/5">
<div className="sticky top-6 bg-slate-800/50 rounded-xl p-6 border border-slate-700/50 text-center">
<p className="text-slate-400 mb-4">No canvas linked yet.</p>
<p className="text-xs text-slate-500">
A collaborative canvas will be created on rSpace when you&apos;re ready to plan visually with your travel partners.
<p className="text-xs text-slate-500 mb-4">
Create a collaborative canvas on rSpace to plan visually with your travel partners.
</p>
<button
onClick={handleCreateCanvas}
disabled={creatingCanvas}
className="px-4 py-2 bg-teal-500 hover:bg-teal-400 disabled:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors"
>
{creatingCanvas ? 'Creating...' : 'Create Canvas'}
</button>
</div>
</div>
)}