From 4a54e6af16c1710430671ef96223207b27f68c71 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 14:09:53 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20enforce=20subdomain=20routing=20?= =?UTF-8?q?=E2=80=94=20spaces=20are=20subdomains,=20never=20path=20segment?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit and fix ~60 violations across 47 files where URLs were constructed as /{space}/moduleId instead of /moduleId (subdomain provides the space). - Add getModuleApiBase() helper to shared/url-helpers.ts - Fix client components: use getModuleApiBase() for fetch URLs, rspaceNavUrl() for navigation - Fix server routes: use c.get("isSubdomain") for redirects and JSON response URLs - Fix OAuth callbacks (notion, google, clickup): subdomain-aware redirects - Fix email notification URLs (rvote): use {space}.rspace.online format - Fix webhook registration (rtasks/clickup): subdomain-aware endpoint URL - Replace broken #getSpaceSlug() methods in folk-feed, folk-splat - Replace NODE_ENV checks with proper isSubdomain checks (rdata, rnetwork) Co-Authored-By: Claude Opus 4.6 --- lib/folk-comment-pin.ts | 11 ++++---- lib/folk-commitment-pool.ts | 3 +- lib/folk-feed.ts | 19 ++++--------- lib/folk-map.ts | 5 ++-- lib/folk-multisig-email.ts | 4 +-- lib/folk-social-campaign.ts | 3 +- lib/folk-social-thread.ts | 3 +- lib/folk-spider-3d.ts | 3 +- lib/folk-splat.ts | 15 +++------- lib/folk-task-request.ts | 3 +- lib/folk-transaction-builder.ts | 4 +-- .../components/folk-crowdsurf-dashboard.ts | 5 ++-- modules/rbnb/components/folk-bnb-view.ts | 11 ++++---- modules/rbnb/components/folk-listing.ts | 4 ++- modules/rbnb/components/folk-stay-request.ts | 4 ++- modules/rbooks/mod.ts | 2 +- modules/rcart/components/folk-cart-shop.ts | 8 +++--- modules/rcart/components/folk-payment-page.ts | 6 ++-- .../rcart/components/folk-payment-request.ts | 8 +++--- .../components/folk-choices-dashboard.ts | 7 +++-- modules/rdata/mod.ts | 10 +++---- .../rinbox/components/folk-inbox-client.ts | 3 +- modules/rmaps/components/folk-map-viewer.ts | 3 +- modules/rmeets/mod.ts | 2 +- modules/rnetwork/mod.ts | 12 ++++---- modules/rnotes/components/comment-panel.ts | 5 ++-- .../rnotes/components/import-export-dialog.ts | 28 ++++++++++--------- modules/rpubs/components/folk-pubs-editor.ts | 5 ++-- .../components/folk-pubs-publish-panel.ts | 12 ++++---- .../components/folk-campaign-manager.ts | 2 +- .../components/folk-newsletter-manager.ts | 3 +- .../components/folk-thread-builder.ts | 4 +-- modules/rsocials/mod.ts | 2 +- modules/rsplat/mod.ts | 17 +++++------ modules/rtasks/mod.ts | 2 +- .../rtrips/components/folk-trips-planner.ts | 4 +-- .../rvnb/components/folk-rental-request.ts | 4 ++- modules/rvnb/components/folk-vehicle-card.ts | 4 ++- modules/rvnb/components/folk-vnb-view.ts | 11 ++++---- modules/rvote/mod.ts | 2 +- server/mi-routes.ts | 2 +- server/oauth/clickup.ts | 2 +- server/oauth/google.ts | 2 +- server/oauth/notion.ts | 2 +- shared/components/rstack-space-switcher.ts | 4 +-- shared/components/rstack-user-dashboard.ts | 3 +- shared/url-helpers.ts | 19 +++++++++++++ 47 files changed, 164 insertions(+), 133 deletions(-) diff --git a/lib/folk-comment-pin.ts b/lib/folk-comment-pin.ts index aa88390..10be6c6 100644 --- a/lib/folk-comment-pin.ts +++ b/lib/folk-comment-pin.ts @@ -4,6 +4,7 @@ */ import * as Automerge from "@automerge/automerge"; +import { getModuleApiBase } from "../shared/url-helpers"; import type { CommunitySync } from "./community-sync"; import type { CommentPinAnchor, @@ -535,7 +536,7 @@ export class CommentPinManager { if (pin.linkedNoteId) { html += `
- 📝 ${this.#escapeHtml(pin.linkedNoteTitle || "Linked note")} @@ -669,7 +670,7 @@ export class CommentPinManager { if (this.#members) return this.#members; try { const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}"); - const res = await fetch(`/${this.#spaceSlug}/api/space-members`, { + const res = await fetch(`${getModuleApiBase("rspace")}/api/space-members`, { headers: sess?.accessToken ? { Authorization: `Bearer ${sess.accessToken}` } : {}, }); if (!res.ok) return []; @@ -685,7 +686,7 @@ export class CommentPinManager { if (this.#notes) return this.#notes; try { const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}"); - const res = await fetch(`/${this.#spaceSlug}/rnotes/api/notes?limit=50`, { + const res = await fetch(`${getModuleApiBase("rnotes")}/api/notes?limit=50`, { headers: sess?.accessToken ? { Authorization: `Bearer ${sess.accessToken}` } : {}, }); if (!res.ok) return []; @@ -816,7 +817,7 @@ export class CommentPinManager { try { const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}"); - await fetch(`/${this.#spaceSlug}/api/comment-pins/notify-mention`, { + await fetch(`${getModuleApiBase("rspace")}/api/comment-pins/notify-mention`, { method: "POST", headers: { "Content-Type": "application/json", @@ -879,7 +880,7 @@ export class CommentPinManager { }; if (email) body.notifyEmail = email; - const res = await fetch(`/${this.#spaceSlug}/rschedule/api/reminders`, { + const res = await fetch(`${getModuleApiBase("rschedule")}/api/reminders`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), diff --git a/lib/folk-commitment-pool.ts b/lib/folk-commitment-pool.ts index 1796a9c..b4499cb 100644 --- a/lib/folk-commitment-pool.ts +++ b/lib/folk-commitment-pool.ts @@ -7,6 +7,7 @@ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; +import { getModuleApiBase } from "../shared/url-helpers"; // ── Skill constants (mirrored from rtime/schemas to avoid server import) ── @@ -299,7 +300,7 @@ export class FolkCommitmentPool extends FolkShape { async #fetchCommitments() { try { - const resp = await fetch(`/${this.#spaceSlug}/rtime/api/commitments`); + const resp = await fetch(`${getModuleApiBase("rtime")}/api/commitments`); if (!resp.ok) return; const data = await resp.json(); const commitments: PoolCommitment[] = data.commitments || []; diff --git a/lib/folk-feed.ts b/lib/folk-feed.ts index 36dca8c..d7bbd83 100644 --- a/lib/folk-feed.ts +++ b/lib/folk-feed.ts @@ -20,6 +20,7 @@ import { FolkShape } from "./folk-shape"; import { FLOW_COLORS, FLOW_LABELS } from "./layer-types"; import type { FlowKind } from "./layer-types"; +import { getModuleApiBase, rspaceNavUrl, getCurrentSpace } from "../shared/url-helpers"; export class FolkFeed extends FolkShape { static tagName = "folk-feed"; @@ -121,8 +122,7 @@ export class FolkFeed extends FolkShape { this.#inner.querySelector(".feed-navigate")?.addEventListener("click", (e) => { e.stopPropagation(); if (this.sourceModule) { - const space = this.#getSpaceSlug(); - window.location.href = `/${space}/${this.sourceModule}`; + window.location.href = rspaceNavUrl(getCurrentSpace(), this.sourceModule); } }); @@ -150,9 +150,8 @@ export class FolkFeed extends FolkShape { try { // Construct feed URL based on feed ID - const space = this.#getSpaceSlug(); const feedEndpoint = this.#getFeedEndpoint(); - const url = `/${space}/${this.sourceModule}/api/${feedEndpoint}`; + const url = `${getModuleApiBase(this.sourceModule)}/api/${feedEndpoint}`; const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); @@ -257,12 +256,6 @@ export class FolkFeed extends FolkShape { return moduleEndpoints[this.feedId] || moduleEndpoints.default || this.feedId; } - #getSpaceSlug(): string { - // Try to get from URL - const parts = window.location.pathname.split("/").filter(Boolean); - return parts[0] || "demo"; - } - // ── Render feed items ── #renderItems() { @@ -327,7 +320,6 @@ export class FolkFeed extends FolkShape { /** Navigate to the source item in its module */ #navigateToItem(item: any) { - const space = this.#getSpaceSlug(); const mod = this.sourceModule; // Build a deep link to the item in its source module @@ -338,7 +330,7 @@ export class FolkFeed extends FolkShape { sourceModule: mod, itemId: item.id, item, - url: `/${space}/${mod}`, + url: getModuleApiBase(mod), } })); } @@ -427,9 +419,8 @@ export class FolkFeed extends FolkShape { // Write back to source module API try { - const space = this.#getSpaceSlug(); const endpoint = this.#getWriteBackEndpoint(item); - const url = `/${space}/${this.sourceModule}/api/${endpoint}`; + const url = `${getModuleApiBase(this.sourceModule)}/api/${endpoint}`; const method = this.#getWriteBackMethod(); const token = this.#getAuthToken(); diff --git a/lib/folk-map.ts b/lib/folk-map.ts index 2bc5011..1f49999 100644 --- a/lib/folk-map.ts +++ b/lib/folk-map.ts @@ -1,5 +1,6 @@ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; +import { getModuleApiBase } from "../shared/url-helpers"; import type { RoomState, ParticipantState, @@ -942,9 +943,7 @@ export class FolkMap extends FolkShape { } #getApiBase(): string { - const match = window.location.pathname.match(/^\/([^/]+)/); - const spaceSlug = match ? match[1] : "default"; - return `/${spaceSlug}/rmaps`; + return getModuleApiBase("rmaps"); } async #initRoomSync() { diff --git a/lib/folk-multisig-email.ts b/lib/folk-multisig-email.ts index f5f84bb..01ce205 100644 --- a/lib/folk-multisig-email.ts +++ b/lib/folk-multisig-email.ts @@ -1,5 +1,6 @@ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; +import { getModuleApiBase } from "../shared/url-helpers"; const styles = css` :host { @@ -295,8 +296,7 @@ export class FolkMultisigEmail extends FolkShape { } private getApiBase(): string { - const space = (this as any).spaceSlug || (window as any).__communitySync?.communitySlug || "demo"; - return `/${space}/rinbox`; + return getModuleApiBase("rinbox"); } private getAuthHeaders(): Record { diff --git a/lib/folk-social-campaign.ts b/lib/folk-social-campaign.ts index da61643..c7ff7e5 100644 --- a/lib/folk-social-campaign.ts +++ b/lib/folk-social-campaign.ts @@ -1,5 +1,6 @@ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; +import { getModuleApiBase } from "../shared/url-helpers"; const PLATFORM_ICONS: Record = { x: "𝕏", linkedin: "in", instagram: "📷", youtube: "▶", @@ -331,7 +332,7 @@ export class FolkSocialCampaign extends FolkShape { e.stopPropagation(); this.dispatchEvent(new CustomEvent("navigate-to-module", { bubbles: true, composed: true, - detail: { path: `/${this.#spaceSlug}/rsocials/campaigns?id=${this.#campaignId}` }, + detail: { path: `${getModuleApiBase("rsocials")}/campaigns?id=${this.#campaignId}` }, })); }); diff --git a/lib/folk-social-thread.ts b/lib/folk-social-thread.ts index 3289310..14d302a 100644 --- a/lib/folk-social-thread.ts +++ b/lib/folk-social-thread.ts @@ -1,6 +1,7 @@ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; import type { SocialPlatform } from "./folk-social-post"; +import { getModuleApiBase } from "../shared/url-helpers"; const PLATFORM_COLORS: Record = { x: "#000000", @@ -331,7 +332,7 @@ export class FolkSocialThread extends FolkShape { this.dispatchEvent(new CustomEvent("navigate-to-module", { bubbles: true, composed: true, - detail: { path: `/${this.#spaceSlug}/rsocials/thread-editor?id=${this.#threadId}` }, + detail: { path: `${getModuleApiBase("rsocials")}/thread-editor?id=${this.#threadId}` }, })); }); diff --git a/lib/folk-spider-3d.ts b/lib/folk-spider-3d.ts index 36343d1..548a7db 100644 --- a/lib/folk-spider-3d.ts +++ b/lib/folk-spider-3d.ts @@ -7,6 +7,7 @@ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; +import { getModuleApiBase } from "../shared/url-helpers"; import { computeSpider3D, computeRadarPolygon, @@ -488,7 +489,7 @@ export class FolkSpider3D extends FolkShape { try { const token = localStorage.getItem("rspace-token") || ""; - const res = await fetch(`/${this.#space}/connections`, { + const res = await fetch(`${getModuleApiBase("rspace")}/connections`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); diff --git a/lib/folk-splat.ts b/lib/folk-splat.ts index 2972bfe..6f1f4a6 100644 --- a/lib/folk-splat.ts +++ b/lib/folk-splat.ts @@ -1,5 +1,6 @@ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; +import { getModuleApiBase } from "../shared/url-helpers"; const styles = css` :host { @@ -327,8 +328,7 @@ export class FolkSplat extends FolkShape { async #loadGallery(container: HTMLElement, viewerArea: HTMLElement) { container.innerHTML = '
Loading gallery...
'; try { - const spaceSlug = this.#getSpaceSlug(); - const res = await fetch(`/${spaceSlug}/rsplat/api/splats?limit=20`); + const res = await fetch(`${getModuleApiBase("rsplat")}/api/splats?limit=20`); if (!res.ok) throw new Error("Failed to load splats"); const data = await res.json(); this.#gallerySplats = data.splats || []; @@ -351,8 +351,7 @@ export class FolkSplat extends FolkShape { const slug = (item as HTMLElement).dataset.slug; const splat = this.#gallerySplats.find((s) => s.slug === slug); if (splat) { - const spaceSlug = this.#getSpaceSlug(); - const url = `/${spaceSlug}/rsplat/api/splats/${splat.slug}/${splat.slug}.${splat.file_format}`; + const url = `${getModuleApiBase("rsplat")}/api/splats/${splat.slug}/${splat.slug}.${splat.file_format}`; if (this.#urlInput) this.#urlInput.value = url; container.style.display = "none"; this.#loadSplat(url, viewerArea); @@ -410,19 +409,13 @@ export class FolkSplat extends FolkShape { } catch (err) { // Fallback: show as iframe pointing to splat viewer page console.warn("[folk-splat] Three.js not available, using iframe fallback"); - const spaceSlug = this.#getSpaceSlug(); const slug = url.split("/").filter(Boolean).pop()?.split(".")[0] || ""; - viewerArea.innerHTML = ``; + viewerArea.innerHTML = ``; } finally { this.#isLoading = false; } } - #getSpaceSlug(): string { - const pathParts = window.location.pathname.split("/").filter(Boolean); - return pathParts[0] || "demo"; - } - #escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; diff --git a/lib/folk-task-request.ts b/lib/folk-task-request.ts index a50e347..43e12db 100644 --- a/lib/folk-task-request.ts +++ b/lib/folk-task-request.ts @@ -7,6 +7,7 @@ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; +import { getModuleApiBase } from "../shared/url-helpers"; // Skill constants (mirrored from rtime/schemas) const SKILL_COLORS: Record = { @@ -404,7 +405,7 @@ export class FolkTaskRequest extends FolkShape { // Get auth token from cookie or localStorage const token = document.cookie.split(";").map(c => c.trim()).find(c => c.startsWith("auth_token="))?.split("=")[1] || localStorage.getItem("auth_token") || ""; - await fetch(`/${this.#spaceSlug}/rtime/api/connections`, { + await fetch(`${getModuleApiBase("rtime")}/api/connections`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/lib/folk-transaction-builder.ts b/lib/folk-transaction-builder.ts index 59983d3..c2d4186 100644 --- a/lib/folk-transaction-builder.ts +++ b/lib/folk-transaction-builder.ts @@ -6,6 +6,7 @@ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; +import { getModuleApiBase } from "../shared/url-helpers"; const styles = css` :host { @@ -283,8 +284,7 @@ export class FolkTransactionBuilder extends FolkShape { } #apiBase(): string { - const space = this.#getSpaceSlug(); - return `/${space}/rwallet/api/safe/${this.#chainId}/${this.#safeAddress}`; + return `${getModuleApiBase("rwallet")}/api/safe/${this.#chainId}/${this.#safeAddress}`; } async #apiFetch(path: string, opts?: RequestInit): Promise { diff --git a/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts b/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts index bd16928..b3b5b6b 100644 --- a/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts +++ b/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts @@ -11,6 +11,7 @@ import { startPresenceHeartbeat } from '../../../shared/collab-presence'; import { CrowdSurfLocalFirstClient } from '../local-first-client'; import type { CrowdSurfDoc, CrowdSurfPrompt, Contribution } from '../schemas'; import { getDecayProgress, getTimeRemaining, getRightSwipeCount, isReadyToTrigger, getUrgency, parseContributions } from '../schemas'; +import { getModuleApiBase } from "../../../shared/url-helpers"; // ── Auth helpers ── function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null { @@ -624,7 +625,7 @@ class FolkCrowdSurfDashboard extends HTMLElement { this.rankLoading = true; this.render(); this.bindEvents(); - fetch(`/${this.space}/crowdsurf/api/crowdsurf/pair?space=${this.space}`) + fetch(`${getModuleApiBase("crowdsurf")}/api/crowdsurf/pair?space=${this.space}`) .then(r => r.json()) .then(data => { if (data.a && data.b) { @@ -697,7 +698,7 @@ class FolkCrowdSurfDashboard extends HTMLElement { } // Live mode: POST to API - fetch(`/${this.space}/crowdsurf/api/crowdsurf/compare?space=${this.space}`, { + fetch(`${getModuleApiBase("crowdsurf")}/api/crowdsurf/compare?space=${this.space}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ winnerId, loserId }), diff --git a/modules/rbnb/components/folk-bnb-view.ts b/modules/rbnb/components/folk-bnb-view.ts index c74e427..87d2709 100644 --- a/modules/rbnb/components/folk-bnb-view.ts +++ b/modules/rbnb/components/folk-bnb-view.ts @@ -10,6 +10,7 @@ import type { TourStep } from '../../../shared/tour-engine'; import './folk-listing'; import './folk-stay-request'; import { startPresenceHeartbeat } from '../../../shared/collab-presence'; +import { getModuleApiBase } from "../../../shared/url-helpers"; const BNB_TOUR_STEPS: TourStep[] = [ { target: '.bnb-search', title: 'Search', message: 'Filter listings by location, type, or economy model.' }, @@ -89,9 +90,9 @@ class FolkBnbView extends HTMLElement { async #loadData() { try { const [listingsRes, staysRes, statsRes] = await Promise.all([ - fetch(`/${this.#space}/rbnb/api/listings`), - fetch(`/${this.#space}/rbnb/api/stays`), - fetch(`/${this.#space}/rbnb/api/stats`), + fetch(`${getModuleApiBase("rbnb")}/api/listings`), + fetch(`${getModuleApiBase("rbnb")}/api/stays`), + fetch(`${getModuleApiBase("rbnb")}/api/stats`), ]); if (listingsRes.ok) { @@ -414,7 +415,7 @@ class FolkBnbView extends HTMLElement { async #handleStayAction(stayId: string, action: string) { try { - const res = await fetch(`/${this.#space}/rbnb/api/stays/${stayId}/${action}`, { + const res = await fetch(`${getModuleApiBase("rbnb")}/api/stays/${stayId}/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); @@ -430,7 +431,7 @@ class FolkBnbView extends HTMLElement { async #handleStayMessage(stayId: string, body: string) { try { - const res = await fetch(`/${this.#space}/rbnb/api/stays/${stayId}/messages`, { + const res = await fetch(`${getModuleApiBase("rbnb")}/api/stays/${stayId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body, sender_name: 'You' }), diff --git a/modules/rbnb/components/folk-listing.ts b/modules/rbnb/components/folk-listing.ts index 374119a..8816574 100644 --- a/modules/rbnb/components/folk-listing.ts +++ b/modules/rbnb/components/folk-listing.ts @@ -5,6 +5,8 @@ * capacity, location, economy badge, availability dot, endorsement count. */ +import { getModuleApiBase } from "../../../shared/url-helpers"; + const ECONOMY_COLORS: Record = { gift: { bg: 'rgba(52,211,153,0.12)', fg: '#34d399', label: 'Gift', icon: '\u{1F49A}' }, exchange: { bg: 'rgba(96,165,250,0.12)', fg: '#60a5fa', label: 'Exchange', icon: '\u{1F91D}' }, @@ -53,7 +55,7 @@ class FolkListing extends HTMLElement { if (!listingId) return; try { - const res = await fetch(`/${space}/rbnb/api/listings/${listingId}`); + const res = await fetch(`${getModuleApiBase("rbnb")}/api/listings/${listingId}`); if (res.ok) { this.#data = await res.json(); this.#render(); diff --git a/modules/rbnb/components/folk-stay-request.ts b/modules/rbnb/components/folk-stay-request.ts index d3f6d60..147b018 100644 --- a/modules/rbnb/components/folk-stay-request.ts +++ b/modules/rbnb/components/folk-stay-request.ts @@ -5,6 +5,8 @@ * action buttons (accept/decline/cancel/complete), endorsement prompt. */ +import { getModuleApiBase } from "../../../shared/url-helpers"; + const STATUS_STYLES: Record = { pending: { bg: 'rgba(245,158,11,0.15)', fg: '#f59e0b', label: 'Pending' }, accepted: { bg: 'rgba(52,211,153,0.15)', fg: '#34d399', label: 'Accepted' }, @@ -50,7 +52,7 @@ class FolkStayRequest extends HTMLElement { if (!stayId) return; try { - const res = await fetch(`/${space}/rbnb/api/stays/${stayId}`); + const res = await fetch(`${getModuleApiBase("rbnb")}/api/stays/${stayId}`); if (res.ok) { this.#data = await res.json(); this.#render(); diff --git a/modules/rbooks/mod.ts b/modules/rbooks/mod.ts index 83537fc..f322d8a 100644 --- a/modules/rbooks/mod.ts +++ b/modules/rbooks/mod.ts @@ -347,7 +347,7 @@ routes.get("/read/:id", async (c) => { }); // Build the PDF URL relative to this module's mount point - const pdfUrl = `/${spaceSlug}/rbooks/api/books/${book.slug}/pdf`; + const pdfUrl = c.get("isSubdomain") ? `/rbooks/api/books/${book.slug}/pdf` : `/${spaceSlug}/rbooks/api/books/${book.slug}/pdf`; const html = renderShell({ title: `${book.title} | rSpace`, diff --git a/modules/rcart/components/folk-cart-shop.ts b/modules/rcart/components/folk-cart-shop.ts index 84226b8..0f84487 100644 --- a/modules/rcart/components/folk-cart-shop.ts +++ b/modules/rcart/components/folk-cart-shop.ts @@ -633,7 +633,7 @@ class FolkCartShop extends HTMLElement { this.shadow.querySelectorAll("[data-action='copy-pay-url']").forEach((el) => { el.addEventListener("click", () => { const payId = (el as HTMLElement).dataset.payId; - const url = `${window.location.origin}/${this.space}/rcart/pay/${payId}`; + const url = `${window.location.origin}${this.getApiBase()}/pay/${payId}`; navigator.clipboard.writeText(url); (el as HTMLElement).textContent = 'Copied!'; setTimeout(() => { (el as HTMLElement).textContent = 'Copy Link'; }, 2000); @@ -644,7 +644,7 @@ class FolkCartShop extends HTMLElement { this.shadow.querySelectorAll("[data-group-buy-id]").forEach((el) => { el.addEventListener("click", () => { const buyId = (el as HTMLElement).dataset.groupBuyId!; - const url = `/${this.space}/rcart/buy/${buyId}`; + const url = `${this.getApiBase()}/buy/${buyId}`; window.location.href = url; }); }); @@ -1116,7 +1116,7 @@ class FolkCartShop extends HTMLElement { if (this.groupBuys.length === 0) { return `

No group buys yet. Start one from any catalog item to unlock volume pricing together.

- Browse Catalog + Browse Catalog
`; } @@ -1333,7 +1333,7 @@ class FolkCartShop extends HTMLElement {
${new Date(pay.created_at).toLocaleDateString()}
${pay.status === 'pending' ? `
- Open + Open QR
` : ''} diff --git a/modules/rcart/components/folk-payment-page.ts b/modules/rcart/components/folk-payment-page.ts index 2ffc4ff..72b6b55 100644 --- a/modules/rcart/components/folk-payment-page.ts +++ b/modules/rcart/components/folk-payment-page.ts @@ -66,7 +66,7 @@ class FolkPaymentPage extends HTMLElement { disconnectedCallback() { this.stopPolling(); this.walletDiscovery?.stop?.(); - window.removeEventListener('message', this.handleTransakMessage); + window.removeEventListener('message', this.handlePaymentMessage); } private getApiBase(): string { @@ -116,7 +116,7 @@ class FolkPaymentPage extends HTMLElement { private async generateQR() { try { const QRCode = await import('qrcode'); - const payUrl = `${window.location.origin}/${this.space}/rcart/pay/${this.paymentId}`; + const payUrl = `${window.location.origin}${this.getApiBase()}/pay/${this.paymentId}`; this.qrDataUrl = await QRCode.toDataURL(payUrl, { margin: 2, width: 200 }); } catch { /* QR generation optional */ } } @@ -528,7 +528,7 @@ class FolkPaymentPage extends HTMLElement { const explorer = explorerBase[p.chainId] || ''; const cartLink = p.linkedCartId - ? `` + ? `` : ''; return ` diff --git a/modules/rcart/components/folk-payment-request.ts b/modules/rcart/components/folk-payment-request.ts index d519209..9a699ff 100644 --- a/modules/rcart/components/folk-payment-request.ts +++ b/modules/rcart/components/folk-payment-request.ts @@ -155,8 +155,8 @@ class FolkPaymentRequest extends HTMLElement { // Build URLs const host = window.location.origin; - this.payUrl = `${host}/${this.space}/rcart/pay/${paymentId}`; - this.qrSvgUrl = `${host}/${this.space}/rcart/api/payments/${paymentId}/qr`; + this.payUrl = `${host}${this.getApiBase()}/pay/${paymentId}`; + this.qrSvgUrl = `${host}${this.getApiBase()}/api/payments/${paymentId}/qr`; // Generate client-side QR try { @@ -296,8 +296,8 @@ class FolkPaymentRequest extends HTMLElement { // Build URLs const host = window.location.origin; - this.payUrl = `${host}/${this.space}/rcart/pay/${this.generatedPayment.id}`; - this.qrSvgUrl = `${host}/${this.space}/rcart/api/payments/${this.generatedPayment.id}/qr`; + this.payUrl = `${host}${this.getApiBase()}/pay/${this.generatedPayment.id}`; + this.qrSvgUrl = `${host}${this.getApiBase()}/api/payments/${this.generatedPayment.id}/qr`; // Generate client-side QR try { diff --git a/modules/rchoices/components/folk-choices-dashboard.ts b/modules/rchoices/components/folk-choices-dashboard.ts index 29db049..b28663e 100644 --- a/modules/rchoices/components/folk-choices-dashboard.ts +++ b/modules/rchoices/components/folk-choices-dashboard.ts @@ -9,6 +9,7 @@ import { TourEngine } from "../../../shared/tour-engine"; import { ChoicesLocalFirstClient } from "../local-first-client"; import type { ChoicesDoc, ChoiceSession, ChoiceVote } from "../schemas"; import { startPresenceHeartbeat } from '../../../shared/collab-presence'; +import { getModuleApiBase, rspaceNavUrl, getCurrentSpace } from "../../../shared/url-helpers"; // ── CrowdSurf types ── interface CrowdSurfOption { @@ -298,7 +299,7 @@ class FolkChoicesDashboard extends HTMLElement { ${isLive ? `LIVE` : ''}
${this.lfClient ? `` : ''} - + Canvas + + Canvas
@@ -472,14 +473,14 @@ class FolkChoicesDashboard extends HTMLElement { return `

No choices in this space yet.

-

Open the canvas and use the Poll, Rank, or Spider buttons to create one.

+

Open the canvas and use the Poll, Rank, or Spider buttons to create one.

`; } private renderGrid(icons: Record, labels: Record): string { return `
${this.choices.map((ch) => ` - +
${icons[ch.type] || "☑"}
${labels[ch.type] || ch.type}

${this.esc(ch.title)}

diff --git a/modules/rdata/mod.ts b/modules/rdata/mod.ts index 5c95a52..a81c3b0 100644 --- a/modules/rdata/mod.ts +++ b/modules/rdata/mod.ts @@ -246,7 +246,7 @@ const DATA_TABS = [ const DATA_TAB_IDS = new Set(DATA_TABS.map((t) => t.id)); -function renderDataPage(space: string, activeTab: string) { +function renderDataPage(space: string, activeTab: string, isSubdomain: boolean) { const isTree = activeTab === "tree"; const body = isTree ? `` @@ -266,14 +266,14 @@ function renderDataPage(space: string, activeTab: string) { styles: ``, tabs: [...DATA_TABS], activeTab, - tabBasePath: process.env.NODE_ENV === "production" ? `/rdata` : `/${space}/rdata`, + tabBasePath: isSubdomain ? `/rdata` : `/${space}/rdata`, }); } // ── Page routes ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; - return c.html(renderDataPage(space, "tree")); + return c.html(renderDataPage(space, "tree", c.get("isSubdomain"))); }); routes.get("/:tabId", (c, next) => { @@ -282,9 +282,9 @@ routes.get("/:tabId", (c, next) => { // Skip API and asset routes — let Hono fall through if (tabId.startsWith("api") || tabId.includes(".")) return next(); if (!DATA_TAB_IDS.has(tabId as any)) { - return c.redirect(process.env.NODE_ENV === "production" ? `/rdata` : `/${space}/rdata`, 302); + return c.redirect(c.get("isSubdomain") ? `/rdata` : `/${space}/rdata`, 302); } - return c.html(renderDataPage(space, tabId)); + return c.html(renderDataPage(space, tabId, c.get("isSubdomain"))); }); export const dataModule: RSpaceModule = { diff --git a/modules/rinbox/components/folk-inbox-client.ts b/modules/rinbox/components/folk-inbox-client.ts index 096db8a..ab12119 100644 --- a/modules/rinbox/components/folk-inbox-client.ts +++ b/modules/rinbox/components/folk-inbox-client.ts @@ -12,6 +12,7 @@ import { TourEngine } from "../../../shared/tour-engine"; import { ViewHistory } from "../../../shared/view-history.js"; import { getAccessToken, getUsername } from "../../../lib/rspace-header"; import { startPresenceHeartbeat } from '../../../shared/collab-presence'; +import { getModuleApiBase } from "../../../shared/url-helpers"; import { Editor } from '@tiptap/core'; import StarterKit from '@tiptap/starter-kit'; import Link from '@tiptap/extension-link'; @@ -1694,7 +1695,7 @@ class FolkInboxClient extends HTMLElement {
- View Full Feature Page → + View Full Feature Page →
diff --git a/modules/rmaps/components/folk-map-viewer.ts b/modules/rmaps/components/folk-map-viewer.ts index 442623d..ed95f18 100644 --- a/modules/rmaps/components/folk-map-viewer.ts +++ b/modules/rmaps/components/folk-map-viewer.ts @@ -13,6 +13,7 @@ import { RoomSync, type RoomState, type ParticipantState, type LocationState, ty import { loadRoomHistory, saveRoomVisit, updateRoomThumbnail } from "./map-room-history"; import { MapPushManager } from "./map-push"; import { fuzzLocation, haversineDistance, formatDistance, formatTime } from "./map-privacy"; +import { getModuleApiBase } from "../../../shared/url-helpers"; import "./map-privacy-panel"; import { TourEngine } from "../../../shared/tour-engine"; import { ViewHistory } from "../../../shared/view-history.js"; @@ -1687,7 +1688,7 @@ class FolkMapViewer extends HTMLElement { import("./map-share-modal"); const modal = document.createElement("map-share-modal") as any; modal.id = "share-modal"; - modal.url = `${window.location.origin}/${this.space}/rmaps/${this.room}`; + modal.url = `${window.location.origin}${getModuleApiBase("rmaps")}/${this.room}`; modal.room = this.room; modal.addEventListener("modal-close", () => { this.showShareModal = false; }); this.shadow.appendChild(modal); diff --git a/modules/rmeets/mod.ts b/modules/rmeets/mod.ts index 2a7b50e..ad91c8d 100644 --- a/modules/rmeets/mod.ts +++ b/modules/rmeets/mod.ts @@ -166,7 +166,7 @@ routes.get("/recordings", async (c) => { routes.get("/recordings/:id", (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); - return c.redirect(`/${space}/rmeets/recordings/${id}/overview`); + return c.redirect(c.get("isSubdomain") ? `/rmeets/recordings/${id}/overview` : `/${space}/rmeets/recordings/${id}/overview`); }); // ── Recording detail with tabs ── diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts index 5c46d46..3a80ca4 100644 --- a/modules/rnetwork/mod.ts +++ b/modules/rnetwork/mod.ts @@ -685,7 +685,7 @@ const CRM_TABS = [ const CRM_TAB_IDS = new Set(CRM_TABS.map(t => t.id)); -function renderCrm(space: string, activeTab: string) { +function renderCrm(space: string, activeTab: string, isSubdomain: boolean) { return renderShell({ title: `${space} — CRM | rSpace`, moduleId: "rnetwork", @@ -698,13 +698,13 @@ function renderCrm(space: string, activeTab: string) { styles: ``, tabs: [...CRM_TABS], activeTab, - tabBasePath: process.env.NODE_ENV === "production" ? `/rnetwork/crm` : `/${space}/rnetwork/crm`, + tabBasePath: isSubdomain ? `/rnetwork/crm` : `/${space}/rnetwork/crm`, }); } routes.get("/crm", (c) => { const space = c.req.param("space") || "demo"; - return c.html(renderCrm(space, "pipeline")); + return c.html(renderCrm(space, "pipeline", c.get("isSubdomain"))); }); // Tab subpath routes: /crm/:tabId @@ -712,9 +712,9 @@ routes.get("/crm/:tabId", (c) => { const space = c.req.param("space") || "demo"; const tabId = c.req.param("tabId"); if (!CRM_TAB_IDS.has(tabId as any)) { - return c.redirect(`/${space}/rnetwork/crm`, 302); + return c.redirect(c.get("isSubdomain") ? `/rnetwork/crm` : `/${space}/rnetwork/crm`, 302); } - return c.html(renderCrm(space, tabId)); + return c.html(renderCrm(space, tabId, c.get("isSubdomain"))); }); // ── Page route ── @@ -723,7 +723,7 @@ routes.get("/", (c) => { const view = c.req.query("view"); if (view === "app") { - return c.redirect(`/${space}/rnetwork/crm`, 301); + return c.redirect(c.get("isSubdomain") ? `/rnetwork/crm` : `/${space}/rnetwork/crm`, 301); } return c.html(renderShell({ diff --git a/modules/rnotes/components/comment-panel.ts b/modules/rnotes/components/comment-panel.ts index 6fce6e7..d7f711f 100644 --- a/modules/rnotes/components/comment-panel.ts +++ b/modules/rnotes/components/comment-panel.ts @@ -10,6 +10,7 @@ import type { Editor } from '@tiptap/core'; import type { DocumentId } from '../../../shared/local-first/document'; +import { getModuleApiBase } from "../../../shared/url-helpers"; interface CommentMessage { id: string; @@ -731,7 +732,7 @@ class NotesCommentPanel extends HTMLElement { // Try creating a reminder via rSchedule API (non-demo only) if (!this.isDemo && this._space) { try { - const res = await fetch(`/${this._space}/rschedule/api/reminders`, { + const res = await fetch(`${getModuleApiBase("rschedule")}/api/reminders`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, body: JSON.stringify({ @@ -785,7 +786,7 @@ class NotesCommentPanel extends HTMLElement { // Delete from rSchedule if exists if (reminderId && !this.isDemo && this._space) { try { - await fetch(`/${this._space}/rschedule/api/reminders/${reminderId}`, { + await fetch(`${getModuleApiBase("rschedule")}/api/reminders/${reminderId}`, { method: 'DELETE', headers: this.authHeaders(), }); diff --git a/modules/rnotes/components/import-export-dialog.ts b/modules/rnotes/components/import-export-dialog.ts index b0fcd86..a9eda96 100644 --- a/modules/rnotes/components/import-export-dialog.ts +++ b/modules/rnotes/components/import-export-dialog.ts @@ -9,6 +9,8 @@ * API-based sources (Notion/Google) use OAuth connections. */ +import { getModuleApiBase } from "../../../shared/url-helpers"; + interface NotebookOption { id: string; title: string; @@ -84,7 +86,7 @@ class ImportExportDialog extends HTMLElement { private async loadConnections() { try { - const res = await fetch(`/${this.space}/rnotes/api/connections`); + const res = await fetch(`${getModuleApiBase("rnotes")}/api/connections`); if (res.ok) { this.connections = await res.json(); } @@ -97,7 +99,7 @@ class ImportExportDialog extends HTMLElement { if (this.activeSource === 'notion') { try { - const res = await fetch(`/${this.space}/rnotes/api/import/notion/pages`); + const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/notion/pages`); if (res.ok) { const data = await res.json(); this.remotePages = data.pages || []; @@ -105,7 +107,7 @@ class ImportExportDialog extends HTMLElement { } catch { /* ignore */ } } else if (this.activeSource === 'google-docs') { try { - const res = await fetch(`/${this.space}/rnotes/api/import/google-docs/list`); + const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/google-docs/list`); if (res.ok) { const data = await res.json(); this.remotePages = data.docs || []; @@ -145,7 +147,7 @@ class ImportExportDialog extends HTMLElement { if (this.targetNotebookId) formData.append('notebookId', this.targetNotebookId); const token = localStorage.getItem('encryptid_token') || ''; - const res = await fetch(`/${this.space}/rnotes/api/import/files`, { + const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/files`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData, @@ -177,7 +179,7 @@ class ImportExportDialog extends HTMLElement { } const token = localStorage.getItem('encryptid_token') || ''; - const res = await fetch(`/${this.space}/rnotes/api/import/upload`, { + const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/upload`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData, @@ -201,7 +203,7 @@ class ImportExportDialog extends HTMLElement { } const token = localStorage.getItem('encryptid_token') || ''; - const res = await fetch(`/${this.space}/rnotes/api/import/notion`, { + const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/notion`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -228,7 +230,7 @@ class ImportExportDialog extends HTMLElement { } const token = localStorage.getItem('encryptid_token') || ''; - const res = await fetch(`/${this.space}/rnotes/api/import/google-docs`, { + const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/google-docs`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -267,7 +269,7 @@ class ImportExportDialog extends HTMLElement { try { if (this.activeSource === 'obsidian' || this.activeSource === 'logseq' || this.activeSource === 'markdown' as any) { const format = this.activeSource === 'markdown' as any ? 'markdown' : this.activeSource; - const url = `/${this.space}/rnotes/api/export/${format}?notebookId=${encodeURIComponent(this.targetNotebookId)}`; + const url = `${getModuleApiBase("rnotes")}/api/export/${format}?notebookId=${encodeURIComponent(this.targetNotebookId)}`; const res = await fetch(url); if (res.ok) { @@ -288,7 +290,7 @@ class ImportExportDialog extends HTMLElement { } } else if (this.activeSource === 'notion') { const token = localStorage.getItem('encryptid_token') || ''; - const res = await fetch(`/${this.space}/rnotes/api/export/notion`, { + const res = await fetch(`${getModuleApiBase("rnotes")}/api/export/notion`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -305,7 +307,7 @@ class ImportExportDialog extends HTMLElement { } } else if (this.activeSource === 'google-docs') { const token = localStorage.getItem('encryptid_token') || ''; - const res = await fetch(`/${this.space}/rnotes/api/export/google-docs`, { + const res = await fetch(`${getModuleApiBase("rnotes")}/api/export/google-docs`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -562,7 +564,7 @@ class ImportExportDialog extends HTMLElement { private async loadSyncStatus(notebookId: string) { try { - const res = await fetch(`/${this.space}/rnotes/api/sync/status/${notebookId}`); + const res = await fetch(`${getModuleApiBase("rnotes")}/api/sync/status/${notebookId}`); if (res.ok) { const data = await res.json(); this.syncStatuses = data.statuses || {}; @@ -579,7 +581,7 @@ class ImportExportDialog extends HTMLElement { try { const token = localStorage.getItem('encryptid_token') || ''; - const res = await fetch(`/${this.space}/rnotes/api/sync/notebook/${this.targetNotebookId}`, { + const res = await fetch(`${getModuleApiBase("rnotes")}/api/sync/notebook/${this.targetNotebookId}`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, }); @@ -624,7 +626,7 @@ class ImportExportDialog extends HTMLElement { formData.append('source', source); const token = localStorage.getItem('encryptid_token') || ''; - const res = await fetch(`/${this.space}/rnotes/api/sync/upload`, { + const res = await fetch(`${getModuleApiBase("rnotes")}/api/sync/upload`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData, diff --git a/modules/rpubs/components/folk-pubs-editor.ts b/modules/rpubs/components/folk-pubs-editor.ts index 5bd6c25..360a978 100644 --- a/modules/rpubs/components/folk-pubs-editor.ts +++ b/modules/rpubs/components/folk-pubs-editor.ts @@ -13,6 +13,7 @@ import { pubsDraftSchema, pubsDocId } from '../schemas'; import type { PubsDoc } from '../schemas'; import type { DocumentId } from '../../../shared/local-first/document'; import { TourEngine } from '../../../shared/tour-engine'; +import { getModuleApiBase } from "../../../shared/url-helpers"; import { startPresenceHeartbeat } from '../../../shared/collab-presence'; interface BookFormat { @@ -445,7 +446,7 @@ export class FolkPubsEditor extends HTMLElement { Open File - + 📰 Zine @@ -766,7 +767,7 @@ export class FolkPubsEditor extends HTMLElement { const s = JSON.parse(localStorage.getItem("encryptid_session") || "{}"); if (s?.accessToken) authHeaders["Authorization"] = `Bearer ${s.accessToken}`; } catch {} - const res = await fetch(`/${this._spaceSlug}/rpubs/api/generate`, { + const res = await fetch(`${getModuleApiBase("rpubs")}/api/generate`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders }, body: JSON.stringify({ diff --git a/modules/rpubs/components/folk-pubs-publish-panel.ts b/modules/rpubs/components/folk-pubs-publish-panel.ts index ecd19dc..8ce035d 100644 --- a/modules/rpubs/components/folk-pubs-publish-panel.ts +++ b/modules/rpubs/components/folk-pubs-publish-panel.ts @@ -12,6 +12,8 @@ * space-slug — Current space slug for API calls */ +import { getModuleApiBase } from "../../../shared/url-helpers"; + export class FolkPubsPublishPanel extends HTMLElement { private _pdfUrl = ""; private _formatId = ""; @@ -373,7 +375,7 @@ export class FolkPubsPublishPanel extends HTMLElement { this.render(); try { - const res = await fetch(`/${this._spaceSlug}/rpubs/api/email-pdf`, { + const res = await fetch(`${getModuleApiBase("rpubs")}/api/email-pdf`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -403,7 +405,7 @@ export class FolkPubsPublishPanel extends HTMLElement { this.render(); try { - const res = await fetch(`/${this._spaceSlug}/rpubs/api/imposition`, { + const res = await fetch(`${getModuleApiBase("rpubs")}/api/imposition`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -517,7 +519,7 @@ export class FolkPubsPublishPanel extends HTMLElement { const { latitude: lat, longitude: lng } = pos.coords; const res = await fetch( - `/${this._spaceSlug}/rpubs/api/printers?lat=${lat}&lng=${lng}&radius=100&format=${this._formatId}`, + `${getModuleApiBase("rpubs")}/api/printers?lat=${lat}&lng=${lng}&radius=100&format=${this._formatId}`, ); if (!res.ok) throw new Error("Failed to search printers"); @@ -538,7 +540,7 @@ export class FolkPubsPublishPanel extends HTMLElement { private async placeOrder() { if (!this._selectedProvider) return; try { - const res = await fetch(`/${this._spaceSlug}/rpubs/api/order`, { + const res = await fetch(`${getModuleApiBase("rpubs")}/api/order`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -569,7 +571,7 @@ export class FolkPubsPublishPanel extends HTMLElement { private async joinBatch() { if (!this._selectedProvider) return; try { - const res = await fetch(`/${this._spaceSlug}/rpubs/api/batch`, { + const res = await fetch(`${getModuleApiBase("rpubs")}/api/batch`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/modules/rsocials/components/folk-campaign-manager.ts b/modules/rsocials/components/folk-campaign-manager.ts index 852d365..e8a4cd1 100644 --- a/modules/rsocials/components/folk-campaign-manager.ts +++ b/modules/rsocials/components/folk-campaign-manager.ts @@ -629,7 +629,7 @@ export class FolkCampaignManager extends HTMLElement { } try { - const res = await fetch(`/${this._space}/rsocials/api/campaign/generate`, { + const res = await fetch(`${this.basePath}api/campaign/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ brief, platforms, tone, style }), diff --git a/modules/rsocials/components/folk-newsletter-manager.ts b/modules/rsocials/components/folk-newsletter-manager.ts index 51f4b6c..c1bc3f6 100644 --- a/modules/rsocials/components/folk-newsletter-manager.ts +++ b/modules/rsocials/components/folk-newsletter-manager.ts @@ -10,6 +10,7 @@ */ import { getAccessToken } from '../../../shared/components/rstack-identity'; +import { getModuleApiBase } from "../../../shared/url-helpers"; interface DraftData { id: string; @@ -74,7 +75,7 @@ export class FolkNewsletterManager extends HTMLElement { } private apiBase(): string { - return `/${this._space}/rsocials/api/newsletter`; + return `${getModuleApiBase("rsocials")}/api/newsletter`; } private async apiFetch(path: string, opts: RequestInit = {}): Promise { diff --git a/modules/rsocials/components/folk-thread-builder.ts b/modules/rsocials/components/folk-thread-builder.ts index 4e8e602..521b79a 100644 --- a/modules/rsocials/components/folk-thread-builder.ts +++ b/modules/rsocials/components/folk-thread-builder.ts @@ -848,7 +848,7 @@ export class FolkThreadBuilder extends HTMLElement { postizBtn.textContent = 'Sending...'; (postizBtn as HTMLButtonElement).disabled = true; try { - const res = await fetch(`/${this._space}/rsocials/api/postiz/threads`, { + const res = await fetch(`${this.basePath}api/postiz/threads`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tweets, type: 'draft' }), @@ -1002,7 +1002,7 @@ export class FolkThreadBuilder extends HTMLElement { roPostizBtn.textContent = 'Sending...'; (roPostizBtn as HTMLButtonElement).disabled = true; try { - const res = await fetch(`/${this._space}/rsocials/api/postiz/threads`, { + const res = await fetch(`${this.basePath}api/postiz/threads`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tweets: t.tweets, type: 'draft' }), diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index da10bf2..14f3f35 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -1364,7 +1364,7 @@ routes.post("/api/campaign/wizard", async (c) => { (d.campaignWizards as any)[wizardId] = wizard; }); - return c.json({ wizardId, url: `/${space}/rsocials/campaign-wizard/${wizardId}` }, 201); + return c.json({ wizardId, url: c.get("isSubdomain") ? `/rsocials/campaign-wizard/${wizardId}` : `/${space}/rsocials/campaign-wizard/${wizardId}` }, 201); }); routes.get("/api/campaign/wizard/:id", (c) => { diff --git a/modules/rsplat/mod.ts b/modules/rsplat/mod.ts index 2ebf387..46258cd 100644 --- a/modules/rsplat/mod.ts +++ b/modules/rsplat/mod.ts @@ -159,9 +159,10 @@ function itemToRow(item: SplatItem): SplatRow { /** * Return the subset of SplatRow fields used in list/gallery responses. */ -function itemToListRow(item: SplatItem, spaceSlug?: string) { +function itemToListRow(item: SplatItem, spaceSlug?: string, isSubdomain?: boolean) { + const effectiveSlug = spaceSlug || item.spaceSlug; const fileUrl = item.filePath - ? `/${spaceSlug || item.spaceSlug}/rsplat/api/splats/${item.slug}/${item.slug}.${item.fileFormat}` + ? (isSubdomain ? `/rsplat/api/splats/${item.slug}/${item.slug}.${item.fileFormat}` : `/${effectiveSlug}/rsplat/api/splats/${item.slug}/${item.slug}.${item.fileFormat}`) : null; return { id: item.id, @@ -229,7 +230,7 @@ routes.get("/api/splats", async (c) => { // Apply offset and limit const paged = items.slice(offset, offset + limit); - return c.json({ splats: paged.map(i => itemToListRow(i, spaceSlug)) }); + return c.json({ splats: paged.map(i => itemToListRow(i, spaceSlug, c.get("isSubdomain"))) }); }); // ── API: Get splat details ── @@ -657,7 +658,7 @@ routes.get("/api/splats/my-history", async (c) => { .sort((a, b) => b.createdAt - a.createdAt) .slice(0, 50); - return c.json({ splats: items.map(i => itemToListRow(i, spaceSlug)) }); + return c.json({ splats: items.map(i => itemToListRow(i, spaceSlug, c.get("isSubdomain"))) }); }); // ── API: Delete splat (owner only) ── @@ -708,7 +709,7 @@ routes.get("/", async (c) => { .sort((a, b) => b.createdAt - a.createdAt) .slice(0, 50); - const rows = items.map(i => itemToListRow(i, spaceSlug)); + const rows = items.map(i => itemToListRow(i, spaceSlug, c.get("isSubdomain"))); const splatsJSON = JSON.stringify(rows); const html = renderShell({ @@ -737,7 +738,7 @@ routes.get("/", async (c) => { }); // ── Shared viewer page renderer ── -function renderViewerPage(spaceSlug: string, dataSpace: string, idOrSlug: string) { +function renderViewerPage(spaceSlug: string, dataSpace: string, idOrSlug: string, isSubdomain?: boolean) { const doc = ensureDoc(dataSpace); const found = findItem(doc, idOrSlug); @@ -761,7 +762,7 @@ function renderViewerPage(spaceSlug: string, dataSpace: string, idOrSlug: string d.items[itemKey].viewCount += 1; }); - const fileUrl = `/${spaceSlug}/rsplat/api/splats/${splat.slug}/${splat.slug}.${splat.fileFormat}`; + const fileUrl = isSubdomain ? `/rsplat/api/splats/${splat.slug}/${splat.slug}.${splat.fileFormat}` : `/${spaceSlug}/rsplat/api/splats/${splat.slug}/${splat.slug}.${splat.fileFormat}`; const html = renderShell({ title: `${splat.title} | rSplat`, @@ -852,7 +853,7 @@ routes.get("/:slug", async (c) => { const spaceSlug = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || spaceSlug; - const result = renderViewerPage(spaceSlug, dataSpace, slug); + const result = renderViewerPage(spaceSlug, dataSpace, slug, c.get("isSubdomain")); return c.html(result.html, result.status); }); diff --git a/modules/rtasks/mod.ts b/modules/rtasks/mod.ts index b625997..72e14f3 100644 --- a/modules/rtasks/mod.ts +++ b/modules/rtasks/mod.ts @@ -711,7 +711,7 @@ routes.post("/api/clickup/import", async (c) => { const client = new ClickUpClient(accessToken); const host = c.req.header('host') || 'rspace.online'; const protocol = c.req.header('x-forwarded-proto') || 'https'; - const endpoint = `${protocol}://${host}/${space}/rtasks/api/clickup/webhook`; + const endpoint = c.get("isSubdomain") ? `${protocol}://${host}/rtasks/api/clickup/webhook` : `${protocol}://${host}/${space}/rtasks/api/clickup/webhook`; try { const wh = await client.createWebhook( conn.clickup.teamId, diff --git a/modules/rtrips/components/folk-trips-planner.ts b/modules/rtrips/components/folk-trips-planner.ts index 9276536..cf08311 100644 --- a/modules/rtrips/components/folk-trips-planner.ts +++ b/modules/rtrips/components/folk-trips-planner.ts @@ -11,6 +11,7 @@ import type { DocumentId } from "../../../shared/local-first/document"; import { TourEngine } from "../../../shared/tour-engine"; import { ViewHistory } from "../../../shared/view-history.js"; import { startPresenceHeartbeat } from '../../../shared/collab-presence'; +import { rspaceNavUrl } from '../../../shared/url-helpers'; class FolkTripsPlanner extends HTMLElement { private shadow: ShadowRoot; @@ -1361,8 +1362,7 @@ GUIDELINES: const accepted = this._aiGeneratedItems.filter(i => i.accepted); if (accepted.length === 0) return; sessionStorage.setItem('rtrips-canvas-export', JSON.stringify(accepted.map(i => ({ type: i.type, props: i.props })))); - const nav = (window as any).__rspaceNavUrl; - window.location.href = (nav ? nav(this.space, 'rspace') : `/${this.space}/rspace`) + '#trip-import'; + window.location.href = rspaceNavUrl(this.space, 'rspace') + '#trip-import'; } private goBack() { diff --git a/modules/rvnb/components/folk-rental-request.ts b/modules/rvnb/components/folk-rental-request.ts index 95f21b2..607644d 100644 --- a/modules/rvnb/components/folk-rental-request.ts +++ b/modules/rvnb/components/folk-rental-request.ts @@ -6,6 +6,8 @@ * action buttons (accept/decline/cancel/complete), endorsement prompt. */ +import { getModuleApiBase } from "../../../shared/url-helpers"; + const STATUS_STYLES: Record = { pending: { bg: 'rgba(245,158,11,0.15)', fg: '#f59e0b', label: 'Pending' }, accepted: { bg: 'rgba(52,211,153,0.15)', fg: '#34d399', label: 'Accepted' }, @@ -51,7 +53,7 @@ class FolkRentalRequest extends HTMLElement { if (!rentalId) return; try { - const res = await fetch(`/${space}/rvnb/api/rentals/${rentalId}`); + const res = await fetch(`${getModuleApiBase("rvnb")}/api/rentals/${rentalId}`); if (res.ok) { this.#data = await res.json(); this.#render(); diff --git a/modules/rvnb/components/folk-vehicle-card.ts b/modules/rvnb/components/folk-vehicle-card.ts index 381de2c..ed3bd19 100644 --- a/modules/rvnb/components/folk-vehicle-card.ts +++ b/modules/rvnb/components/folk-vehicle-card.ts @@ -5,6 +5,8 @@ * owner name + trust badge, sleeps, mileage policy, pickup location, endorsement count. */ +import { getModuleApiBase } from "../../../shared/url-helpers"; + const ECONOMY_COLORS: Record = { gift: { bg: 'rgba(52,211,153,0.12)', fg: '#34d399', label: 'Gift', icon: '\u{1F49A}' }, exchange: { bg: 'rgba(96,165,250,0.12)', fg: '#60a5fa', label: 'Exchange', icon: '\u{1F91D}' }, @@ -58,7 +60,7 @@ class FolkVehicleCard extends HTMLElement { if (!vehicleId) return; try { - const res = await fetch(`/${space}/rvnb/api/vehicles/${vehicleId}`); + const res = await fetch(`${getModuleApiBase("rvnb")}/api/vehicles/${vehicleId}`); if (res.ok) { this.#data = await res.json(); this.#render(); diff --git a/modules/rvnb/components/folk-vnb-view.ts b/modules/rvnb/components/folk-vnb-view.ts index 33029ff..27bb7b8 100644 --- a/modules/rvnb/components/folk-vnb-view.ts +++ b/modules/rvnb/components/folk-vnb-view.ts @@ -10,6 +10,7 @@ import type { TourStep } from '../../../shared/tour-engine'; import './folk-vehicle-card'; import './folk-rental-request'; import { startPresenceHeartbeat } from '../../../shared/collab-presence'; +import { getModuleApiBase } from "../../../shared/url-helpers"; const VNB_TOUR_STEPS: TourStep[] = [ { target: '.vnb-search', title: 'Search', message: 'Filter by vehicle type, dates, or economy model.' }, @@ -94,9 +95,9 @@ class FolkVnbView extends HTMLElement { async #loadData() { try { const [vehiclesRes, rentalsRes, statsRes] = await Promise.all([ - fetch(`/${this.#space}/rvnb/api/vehicles`), - fetch(`/${this.#space}/rvnb/api/rentals`), - fetch(`/${this.#space}/rvnb/api/stats`), + fetch(`${getModuleApiBase("rvnb")}/api/vehicles`), + fetch(`${getModuleApiBase("rvnb")}/api/rentals`), + fetch(`${getModuleApiBase("rvnb")}/api/stats`), ]); if (vehiclesRes.ok) { @@ -467,7 +468,7 @@ class FolkVnbView extends HTMLElement { async #handleRentalAction(rentalId: string, action: string) { try { - const res = await fetch(`/${this.#space}/rvnb/api/rentals/${rentalId}/${action}`, { + const res = await fetch(`${getModuleApiBase("rvnb")}/api/rentals/${rentalId}/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); @@ -483,7 +484,7 @@ class FolkVnbView extends HTMLElement { async #handleRentalMessage(rentalId: string, body: string) { try { - const res = await fetch(`/${this.#space}/rvnb/api/rentals/${rentalId}/messages`, { + const res = await fetch(`${getModuleApiBase("rvnb")}/api/rentals/${rentalId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body, sender_name: 'You' }), diff --git a/modules/rvote/mod.ts b/modules/rvote/mod.ts index 3416539..a0b6c2e 100644 --- a/modules/rvote/mod.ts +++ b/modules/rvote/mod.ts @@ -390,7 +390,7 @@ routes.post("/api/proposals", async (c) => { // Notify space members about the new proposal import('../rinbox/agent-notify').then(({ sendSpaceNotification }) => { sendSpaceNotification(space_slug, `New Proposal: ${title}`, - `

${title}

${description ? `

${description}

` : ''}

Vote in rVote

` + `

${title}

${description ? `

${description}

` : ''}

Vote in rVote

` ).catch(() => {}); }).catch(() => {}); diff --git a/server/mi-routes.ts b/server/mi-routes.ts index 8c2c8d8..31ac5f3 100644 --- a/server/mi-routes.ts +++ b/server/mi-routes.ts @@ -651,7 +651,7 @@ function generateFallbackResponse( for (const m of modules) { if (q.includes(m.id) || q.includes(m.name.toLowerCase())) { - return `**${m.name}** ${m.icon} — ${m.description}. You can access it at /${space || "personal"}/${m.id}.`; + return `**${m.name}** ${m.icon} — ${m.description}. You can access it at /${m.id}.`; } } diff --git a/server/oauth/clickup.ts b/server/oauth/clickup.ts index 82ca28d..a67901c 100644 --- a/server/oauth/clickup.ts +++ b/server/oauth/clickup.ts @@ -123,7 +123,7 @@ clickupOAuthRoutes.get('/callback', async (c) => { }); // Redirect back to rTasks - const redirectUrl = `/${state.space}/rtasks?connected=clickup`; + const redirectUrl = c.get("isSubdomain") ? `/rtasks?connected=clickup` : `/${state.space}/rtasks?connected=clickup`; return c.redirect(redirectUrl); }); diff --git a/server/oauth/google.ts b/server/oauth/google.ts index 236818b..28be495 100644 --- a/server/oauth/google.ts +++ b/server/oauth/google.ts @@ -132,7 +132,7 @@ googleOAuthRoutes.get('/callback', async (c) => { }; }); - const redirectUrl = `/${state.space}/rnotes?connected=google`; + const redirectUrl = c.get("isSubdomain") ? `/rnotes?connected=google` : `/${state.space}/rnotes?connected=google`; return c.redirect(redirectUrl); }); diff --git a/server/oauth/notion.ts b/server/oauth/notion.ts index 1f83c43..46d3966 100644 --- a/server/oauth/notion.ts +++ b/server/oauth/notion.ts @@ -105,7 +105,7 @@ notionOAuthRoutes.get('/callback', async (c) => { }); // Redirect back to rNotes - const redirectUrl = `/${state.space}/rnotes?connected=notion`; + const redirectUrl = c.get("isSubdomain") ? `/rnotes?connected=notion` : `/${state.space}/rnotes?connected=notion`; return c.redirect(redirectUrl); }); diff --git a/shared/components/rstack-space-switcher.ts b/shared/components/rstack-space-switcher.ts index b17f3a0..ce1101e 100644 --- a/shared/components/rstack-space-switcher.ts +++ b/shared/components/rstack-space-switcher.ts @@ -10,7 +10,7 @@ */ import { isAuthenticated, getAccessToken, getUsername } from "./rstack-identity"; -import { rspaceNavUrl, getCurrentModule as getModule } from "../url-helpers"; +import { rspaceNavUrl, getCurrentModule as getModule, getModuleApiBase } from "../url-helpers"; interface SpaceInfo { slug: string; @@ -1208,7 +1208,7 @@ export class RStackSpaceSwitcher extends HTMLElement { const savedVal = modules.find(m => m.id === modId)?.settings?.[key] || ''; try { const token = getAccessToken(); - const nbRes = await fetch(`/${space}/rnotes/api/notebooks`, { + const nbRes = await fetch(`${getModuleApiBase("rnotes")}/api/notebooks`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (nbRes.ok) { diff --git a/shared/components/rstack-user-dashboard.ts b/shared/components/rstack-user-dashboard.ts index b9f4fca..429e14d 100644 --- a/shared/components/rstack-user-dashboard.ts +++ b/shared/components/rstack-user-dashboard.ts @@ -9,6 +9,7 @@ */ import { getSession, getAccessToken } from "./rstack-identity"; +import { getModuleApiBase } from "../url-helpers"; interface MemberInfo { did: string; @@ -95,7 +96,7 @@ export class RStackUserDashboard extends HTMLElement { async #fetchMembers() { this.#membersLoading = true; try { - const res = await fetch(`/${encodeURIComponent(this.space)}/api/space-members`); + const res = await fetch(`${getModuleApiBase("rspace")}/api/space-members`); if (res.ok) { const data = await res.json(); this.#members = data.members || []; diff --git a/shared/url-helpers.ts b/shared/url-helpers.ts index 66e2366..a9f85ba 100644 --- a/shared/url-helpers.ts +++ b/shared/url-helpers.ts @@ -118,3 +118,22 @@ export function rspaceNavUrl(space: string, moduleId: string): string { // Localhost/dev return `/${space}/${moduleId}`; } + +/** + * Get the API base path for a module, subdomain-aware. + * + * On subdomain: returns `/{moduleId}` (e.g. `/rcart`) + * On localhost: returns `/{space}/{moduleId}` (e.g. `/jeff/rcart`) + * + * Uses regex on the current pathname so it works regardless of routing mode. + */ +export function getModuleApiBase(moduleId: string): string { + const path = window.location.pathname; + const re = new RegExp(`^(\\/[^/]+)?\\/${moduleId}`); + const match = path.match(re); + if (match) return match[0]; + // Fallback: subdomain → /{moduleId}, else /{firstSegment}/{moduleId} + if (isSubdomain() || isBareDomain()) return `/${moduleId}`; + const space = path.split("/").filter(Boolean)[0] || "demo"; + return `/${space}/${moduleId}`; +}