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:
parent
cc88cdd0d8
commit
5949973d13
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue