diff --git a/lib/canvas-tools.ts b/lib/canvas-tools.ts index 449776e..0678f45 100644 --- a/lib/canvas-tools.ts +++ b/lib/canvas-tools.ts @@ -141,6 +141,144 @@ const registry: CanvasToolDefinition[] = [ }), actionLabel: (args) => `Generating image: ${args.prompt.slice(0, 50)}${args.prompt.length > 50 ? "..." : ""}`, }, + // ── Trip Planning Tools ── + { + declaration: { + name: "create_destination", + description: "Create a destination card for a trip location. Use when the user mentions a city, place, or stop on their trip.", + parameters: { + type: "object", + properties: { + destName: { type: "string", description: "Name of the destination (city or place)" }, + country: { type: "string", description: "Country name" }, + lat: { type: "number", description: "Latitude coordinate" }, + lng: { type: "number", description: "Longitude coordinate" }, + arrivalDate: { type: "string", description: "Arrival date in YYYY-MM-DD format" }, + departureDate: { type: "string", description: "Departure date in YYYY-MM-DD format" }, + notes: { type: "string", description: "Additional notes about this destination" }, + }, + required: ["destName"], + }, + }, + tagName: "folk-destination", + buildProps: (args) => ({ + destName: args.destName, + ...(args.country ? { country: args.country } : {}), + ...(args.lat != null ? { lat: args.lat } : {}), + ...(args.lng != null ? { lng: args.lng } : {}), + ...(args.arrivalDate ? { arrivalDate: args.arrivalDate } : {}), + ...(args.departureDate ? { departureDate: args.departureDate } : {}), + ...(args.notes ? { notes: args.notes } : {}), + }), + actionLabel: (args) => `Created destination: ${args.destName}${args.country ? `, ${args.country}` : ""}`, + }, + { + declaration: { + name: "create_itinerary", + description: "Create an itinerary card with a list of activities/events organized by date. Use when planning a schedule or day-by-day plan.", + parameters: { + type: "object", + properties: { + tripTitle: { type: "string", description: "Title for the itinerary" }, + itemsJson: { type: "string", description: 'JSON array of items. Each: {"id":"","title":"...","date":"YYYY-MM-DD","startTime":"HH:MM","category":"ACTIVITY|TRANSPORT|MEAL|FREE_TIME|FLIGHT"}' }, + }, + required: ["tripTitle", "itemsJson"], + }, + }, + tagName: "folk-itinerary", + buildProps: (args) => { + let items: any[] = []; + try { items = JSON.parse(args.itemsJson); } catch { items = []; } + return { tripTitle: args.tripTitle, items }; + }, + actionLabel: (args) => `Created itinerary: ${args.tripTitle}`, + }, + { + declaration: { + name: "create_booking", + description: "Create a booking card for a flight, hotel, transport, activity, or restaurant reservation.", + parameters: { + type: "object", + properties: { + bookingType: { + type: "string", + description: "Type of booking", + enum: ["FLIGHT", "HOTEL", "CAR_RENTAL", "TRAIN", "BUS", "FERRY", "ACTIVITY", "RESTAURANT", "OTHER"], + }, + provider: { type: "string", description: "Provider/company name (e.g. airline, hotel name)" }, + cost: { type: "number", description: "Cost amount" }, + currency: { type: "string", description: "ISO currency code (e.g. USD, EUR)" }, + startDate: { type: "string", description: "Start/check-in date in YYYY-MM-DD format" }, + endDate: { type: "string", description: "End/check-out date in YYYY-MM-DD format" }, + bookingStatus: { type: "string", description: "Booking status", enum: ["PENDING", "CONFIRMED", "CANCELLED"] }, + details: { type: "string", description: "Additional booking details or notes" }, + }, + required: ["bookingType", "provider"], + }, + }, + tagName: "folk-booking", + buildProps: (args) => ({ + bookingType: args.bookingType, + provider: args.provider, + ...(args.cost != null ? { cost: args.cost } : {}), + ...(args.currency ? { currency: args.currency } : {}), + ...(args.startDate ? { startDate: args.startDate } : {}), + ...(args.endDate ? { endDate: args.endDate } : {}), + ...(args.bookingStatus ? { bookingStatus: args.bookingStatus } : {}), + ...(args.details ? { details: args.details } : {}), + }), + actionLabel: (args) => `Created booking: ${args.bookingType} — ${args.provider}`, + }, + { + declaration: { + name: "create_budget", + description: "Create a budget tracker card with total budget and expense line items. Use when the user wants to track trip costs.", + parameters: { + type: "object", + properties: { + budgetTotal: { type: "number", description: "Total budget amount" }, + currency: { type: "string", description: "ISO currency code (e.g. USD, EUR)" }, + expensesJson: { type: "string", description: 'JSON array of expenses. Each: {"id":"","category":"TRANSPORT|ACCOMMODATION|FOOD|ACTIVITY|SHOPPING|OTHER","description":"...","amount":123,"date":"YYYY-MM-DD"}' }, + }, + required: ["budgetTotal"], + }, + }, + tagName: "folk-budget", + buildProps: (args) => { + let expenses: any[] = []; + try { expenses = JSON.parse(args.expensesJson); } catch { expenses = []; } + return { + budgetTotal: args.budgetTotal, + ...(args.currency ? { currency: args.currency } : {}), + expenses, + }; + }, + actionLabel: (args) => `Created budget: ${args.currency || "USD"} ${args.budgetTotal}`, + }, + { + declaration: { + name: "create_packing_list", + description: "Create a packing list card with checkable items organized by category. Use when the user needs help with what to pack.", + parameters: { + type: "object", + properties: { + itemsJson: { type: "string", description: 'JSON array of packing items. Each: {"id":"","name":"...","category":"CLOTHING|FOOTWEAR|ELECTRONICS|GEAR|PERSONAL|DOCUMENTS|SAFETY|SUPPLIES","quantity":1,"packed":false}' }, + }, + required: ["itemsJson"], + }, + }, + tagName: "folk-packing-list", + buildProps: (args) => { + let items: any[] = []; + try { items = JSON.parse(args.itemsJson); } catch { items = []; } + return { items }; + }, + actionLabel: (args) => { + let count = 0; + try { count = JSON.parse(args.itemsJson).length; } catch {} + return `Created packing list (${count} items)`; + }, + }, ]; export const CANVAS_TOOLS: CanvasToolDefinition[] = [...registry]; diff --git a/lib/folk-prompt.ts b/lib/folk-prompt.ts index bdb5d31..8f636f5 100644 --- a/lib/folk-prompt.ts +++ b/lib/folk-prompt.ts @@ -386,6 +386,7 @@ export class FolkPrompt extends FolkShape { #model = "gemini-flash"; #pendingImages: string[] = []; #toolsEnabled = false; + #systemPrompt = ""; #messagesEl: HTMLElement | null = null; #promptInput: HTMLTextAreaElement | null = null; @@ -399,6 +400,20 @@ export class FolkPrompt extends FolkShape { return this.#messages; } + get toolsEnabled() { return this.#toolsEnabled; } + set toolsEnabled(v: boolean) { + this.#toolsEnabled = v; + this.#toolsBtn?.classList.toggle("active", v); + if (this.#promptInput) { + this.#promptInput.placeholder = v + ? "Ask me to create maps, notes, images, or embeds on the canvas..." + : "Type your message..."; + } + } + + get systemPrompt() { return this.#systemPrompt; } + set systemPrompt(v: string) { this.#systemPrompt = v; } + override createRenderRoot() { const root = super.createRenderRoot(); @@ -471,15 +486,15 @@ export class FolkPrompt extends FolkShape { // Tools toggle this.#toolsBtn?.addEventListener("click", (e) => { e.stopPropagation(); - this.#toolsEnabled = !this.#toolsEnabled; - this.#toolsBtn!.classList.toggle("active", this.#toolsEnabled); - if (this.#promptInput) { - this.#promptInput.placeholder = this.#toolsEnabled - ? "Ask me to create maps, notes, images, or embeds on the canvas..." - : "Type your message..."; - } + this.toolsEnabled = !this.#toolsEnabled; }); + // Sync initial state after DOM ready + this.#toolsBtn?.classList.toggle("active", this.#toolsEnabled); + if (this.#toolsEnabled && this.#promptInput) { + this.#promptInput.placeholder = "Ask me to create maps, notes, images, or embeds on the canvas..."; + } + // Attach button attachBtn?.addEventListener("click", (e) => { e.stopPropagation(); @@ -635,7 +650,7 @@ export class FolkPrompt extends FolkShape { ...(m.images?.length ? { images: m.images } : {}), })), model: this.#model, - ...(useTools ? { useTools: true } : {}), + ...(useTools ? { useTools: true, systemPrompt: this.#systemPrompt || undefined } : {}), }), }); @@ -780,6 +795,8 @@ export class FolkPrompt extends FolkShape { static override fromData(data: Record): FolkPrompt { const shape = FolkShape.fromData(data) as FolkPrompt; + if (data.toolsEnabled) shape.toolsEnabled = true; + if (data.systemPrompt) shape.systemPrompt = data.systemPrompt; return shape; } @@ -788,6 +805,8 @@ export class FolkPrompt extends FolkShape { ...super.toJSON(), type: "folk-prompt", model: this.#model, + toolsEnabled: this.#toolsEnabled || undefined, + systemPrompt: this.#systemPrompt || undefined, messages: this.messages.filter((m) => m.role !== "tool-action").map((msg) => ({ role: msg.role, content: msg.content, @@ -799,5 +818,7 @@ export class FolkPrompt extends FolkShape { override applyData(data: Record): void { super.applyData(data); + if (data.toolsEnabled !== undefined) this.toolsEnabled = !!data.toolsEnabled; + if (data.systemPrompt !== undefined) this.systemPrompt = data.systemPrompt; } } diff --git a/modules/rtrips/components/folk-trips-planner.ts b/modules/rtrips/components/folk-trips-planner.ts index 6d92d1a..644c216 100644 --- a/modules/rtrips/components/folk-trips-planner.ts +++ b/modules/rtrips/components/folk-trips-planner.ts @@ -421,6 +421,8 @@ class FolkTripsPlanner extends HTMLElement { .rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #14b8a6; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; } .rapp-nav__btn:hover { background: #0d9488; } + .rapp-nav__btn--ai { background: linear-gradient(135deg, #0ea5e9, #6366f1); } + .rapp-nav__btn--ai:hover { opacity: 0.9; background: linear-gradient(135deg, #0ea5e9, #6366f1); } .trip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; } .trip-card { @@ -504,6 +506,7 @@ class FolkTripsPlanner extends HTMLElement {
My Trips +
${this.trips.length > 0 ? `
@@ -546,6 +549,7 @@ class FolkTripsPlanner extends HTMLElement {
${this._history.canGoBack ? `` : ""} ${this.esc(t.title)} + ${st.label}
@@ -678,6 +682,9 @@ class FolkTripsPlanner extends HTMLElement { private attachListeners() { this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); this.shadow.getElementById("create-trip")?.addEventListener("click", () => this.createTrip()); + this.shadow.getElementById("btn-plan-ai")?.addEventListener("click", () => { + window.location.href = `/${this.space}/rspace#trip-planner`; + }); this.shadow.querySelectorAll("[data-trip]").forEach(el => { el.addEventListener("click", () => { diff --git a/server/index.ts b/server/index.ts index 31b2ff3..f39250c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -2225,7 +2225,9 @@ for (const mod of getAllModules()) { || pathname.endsWith("/api/transak/config") || pathname.endsWith("/api/transak/webhook") || pathname.endsWith("/api/coinbase/webhook") - || pathname.endsWith("/api/ramp/webhook"); + || pathname.endsWith("/api/ramp/webhook") + || pathname.includes("/rcart/api/payments") + || pathname.includes("/rcart/pay/"); if (!isHtmlRequest && !isPublicEndpoint && (vis === "private" || vis === "permissioned")) { const token = extractToken(c.req.raw.headers); diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index 0d8daa5..a940f28 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -540,7 +540,7 @@ export async function getRecoveryRequest(requestId: string): Promise { const rows = await sql` SELECT * FROM recovery_requests - WHERE user_id = ${userId} AND status = 'pending' AND expires_at > NOW() + WHERE user_id = ${userId} AND status IN ('pending', 'approved') AND expires_at > NOW() ORDER BY initiated_at DESC LIMIT 1 `; if (rows.length === 0) return null; diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 7719fc0..a608363 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -2257,6 +2257,220 @@ app.get('/recover', (c) => { `); }); +// ============================================================================ +// SOCIAL RECOVERY COMPLETION PAGE +// ============================================================================ + +/** + * GET /recover/social — page where user finalizes social recovery + * After guardians approve, user opens this to register a new passkey + */ +app.get('/recover/social', (c) => { + return c.html(` + + + + + + Social Recovery — rStack Identity + + + +
+
👥
+

Social Recovery

+

Your guardians are helping you recover your account

+ +
Checking recovery status...
+ + + + + + + +
+ + + + + `); +}); + // ============================================================================ // GUARDIAN MANAGEMENT ROUTES // ============================================================================ @@ -2757,7 +2971,7 @@ app.post('/api/recovery/social/initiate', async (c) => { // Check for existing active request const existing = await getActiveRecoveryRequest(user.id); if (existing) { - return c.json({ success: true, message: 'A recovery request is already active. Recovery emails have been re-sent.' }); + return c.json({ success: true, requestId: existing.id, message: 'A recovery request is already active. Recovery emails have been re-sent.' }); } // Create recovery request (7 day expiry, 2-of-3 threshold) @@ -2773,6 +2987,7 @@ app.post('/api/recovery/social/initiate', async (c) => { title: 'Account recovery initiated', body: `A recovery request was initiated for your account. ${accepted.length} guardians have been contacted.`, metadata: { recoveryRequestId: requestId, threshold: 2, totalGuardians: accepted.length }, + actionUrl: `/recover/social?id=${requestId}`, }).catch(() => {}); // Create approval tokens and notify guardians @@ -2854,7 +3069,7 @@ app.post('/api/recovery/social/initiate', async (c) => { } } - return c.json({ success: true, message: 'If the account exists and has guardians, recovery emails have been sent.' }); + return c.json({ success: true, requestId, message: 'If the account exists and has guardians, recovery emails have been sent.' }); }); /** @@ -2973,6 +3188,7 @@ app.post('/api/recovery/social/approve', async (c) => { title: 'Account recovery approved', body: `${request.approvalCount}/${request.threshold} guardians approved. You can now recover your account.`, metadata: { recoveryRequestId: request.id }, + actionUrl: `https://auth.rspace.online/recover/social?id=${request.id}`, }).catch(() => {}); } @@ -7394,7 +7610,10 @@ app.get('/', (c) => { body: JSON.stringify(body), }); const data = await res.json(); - msgEl.textContent = data.message || 'Recovery request sent. Check with your guardians.'; + msgEl.innerHTML = data.message || 'Recovery request sent. Check with your guardians.'; + if (data.requestId) { + msgEl.innerHTML += '

Track your recovery progress →'; + } msgEl.style.color = '#86efac'; msgEl.style.display = 'block'; // Also try email recovery diff --git a/website/canvas.html b/website/canvas.html index a703ec8..009087b 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -3642,6 +3642,33 @@ if (hash.startsWith("#pin-")) { const pinId = hash.slice(5); setTimeout(() => pinManager.openPinById(pinId), 500); + } else if (hash === "#trip-planner") { + history.replaceState(null, "", location.pathname + location.search); + setTimeout(() => { + const TRIP_SYSTEM_PROMPT = `You are a travel planning AI in rSpace. Help plan trips by creating visual shapes on the canvas. + +When the user describes a trip, proactively create: +- Destination cards for each city/place (with coordinates and dates) +- A map showing the destinations +- Flight/accommodation search embeds (Google Flights, Airbnb, Booking.com) +- An itinerary with activities by date +- A budget tracker with estimated costs +- A packing list tailored to the destination + +Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying questions if needed before creating shapes.`; + + const prompt = newShape("folk-prompt", { + toolsEnabled: true, + systemPrompt: TRIP_SYSTEM_PROMPT, + }); + if (prompt) { + setTimeout(() => { + const root = prompt.shadowRoot || prompt; + const ta = root.querySelector(".prompt-input"); + if (ta) ta.focus(); + }, 200); + } + }, 300); } });