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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { CanvasEmbed } from '@/components/CanvasEmbed';
|
import { CanvasEmbed } from '@/components/CanvasEmbed';
|
||||||
|
import { CanvasShapeMessage } from '@/lib/canvas-sync';
|
||||||
|
|
||||||
interface Trip {
|
interface Trip {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -116,8 +117,9 @@ export default function TripDashboard() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||||
const [showCanvas, setShowCanvas] = useState(false);
|
const [showCanvas, setShowCanvas] = useState(false);
|
||||||
|
const [creatingCanvas, setCreatingCanvas] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchTrip = useCallback(() => {
|
||||||
fetch(`/api/trips/${params.id}`)
|
fetch(`/api/trips/${params.id}`)
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then(setTrip)
|
.then(setTrip)
|
||||||
|
|
@ -125,6 +127,45 @@ export default function TripDashboard() {
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [params.id]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
<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
|
<CanvasEmbed
|
||||||
canvasSlug={trip.canvasSlug}
|
canvasSlug={trip.canvasSlug}
|
||||||
className="h-[600px]"
|
className="h-[600px]"
|
||||||
|
onShapeUpdate={handleShapeUpdate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -449,9 +491,16 @@ export default function TripDashboard() {
|
||||||
<div className="w-2/5">
|
<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">
|
<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-slate-400 mb-4">No canvas linked yet.</p>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500 mb-4">
|
||||||
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.
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue