feat(onboarding): add module-specific connect/import/create CTAs

Declarative onboardingActions on RSpaceModule lets each rApp define its
own onboarding cards (import, upload, link, create). renderOnboarding()
renders them as a responsive card grid with upload handling. Adds ICS
import endpoint to rCal (POST /api/import-ics). 15 modules wired up.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-20 14:17:19 -07:00
parent df77c9c903
commit d4972453a3
18 changed files with 284 additions and 4 deletions

View File

@ -1254,4 +1254,7 @@ export const bnbModule: RSpaceModule = {
{ path: "stays", name: "Stays", icon: "\u{1F6CC}", description: "Stay requests and history" },
{ path: "endorsements", name: "Endorsements", icon: "\u{2B50}", description: "Trust endorsements from stays" },
],
onboardingActions: [
{ label: "Create a Listing", icon: "🏠", description: "List a space for community stays", type: 'create', href: '/{space}/rbnb' },
],
};

View File

@ -475,6 +475,10 @@ export const booksModule: RSpaceModule = {
],
},
],
onboardingActions: [
{ label: "Upload a PDF", icon: "📕", description: "Add a book or document to the library", type: 'upload' as const, upload: { accept: '.pdf', endpoint: '/{space}/rbooks/api/books' } },
{ label: "Browse the Library", icon: "📚", description: "Explore the shared book collection", type: 'create' as const, href: '/{space}/rbooks' },
],
async onSpaceCreate(ctx: SpaceLifecycleContext) {
// Books are global, not space-scoped (for now). No-op.
},

View File

@ -395,6 +395,126 @@ routes.post("/api/events", async (c) => {
return c.json(eventToRow(updated.events[eventId], updated.sources), 201);
});
// POST /api/import-ics — import events from an ICS file upload
routes.post("/api/import-ics", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const formData = await c.req.formData();
const file = formData.get("file");
if (!file || !(file instanceof File)) return c.json({ error: "No .ics file provided" }, 400);
const text = await file.text();
// Simple VEVENT parser — extract events from ICS text
const vevents: Array<{ title: string; description: string; start: number; end: number; allDay: boolean; location: string }> = [];
const eventBlocks = text.split("BEGIN:VEVENT");
for (let i = 1; i < eventBlocks.length; i++) {
const block = eventBlocks[i].split("END:VEVENT")[0];
const getField = (name: string): string => {
const match = block.match(new RegExp(`^${name}[;:](.*)$`, 'm'));
return match ? match[1].replace(/\\n/g, '\n').replace(/\\,/g, ',').trim() : '';
};
const summary = getField("SUMMARY");
if (!summary) continue;
const dtstart = getField("DTSTART");
const dtend = getField("DTEND");
const description = getField("DESCRIPTION");
const location = getField("LOCATION");
// Parse ICS date: YYYYMMDD or YYYYMMDDTHHmmssZ
const parseIcsDate = (s: string): number => {
if (!s) return 0;
// Strip VALUE=DATE: prefix etc
const clean = s.replace(/^[^:]*:/, '').trim();
if (clean.length === 8) {
// All-day: YYYYMMDD
return new Date(`${clean.slice(0,4)}-${clean.slice(4,6)}-${clean.slice(6,8)}`).getTime();
}
// Full datetime: YYYYMMDDTHHmmss or YYYYMMDDTHHmmssZ
const m = clean.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/);
if (m) return new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`).getTime();
return new Date(clean).getTime() || 0;
};
const startMs = parseIcsDate(dtstart);
if (!startMs) continue;
const endMs = parseIcsDate(dtend) || startMs + 3600000;
const allDay = dtstart.includes("VALUE=DATE") || (dtstart.replace(/^[^:]*:/, '').trim().length === 8);
vevents.push({ title: summary, description, start: startMs, end: endMs, allDay, location });
}
if (!vevents.length) return c.json({ error: "No events found in ICS file" }, 400);
const docId = calendarDocId(dataSpace);
ensureDoc(dataSpace);
const now = Date.now();
// Create an "Imported" source for these events
const sourceId = crypto.randomUUID();
_syncServer!.changeDoc<CalendarDoc>(docId, `import ${vevents.length} ICS events`, (d) => {
d.sources[sourceId] = {
id: sourceId,
name: file.name.replace(/\.ics$/i, '') || 'Imported Calendar',
sourceType: 'ICS_IMPORT',
url: null,
color: '#8b5cf6',
isActive: true,
isVisible: true,
syncIntervalMinutes: null,
lastSyncedAt: now,
ownerId: null,
createdAt: now,
};
for (const ev of vevents) {
const id = crypto.randomUUID();
d.events[id] = {
id,
title: ev.title,
description: ev.description,
startTime: ev.start,
endTime: ev.end,
allDay: ev.allDay,
timezone: 'UTC',
rrule: null,
status: null,
visibility: null,
sourceId,
sourceName: null,
sourceType: null,
sourceColor: null,
locationId: null,
locationName: ev.location || null,
coordinates: null,
locationGranularity: null,
locationLat: null,
locationLng: null,
isVirtual: false,
virtualUrl: null,
virtualPlatform: null,
rToolSource: 'ICS_IMPORT',
rToolEntityId: null,
attendees: [],
attendeeCount: 0,
metadata: null,
likelihood: null,
createdAt: now,
updatedAt: now,
};
}
});
return c.json({ imported: vevents.length, sourceId });
});
// GET /api/events/scheduled — query only scheduled knowledge items
routes.get("/api/events/scheduled", async (c) => {
const space = c.req.param("space") || "demo";
@ -771,4 +891,9 @@ export const calModule: RSpaceModule = {
{ path: "saved-views", name: "Saved Views", icon: "👁️", description: "Custom calendar views and filters" },
{ path: "events", name: "Events", icon: "📅", description: "Calendar events across all systems" },
],
onboardingActions: [
{ label: "Import Calendar (.ics)", icon: "📅", description: "Upload an ICS file from Google Calendar, Outlook, or Apple", type: 'upload', upload: { accept: '.ics,.ical', endpoint: '/{space}/rcal/api/import-ics' } },
{ label: "Subscribe to iCal URL", icon: "🔗", description: "Add a read-only calendar feed", type: 'link', href: '/{space}/rcal?action=add-source' },
{ label: "Create an Event", icon: "✏️", description: "Add your first calendar event", type: 'create', href: '/{space}/rcal' },
],
};

View File

@ -2584,4 +2584,7 @@ export const cartModule: RSpaceModule = {
{ path: "group-buys", name: "Group Buys", icon: "👥", description: "Volume discount group purchasing campaigns" },
{ path: "subscriptions", name: "Subscriptions", icon: "🔄", description: "Recurring subscription orders" },
],
onboardingActions: [
{ label: "Create a Store", icon: "🏪", description: "Set up a community storefront", type: 'create', href: '/{space}/rcart' },
],
};

View File

@ -703,4 +703,7 @@ export const filesModule: RSpaceModule = {
{ path: "files", name: "Files", icon: "📁", description: "Uploaded files and documents" },
{ path: "shares", name: "Shares", icon: "🔗", description: "Public share links" },
],
onboardingActions: [
{ label: "Upload Files", icon: "⬆️", description: "Add files to your space", type: 'create', href: '/{space}/rfiles' },
],
};

View File

@ -1086,4 +1086,7 @@ export const flowsModule: RSpaceModule = {
],
},
],
onboardingActions: [
{ label: "Create a Budget", icon: "🌊", description: "Set up a community funding flow", type: 'create', href: '/{space}/rflows' },
],
};

View File

@ -1750,4 +1750,8 @@ export const inboxModule: RSpaceModule = {
],
},
],
onboardingActions: [
{ label: "Connect your Email", icon: "📧", description: "Link an email account to receive messages", type: 'link', href: '/{space}/rinbox?action=connect-email' },
{ label: "Create a Mailbox", icon: "📬", description: "Set up a shared team mailbox", type: 'create', href: '/{space}/rinbox' },
],
};

View File

@ -1421,4 +1421,9 @@ export const notesModule: RSpaceModule = {
{ path: "transcripts", name: "Transcripts", icon: "🎙️", description: "Voice transcription records" },
{ path: "articles", name: "Articles", icon: "📰", description: "Published articles and posts" },
],
onboardingActions: [
{ label: "Import from Obsidian or Logseq", icon: "📂", description: "Import markdown files from your vault", type: 'link', href: '/{space}/rnotes?action=import' },
{ label: "Import from Notion", icon: "📄", description: "Bring in pages from a Notion export", type: 'link', href: '/{space}/rnotes?action=import&source=notion' },
{ label: "Create a Notebook", icon: "✏️", description: "Start writing from scratch", type: 'create', href: '/{space}/rnotes' },
],
};

View File

@ -2091,4 +2091,7 @@ export const scheduleModule: RSpaceModule = {
{ path: "workflows", name: "Automations", icon: "🔀", description: "Visual automation workflows with triggers, conditions, and actions" },
{ path: "log", name: "Execution Log", icon: "📋", description: "History of job executions" },
],
onboardingActions: [
{ label: "Create a Schedule", icon: "⏱", description: "Set up a recurring job or reminder", type: 'create', href: '/{space}/rschedule' },
],
};

View File

@ -1215,4 +1215,7 @@ export const socialsModule: RSpaceModule = {
],
},
],
onboardingActions: [
{ label: "Create a Thread", icon: "🧵", description: "Start a discussion thread", type: 'create', href: '/{space}/rsocials' },
],
};

View File

@ -899,6 +899,9 @@ export const splatModule: RSpaceModule = {
_syncServer = ctx.syncServer;
console.log("[Splat] Automerge document store ready");
},
onboardingActions: [
{ label: "3D Splatting", icon: "🌀", description: "Upload a PLY or Splat file", type: 'upload' as const, upload: { accept: '.ply,.splat', endpoint: '/{space}/rsplat/api/scenes' } },
],
async onSpaceCreate(ctx: SpaceLifecycleContext) {
// Eagerly create the Automerge doc for new spaces
ensureDoc(ctx.spaceSlug);

View File

@ -520,4 +520,7 @@ export const tasksModule: RSpaceModule = {
{ path: "projects", name: "Projects", icon: "📋", description: "Kanban project boards" },
{ path: "tasks", name: "Tasks", icon: "✅", description: "Task cards across all boards" },
],
onboardingActions: [
{ label: "Create a Taskboard", icon: "📋", description: "Start a new kanban project board", type: 'create', href: '/{space}/rtasks' },
],
};

View File

@ -717,4 +717,7 @@ export const tripsModule: RSpaceModule = {
],
},
],
onboardingActions: [
{ label: "Plan a Trip", icon: "✈️", description: "Create your first trip itinerary", type: 'create', href: '/{space}/rtrips' },
],
};

View File

@ -1335,4 +1335,7 @@ export const vnbModule: RSpaceModule = {
{ path: "rentals", name: "Rentals", icon: "\u{1F697}", description: "Rental requests and history" },
{ path: "endorsements", name: "Endorsements", icon: "\u{2B50}", description: "Trust endorsements from trips" },
],
onboardingActions: [
{ label: "Add a Vehicle", icon: "🚗", description: "Share a vehicle with your community", type: 'create', href: '/{space}/rvnb' },
],
};

View File

@ -600,4 +600,7 @@ export const voteModule: RSpaceModule = {
{ path: "proposals", name: "Proposals", icon: "📜", description: "Governance proposals for conviction voting" },
{ path: "ballots", name: "Ballots", icon: "🗳️", description: "Voting ballots and results" },
],
onboardingActions: [
{ label: "Create a Proposal", icon: "🗳️", description: "Start a governance vote", type: 'create', href: '/{space}/rvote' },
],
};

View File

@ -2026,6 +2026,7 @@ for (const mod of getAllModules()) {
spaceSlug: space,
modules: getModuleInfoList(),
landingHTML: mod.landingPage?.(),
onboardingActions: mod.onboardingActions,
}));
}
return next();

View File

@ -6,7 +6,7 @@
*/
import { resolve } from "node:path";
import type { ModuleInfo, SubPageInfo } from "../shared/module";
import type { ModuleInfo, SubPageInfo, OnboardingAction } from "../shared/module";
import { getDocumentData } from "./community-store";
// ── Browser compatibility polyfills (inline, runs before ES modules) ──
@ -2165,10 +2165,12 @@ export interface OnboardingOptions {
modules: ModuleInfo[];
/** Pre-rendered landing page HTML from the module (feature cards, etc.) */
landingHTML?: string;
/** Module-specific onboarding CTAs (import, connect, create) */
onboardingActions?: OnboardingAction[];
}
export function renderOnboarding(opts: OnboardingOptions): string {
const { moduleId, moduleName, moduleIcon, moduleDescription, spaceSlug, modules, landingHTML } = opts;
const { moduleId, moduleName, moduleIcon, moduleDescription, spaceSlug, modules, landingHTML, onboardingActions } = opts;
const demoUrl = `/${moduleId}/demo`;
const templateUrl = `/${moduleId}/template`;
@ -2176,6 +2178,61 @@ export function renderOnboarding(opts: OnboardingOptions): string {
? `<div class="onboarding__features">${landingHTML}</div>`
: '';
// Render action cards if module declares them
let actionsBlock = '';
if (onboardingActions?.length) {
const cards = onboardingActions.map((a) => {
const resolvedHref = a.href?.replace(/\{space\}/g, spaceSlug) ?? '#';
const resolvedEndpoint = a.upload?.endpoint?.replace(/\{space\}/g, spaceSlug) ?? '';
if (a.type === 'upload') {
const inputId = `upload-${Math.random().toString(36).slice(2, 8)}`;
return `<button class="onboarding__action" data-upload-endpoint="${escapeAttr(resolvedEndpoint)}" data-upload-input="${inputId}" type="button">
<span class="onboarding__action-icon">${a.icon}</span>
<span class="onboarding__action-label">${escapeHtml(a.label)}</span>
<span class="onboarding__action-desc">${escapeHtml(a.description)}</span>
<input type="file" id="${inputId}" accept="${escapeAttr(a.upload!.accept)}" hidden>
</button>`;
}
return `<a href="${escapeAttr(resolvedHref)}" class="onboarding__action">
<span class="onboarding__action-icon">${a.icon}</span>
<span class="onboarding__action-label">${escapeHtml(a.label)}</span>
<span class="onboarding__action-desc">${escapeHtml(a.description)}</span>
</a>`;
}).join('\n');
actionsBlock = `
<div class="onboarding__divider"><span>or connect your data</span></div>
<div class="onboarding__actions">${cards}</div>`;
}
const uploadScript = onboardingActions?.some(a => a.type === 'upload') ? `
<script>
(function(){
document.querySelectorAll('[data-upload-endpoint]').forEach(function(card){
var inputId = card.getAttribute('data-upload-input');
var endpoint = card.getAttribute('data-upload-endpoint');
var input = document.getElementById(inputId);
if(!input) return;
card.addEventListener('click', function(){ input.click(); });
input.addEventListener('change', function(){
if(!input.files||!input.files.length) return;
var fd = new FormData();
fd.append('file', input.files[0]);
var headers = {};
try {
var raw = localStorage.getItem('encryptid_session');
if(raw){ var s=JSON.parse(raw); if(s&&s.accessToken) headers['Authorization']='Bearer '+s.accessToken; }
}catch(e){}
card.style.opacity='0.5'; card.style.pointerEvents='none';
fetch(endpoint, { method:'POST', body:fd, headers:headers })
.then(function(r){ if(!r.ok) throw new Error('Upload failed: '+r.status); return r.json(); })
.then(function(){ location.reload(); })
.catch(function(err){ alert(err.message); card.style.opacity='1'; card.style.pointerEvents=''; });
});
});
})();
</script>` : '';
const body = `
<div class="onboarding">
<div class="onboarding__card">
@ -2189,11 +2246,13 @@ export function renderOnboarding(opts: OnboardingOptions): string {
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M8 3v10M3 8h10"/></svg>
Load Sample Data
</a>
<a href="${escapeAttr(demoUrl)}" class="onboarding__btn onboarding__btn--secondary">Try Demo</a>
<a href="${escapeAttr(demoUrl)}" class="onboarding__btn onboarding__btn--secondary">View with Demo Data</a>
</div>
${actionsBlock}
</div>
${featuresBlock}
</div>`;
</div>
${uploadScript}`;
return renderShell({
title: `${moduleName}${spaceSlug} | rSpace`,
@ -2268,10 +2327,38 @@ const ONBOARDING_CSS = `
border-top: 1px solid var(--rs-border-subtle);
padding-top: 2rem; margin-top: 1rem;
}
.onboarding__divider {
position: relative; display: flex; align-items: center; gap: 1rem;
margin: 2rem 0 1.25rem; color: var(--rs-text-muted); font-size: 0.8125rem;
}
.onboarding__divider::before, .onboarding__divider::after {
content: ''; flex: 1; height: 1px; background: var(--rs-border);
}
.onboarding__actions {
position: relative;
display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem; width: 100%;
}
.onboarding__action {
display: flex; flex-direction: column; align-items: center; gap: 0.25rem;
padding: 1.25rem 1rem; border-radius: 12px;
border: 1px solid var(--rs-border); background: var(--rs-bg-surface);
text-decoration: none; color: var(--rs-text-primary); cursor: pointer;
transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
font-family: inherit; font-size: inherit; text-align: center;
}
.onboarding__action:hover {
transform: translateY(-2px); border-color: var(--rs-border-strong);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.onboarding__action-icon { font-size: 1.75rem; margin-bottom: 0.25rem; }
.onboarding__action-label { font-size: 0.875rem; font-weight: 600; }
.onboarding__action-desc { font-size: 0.75rem; color: var(--rs-text-muted); line-height: 1.4; }
@media (max-width: 600px) {
.onboarding { padding: 2rem 1rem 1.5rem; }
.onboarding__title { font-size: 1.6rem; }
.onboarding__icon { font-size: 2.5rem; }
.onboarding__actions { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
}
`;

View File

@ -71,6 +71,22 @@ export interface ModuleSettingField {
options?: Array<{ value: string; label: string }>;
}
/** An onboarding call-to-action shown when a module has no data. */
export interface OnboardingAction {
/** Button label, e.g. "Import Calendar (.ics)" */
label: string;
/** Emoji icon */
icon: string;
/** Short description, e.g. "Upload an ICS file from Google Calendar, Outlook, or Apple" */
description: string;
/** Action type: link navigates, upload opens file picker, create navigates to module */
type: 'link' | 'upload' | 'create';
/** For 'link'/'create': URL with {space} placeholder */
href?: string;
/** For 'upload': file accept filter + POST endpoint */
upload?: { accept: string; endpoint: string };
}
/** A browsable content type that a module produces. */
export interface OutputPath {
/** URL segment: "notebooks" */
@ -148,6 +164,9 @@ export interface RSpaceModule {
/** Seed template/demo data for a space. Called by /template route. */
seedTemplate?: (space: string) => void;
/** Onboarding CTAs shown when the module has no data in a space */
onboardingActions?: OnboardingAction[];
/** If true, write operations (POST/PUT/PATCH/DELETE) skip the space role check.
* Use for modules whose API endpoints are publicly accessible (e.g. thread builder). */
publicWrite?: boolean;
@ -190,6 +209,7 @@ export interface ModuleInfo {
outputPaths?: OutputPath[];
subPageInfos?: Array<{ path: string; title: string }>;
settingsSchema?: ModuleSettingField[];
onboardingActions?: OnboardingAction[];
}
export function getModuleInfoList(): ModuleInfo[] {
@ -209,5 +229,6 @@ export function getModuleInfoList(): ModuleInfo[] {
...(m.outputPaths ? { outputPaths: m.outputPaths } : {}),
...(m.subPageInfos ? { subPageInfos: m.subPageInfos.map(s => ({ path: s.path, title: s.title })) } : {}),
...(m.settingsSchema ? { settingsSchema: m.settingsSchema } : {}),
...(m.onboardingActions ? { onboardingActions: m.onboardingActions } : {}),
}));
}