1 2// Init Supabase from config.js (loaded via env-generated file). 3// The SDK is used only for auth flows (signInWithOAuth, signOut, 4// onAuthStateChange). Data queries go through `db` below, which calls 5// PostgREST directly â this is the same HTTP API the SDK wraps. 6const SUPABASE_STORAGE_KEY = "sb-eyjmtwlqscbkjpgvkdnt-auth-token"; 7 8const sb = supabase.createClient(SUPABASE_URL, SUPABASE_KEY, { 9 auth: { 10 detectSessionInUrl: true, 11 flowType: "pkce", 12 persistSession: true, 13 storage: window.localStorage, 14 }, 15}); 16 17function readSession() { 18 try { 19 const raw = localStorage.getItem(SUPABASE_STORAGE_KEY); 20 return raw ? JSON.parse(raw) : null; 21 } catch { return null; } 22} 23function authToken() { return readSession()?.access_token || null; } 24 25// Fluent PostgREST query builder. Mirrors sb.from() shape so call sites read 26// the same. Chain methods build up state; awaiting the builder runs the query. 27function pgQuery(table) { 28 let method = "GET"; 29 let body = null; 30 let selectCols = "*"; 31 let selectExplicit = false; 32 const filters = []; 33 let order = null; 34 let limit = null; 35 let range = null; 36 let single = false; 37 let maybeSingle = false; 38 let upsertOnConflict = null; 39 40 const builder = { 41 select(cols) { selectCols = cols || "*"; selectExplicit = true; return builder; }, 42 insert(payload) { method = "POST"; body = JSON.stringify(payload); return builder; }, 43 // Was missing entirely, and its absence was silent: db.from(x).upsert(...) 44 // threw "upsert is not a function", the exception escaped the async click 45 // handler that called it, and the optimistic UI stayed flipped while 46 // nothing was written. A control that lies about having saved. 47 // 48 // PostgREST spells upsert as POST + Prefer: resolution=merge-duplicates, 49 // with on_conflict naming the unique constraint to merge on. 50 upsert(payload, opts) { 51 method = "POST"; 52 body = JSON.stringify(payload); 53 upsertOnConflict = (opts && opts.onConflict) || null; 54 return builder; 55 }, 56 update(payload) { method = "PATCH"; body = JSON.stringify(payload); return builder; }, 57 delete() { method = "DELETE"; return builder; }, 58 eq(col, val) { filters.push([col, `eq.${val}`]); return builder; }, 59 neq(col, val) { filters.push([col, `neq.${val}`]); return builder; }, 60 in(col, vals) { 61 const list = vals.map(v => `"${String(v).replace(/"/g, '\\"')}"`).join(","); 62 filters.push([col, `in.(${list})`]); 63 return builder; 64 }, 65 is(col, val) { filters.push([col, `is.${val}`]); return builder; }, 66 not(col, op, val) { filters.push([col, `not.${op}.${val}`]); return builder; }, 67 ilike(col, pattern) { filters.push([col, `ilike.${pattern}`]); return builder; }, 68 or(exprs) { filters.push(["or", `(${exprs.join(",")})`]); return builder; }, 69 order(col, opts = {}) { order = `${col}.${opts.ascending === false ? "desc" : "asc"}`; return builder; }, 70 limit(n) { limit = n; return builder; }, 71 range(from, to) { range = [from, to]; return builder; }, 72 single() { single = true; return builder; }, 73 maybeSingle() { maybeSingle = true; return builder; }, 74 then(onFulfilled, onRejected) { return execute().then(onFulfilled, onRejected); }, 75 }; 76 77 async function execute() { 78 const params = new URLSearchParams(); 79 if (method === "GET" || selectExplicit) params.set("select", selectCols); 80 for (const [col, expr] of filters) params.append(col, expr); 81 if (order) params.set("order", order); 82 if (limit != null) params.set("limit", String(limit)); 83 // Names the unique constraint PostgREST merges on. Without it the POST is 84 // a plain insert and a second write raises a duplicate-key error. 85 if (upsertOnConflict) params.set("on_conflict", upsertOnConflict); 86 87 const headers = { "apikey": SUPABASE_KEY }; 88 const tok = authToken(); 89 if (tok) headers["Authorization"] = `Bearer ${tok}`; 90 if (body != null) headers["Content-Type"] = "application/json"; 91 if (method !== "GET") { 92 const prefer = [selectExplicit ? "return=representation" : "return=minimal"]; 93 if (upsertOnConflict !== null) prefer.push("resolution=merge-duplicates"); 94 headers["Prefer"] = prefer.join(","); 95 } 96 if (range) { headers["Range-Unit"] = "items"; headers["Range"] = `${range[0]}-${range[1]}`; } 97 if (single) headers["Accept"] = "application/vnd.pgrst.object+json"; 98 99 const url = `${SUPABASE_URL}/rest/v1/${table}?${params.toString()}`; 100 let res; 101 try { 102 res = await fetch(url, { method, headers, body }); 103 } catch (e) { 104 return { data: null, error: { message: e.message || "Network error" } }; 105 } 106 if (res.status === 204) return { data: null, error: null }; 107 108 const text = await res.text(); 109 let parsed = null; 110 if (text) {
111 try { parsed = JSON.parse(text); } catch { parsed = text; } 112 } 113 if (!res.ok) { 114 const msg = parsed?.message || parsed?.error || res.statusText || `HTTP ${res.status}`; 115 return { data: null, error: { message: msg, status: res.status, details: parsed } }; 116 } 117 if (single) return { data: parsed, error: null }; 118 if (maybeSingle) { 119 const rows = Array.isArray(parsed) ? parsed : (parsed ? [parsed] : []); 120 return { data: rows[0] ?? null, error: null }; 121 } 122 return { data: parsed, error: null }; 123 } 124 125 return builder; 126} 127 128const db = { 129 from(table) { return pgQuery(table); }, 130 async rpc(name, params) { 131 const headers = { "apikey": SUPABASE_KEY, "Content-Type": "application/json" }; 132 const tok = authToken(); 133 if (tok) headers["Authorization"] = `Bearer ${tok}`; 134 let res; 135 try { 136 res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${name}`, { 137 method: "POST", headers, body: JSON.stringify(params || {}), 138 }); 139 } catch (e) { 140 return { data: null, error: { message: e.message || "Network error" } }; 141 } 142 if (res.status === 204) return { data: null, error: null }; 143 const text = await res.text(); 144 let parsed = null; 145 if (text) { 146 try { parsed = JSON.parse(text); } catch { parsed = text; } 147 } 148 if (!res.ok) { 149 const msg = parsed?.message || parsed?.error || `HTTP ${res.status}`; 150 return { data: null, error: { message: msg, status: res.status, details: parsed } }; 151 } 152 return { data: parsed, error: null }; 153 }, 154}; 155 156let currentUser = null; 157let currentProfile = null; 158// URL routing: every page maps to a real URL so links are shareable, 159// bookmarkable, and browser back/forward actually works. The routing is 160// best-effort â state still lives in module-level vars (currentTeamId, 161// currentProfile_*). URL is just a reflection we keep in sync here. 162 163const PUBLIC_PAGES = new Set(["landing", "install", "terms", "privacy", "privacy-profile-data"]); 164let pendingPostAuthPath = null; // stashed when a logged-out visitor hits a private URL 165let pendingJoinCode = null; // stashed from legacy #join/<code> during boot 166 167function pathForPage(page, extra) { 168 if (page === "dashboard") return "/home"; 169 if (page === "team") { 170 const tid = typeof extra === "string" ? extra : extra?.teamId; 171 if (!tid) return "/home"; 172 // Each workspace view is its own address, the way a list is â People 173 // included, so the three read alike in the bar. Bare /team/<id> still 174 // resolves to People, for old links and for landing on the workspace. 175 const view = (typeof extra === "object" && extra) ? extra.view : null; 176 const seg = view === "integrations" ? "configure" 177 : view === "team" ? "team" 178 : view === "people" ? "people" 179 : view === "notifications" ? "notifications" 180 : null; 181 return seg ? `/team/${encodeURIComponent(tid)}/${seg}` : `/team/${encodeURIComponent(tid)}`; 182 } 183 if (page === "profile") { 184 const tid = extra?.teamId || currentProfile_TeamId; 185 const pid = extra?.profileId || currentProfile_Id; 186 if (!tid || !pid) return "/home"; 187 return `/team/${encodeURIComponent(tid)}/profile/${encodeURIComponent(pid)}`; 188 } 189 if (page === "review") { 190 const tid = extra?.teamId; 191 const iid = extra?.integrationId; 192 const eid = extra?.eventId; 193 if (!tid || !iid) return "/home"; 194 const base = `/team/${encodeURIComponent(tid)}/review/${encodeURIComponent(iid)}`; 195 return eid ? `${base}/event/${encodeURIComponent(eid)}` : base; 196 } 197 if (page === "entries") { 198 const tid = extra?.teamId || currentTeamId; 199 if (!tid) return "/home"; 200 let p = `/team/${encodeURIComponent(tid)}/entries`; 201 if (extra?.listId) { 202 p += `/${encodeURIComponent(extra.listId)}`; 203 if (extra.view === "outreach") p += "/outreach"; // pipeline is the default sub-view 204 } 205 return p; 206 } 207 if (page === "settings") return "/account"; 208 if (page === "oauth-consent") return "/oauth/consent"; 209 if (page === "install") return "/install"; 210 if (page === "terms") return "/terms"; 211 if (page === "privacy") return "/privacy"; 212 if (page === "privacy-profile-data") return "/privacy/profile-data"; 213 return "/"; 214} 215 216function pageFromPath(rawPath) { 217 const path = rawPath.replace(/\/+$/, "") || "/"; 218 if (path === "/" || path === "") return { page: "landing" }; 219 if (path === "/home") return { page: "dashboard" }; 220 // /dashboard was this page's address until it was renamed. Old links and 221 // bookmarks still land, and navigate() rewrites the bar to /home. 222 if (path === "/dashboard") return { page: "dashboard" }; 223 if (path === "/account") return { page: "settings" }; 224 // /settings was this page's address until it was renamed â and the team's 225 // own Configure view now owns the word "settings" in this app. Old links 226 // still land, and navigate() rewrites the bar to /account. 227 if (path === "/settings") return { page: "settings" }; 228 if (path === "/oauth/consent") return { page: "oauth-consent" }; 229 if (path === "/install") return { page: "install" }; 230 if (path === "/terms") return { page: "terms" }; 231 if (path === "/privacy") return { page: "privacy" }; 232 if (path === "/privacy/profile-data") return { page: "privacy-profile-data" }; 233 let m = path.match(/^\/team\/([^/]+)\/profile\/([^/]+)$/);
234 if (m) return { page: "profile", teamId: decodeURIComponent(m[1]), profileId: decodeURIComponent(m[2]) }; 235 m = path.match(/^\/team\/([^/]+)\/review\/([^/]+)\/event\/([^/]+)$/); 236 if (m) return { page: "review", teamId: decodeURIComponent(m[1]), integrationId: decodeURIComponent(m[2]), eventId: decodeURIComponent(m[3]) }; 237 m = path.match(/^\/team\/([^/]+)\/review\/([^/]+)$/); 238 if (m) return { page: "review", teamId: decodeURIComponent(m[1]), integrationId: decodeURIComponent(m[2]) }; 239 m = path.match(/^\/team\/([^/]+)\/entries(?:\/([^/]+)(\/outreach)?)?$/); 240 if (m) return { 241 page: "entries", 242 teamId: decodeURIComponent(m[1]), 243 listId: m[2] ? decodeURIComponent(m[2]) : null, 244 view: m[3] ? "outreach" : (m[2] ? "pipeline" : null), 245 }; 246 m = path.match(/^\/team\/([^/]+)\/(people|configure|team|notifications)$/); 247 if (m) return { 248 page: "team", 249 teamId: decodeURIComponent(m[1]), 250 view: m[2] === "configure" ? "integrations" : m[2], 251 }; 252 m = path.match(/^\/team\/([^/]+)$/); 253 if (m) return { page: "team", teamId: decodeURIComponent(m[1]) }; 254 return { page: "landing" }; 255} 256 257// Legacy hash deep-links (#privacy, #terms, #profile/TID/PID, #join/CODE, #login) 258// shipped in the first version of the site â translate to paths so the rest of 259// the routing layer doesn't have to think about hashes. Runs synchronously before 260// auth resolves, which is fine because we only rewrite the URL; actual navigation 261// happens inside renderRoute / onAuth. 262function migrateLegacyHash() { 263 const h = window.location.hash; 264 if (!h) return; 265 if (h === "#privacy") { history.replaceState(null, "", "/privacy"); return; } 266 if (h === "#terms") { history.replaceState(null, "", "/terms"); return; } 267 if (h.startsWith("#profile/")) { 268 const parts = h.slice("#profile/".length).split("/"); 269 const tid = decodeURIComponent(parts[0] || ""); 270 const pid = decodeURIComponent(parts[1] || ""); 271 if (tid && pid) { 272 history.replaceState(null, "", `/team/${encodeURIComponent(tid)}/profile/${encodeURIComponent(pid)}`); 273 } 274 return; 275 } 276 if (h.startsWith("#join/")) { 277 pendingJoinCode = h.slice("#join/".length); 278 history.replaceState(null, "", window.location.pathname); 279 return; 280 } 281 // #login and OAuth hash params (#access_token=...) are left for the existing 282 // handlers at onAuthStateChange / line 5717 to consume. 283} 284 285let _settingsReturn = { page: "dashboard" }; 286function goBackFromSettings() { 287 if (_settingsReturn && _settingsReturn.page === "team" && _settingsReturn.teamId) navigate("team", _settingsReturn.teamId); 288 else navigate("dashboard"); 289} 290 291function navigate(page, extra, opts = {}) { 292 // Reveal the body â the inline script in <head> hid it while we 293 // waited for Supabase to resolve the stored session. Once we've 294 // picked a page to show, no more flicker risk. 295 document.documentElement.classList.remove("auth-pending"); 296 // Remember where Settings was opened from so its back link returns there: 297 // a team/workspace page â that team; anywhere else â the dashboard (home). 298 if (page === "settings") { 299 const prevId = document.querySelector(".page.active")?.id || ""; 300 _settingsReturn = ((prevId === "page-team" || prevId === "page-entries" || prevId === "page-profile") && typeof currentTeamId !== "undefined" && currentTeamId) 301 ? { page: "team", teamId: currentTeamId } 302 : { page: "dashboard" }; 303 } 304 document.querySelectorAll(".page").forEach(p => p.classList.remove("active")); 305 const target = document.getElementById(`page-${page}`); 306 if (target) target.classList.add("active"); 307 308 document.body.classList.toggle("on-profile", page === "profile"); 309 // Clear CSV import status when leaving the team page 310 const csvStatus = document.getElementById("csv-status"); 311 if (csvStatus) csvStatus.innerHTML = ""; 312 313 // Sync the URL to reflect the page we just rendered. For profile we synthesize 314 // the extra from the globals openProfile already set (keeps every existing 315 // caller of navigate("profile") working unchanged). 316 if (!opts.skipHistory) { 317 let extraForPath = extra; 318 if (page === "profile" && !extra) {
319 extraForPath = { teamId: currentProfile_TeamId, profileId: currentProfile_Id }; 320 } 321 const targetPath = pathForPage(page, extraForPath); 322 const currentPath = window.location.pathname + window.location.search; 323 if (targetPath !== currentPath) { 324 if (opts.replace) history.replaceState({ page }, "", targetPath); 325 else history.pushState({ page }, "", targetPath); 326 } 327 } 328 329 if (page === "dashboard") loadDashboard(); 330 if (page === "team" && extra) { 331 if (typeof extra === "object" && extra.view && typeof _pendingTeamView !== "undefined") { 332 _pendingTeamView = extra.view; 333 // Switch the view NOW, not when the team data lands. loadTeamDetail is 334 // several round trips long and only set the view at the end of it, so 335 // #page-team came up showing whichever view you were last on â press 336 // People from Configure and you watched Configure for a second first. 337 if (typeof setTeamView === "function") setTeamView(extra.view, { syncUrl: false }); 338 } 339 loadTeamDetail(typeof extra === "string" ? extra : extra.teamId); 340 } 341 if (page === "settings") loadSettings(); 342 if (page === "oauth-consent") loadOAuthConsent(); 343 if (page === "profile") loadProfile(); 344 if (page === "review") { 345 if (extra && typeof extra === "object") { 346 if (extra.teamId) currentReview_TeamId = extra.teamId; 347 if (extra.integrationId) currentReview_IntegrationId = extra.integrationId; 348 currentReview_EventId = extra.eventId || null; 349 } 350 loadReview(); 351 } 352 if (page === "entries" && !opts.skipLoad) { 353 const tid = (extra && typeof extra === "object" ? extra.teamId : extra) || currentTeamId; 354 const lid = (extra && typeof extra === "object") ? extra.listId : null; 355 const view = (extra && typeof extra === "object") ? extra.view : null; 356 loadEntriesPage(tid, lid, view); 357 } 358 // Show/hide the workspace sidebar (team + pipeline pages) and offset content. 359 if (typeof updateWorkspaceChrome === "function") updateWorkspaceChrome(page); 360 window.scrollTo(0, 0); 361} 362 363// Resolve the current URL into a rendered page. Called on boot, on popstate, 364// and after auth completes (so a logged-in deep-link lands where it should). 365function renderRoute() { 366 const parsed = pageFromPath(window.location.pathname); 367 if (PUBLIC_PAGES.has(parsed.page)) { 368 navigate(parsed.page, undefined, { skipHistory: true }); 369 return; 370 } 371 if (!currentUser) { 372 // Stash intended destination so onAuth can restore it after sign-in. 373 // Skip `/` â that just means the user was on the home/landing page. 374 // Include the query string: extension deep-links carry ?name=<...> and 375 // losing it would blank out the profile page's name-based matching. 376 if (window.location.pathname !== "/") pendingPostAuthPath = window.location.pathname + window.location.search; 377 // Land the user on `/` (the canonical sign-in URL) â the login page 378 // shouldn't show under a /home or /team/X URL. Drop skipHistory 379 // so navigate() updates the address bar via replaceState. 380 navigate("landing", undefined, { replace: true }); 381 return; 382 } 383 if (parsed.page === "team") { 384 if (parsed.view && typeof _pendingTeamView !== "undefined") _pendingTeamView = parsed.view; 385 navigate("team", { teamId: parsed.teamId, view: parsed.view || null }, { skipHistory: true }); 386 // Returning from Stripe Checkout lands on /team/<id>?billing=⦠387 // skipHistory above leaves the query string intact for this to read. 388 if (typeof handleBillingReturn === "function") handleBillingReturn(parsed.teamId); 389 return; 390 } 391 if (parsed.page === "profile") { 392 currentProfile_TeamId = parsed.teamId; 393 currentProfile_Id = parsed.profileId; 394 // Deep-links from the extension carry ?name=<rendered LinkedIn name> so the 395 // gsheet RPC and Luma name-matching have a display name to work with. 396 // Without it those sections render empty even when matches exist. 397 const nameParam = new URLSearchParams(window.location.search).get("name"); 398 currentProfile_Name = nameParam ? nameParam.trim() || null : null; 399 // No people-list row context on a direct URL â clear so a stale 400 // preview from a previous click doesn't leak onto this profile. 401 currentProfile_ListRow = null; 402 navigate("profile", undefined, { skipHistory: true }); 403 return; 404 } 405 if (parsed.page === "review") { 406 currentReview_TeamId = parsed.teamId; 407 currentReview_IntegrationId = parsed.integrationId; 408 currentReview_EventId = parsed.eventId || null; 409 navigate("review", undefined, { skipHistory: true }); 410 return; 411 } 412 if (parsed.page === "entries") { 413 navigate("entries", { teamId: parsed.teamId, listId: parsed.listId, view: parsed.view }, { skipHistory: true }); 414 return; 415 } 416 navigate(parsed.page, undefined, { skipHistory: true }); 417} 418 419window.addEventListener("popstate", () => { renderRoute(); }); 420 421// The name that gets written into `who` and shown to teammates on every 422// outreach, view, comment and marking. 423//
424// NEVER the email address. That was the last fallback everywhere â a dozen 425// copies of `full_name || linkedin_name || currentUser.email` â so anyone 426// whose profile had no name broadcast their address to the whole team on 427// every profile they opened. An email is not a display name; it is contact 428// details, and the person it belongs to did not choose to publish it here. 429// 430// user_metadata is worth trying before giving up: Google and LinkedIn both 431// put a real name there at sign-up even when the profile row has not caught 432// up yet. 433function myWhoName() { 434 const meta = (typeof currentUser !== "undefined" && currentUser && currentUser.user_metadata) || {}; 435 const prof = (typeof currentProfile !== "undefined" && currentProfile) || {}; 436 return ( 437 (prof.full_name || "").trim() || 438 (prof.linkedin_name || "").trim() || 439 (meta.full_name || "").trim() || 440 (meta.name || "").trim() || 441 "Unknown" 442 ); 443}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.