{
const region = await getStoredRegion();
- return apiBaseUrlForRegion(region);
+ return `${getCalApiUrl(region)}/v2`;
}
const tokenOperationTimestamps: number[] = [];
@@ -934,7 +932,7 @@ async function validateTokens(tokens: OAuthTokens, region?: "us" | "eu"): Promis
}
try {
- const apiBaseUrl = region ? apiBaseUrlForRegion(region) : await getApiBaseUrl();
+ const apiBaseUrl = region ? `${getCalApiUrl(region)}/v2` : await getApiBaseUrl();
const response = await fetchWithTimeout(`${apiBaseUrl}/me`, {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
diff --git a/apps/extension/entrypoints/content.ts b/apps/extension/entrypoints/content.ts
index e1abcc94..347b7ae7 100644
--- a/apps/extension/entrypoints/content.ts
+++ b/apps/extension/entrypoints/content.ts
@@ -1,6 +1,7 @@
///
import { initGoogleCalendarIntegration } from "../lib/google-calendar";
import { initLinkedInIntegration } from "../lib/linkedin";
+import { getCalAppUrl, getCalWebUrl } from "../lib/region";
import { escapeHtml } from "../lib/utils";
/**
@@ -1091,7 +1092,7 @@ export default defineContentScript({
e.stopPropagation();
const bookingUrl =
eventType.bookingUrl ||
- `https://cal.com/${
+ `${getCalWebUrl()}/${
eventType.users?.[0]?.username || "user"
}/${eventType.slug}`;
window.open(bookingUrl, "_blank");
@@ -1135,7 +1136,7 @@ export default defineContentScript({
// Copy to clipboard
const bookingUrl =
eventType.bookingUrl ||
- `https://cal.com/${
+ `${getCalWebUrl()}/${
eventType.users?.[0]?.username || "user"
}/${eventType.slug}`;
navigator.clipboard
@@ -1197,7 +1198,7 @@ export default defineContentScript({
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
- const editUrl = `https://app.cal.com/event-types/${eventType.id}`;
+ const editUrl = `${getCalAppUrl()}/event-types/${eventType.id}`;
window.open(editUrl, "_blank");
});
editBtn.addEventListener("mouseenter", () => {
@@ -1308,7 +1309,7 @@ export default defineContentScript({
// Construct the Cal.com booking link
const bookingUrl =
eventType.bookingUrl ||
- `https://cal.com/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
+ `${getCalWebUrl()}/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
// Try to insert at cursor position in the compose field
const inserted = insertTextAtCursor(bookingUrl);
@@ -1336,7 +1337,7 @@ export default defineContentScript({
// Construct the Cal.com booking link
const bookingUrl =
eventType.bookingUrl ||
- `https://cal.com/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
+ `${getCalWebUrl()}/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
// Try to insert at cursor position in the compose field
const inserted = insertTextAtCursor(bookingUrl);
@@ -1840,7 +1841,7 @@ export default defineContentScript({
e.stopPropagation();
const bookingUrl =
eventType.bookingUrl ||
- `https://cal.com/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
+ `${getCalWebUrl()}/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
window.open(bookingUrl, "_blank");
});
previewBtn.addEventListener("mouseenter", () => {
@@ -1882,7 +1883,7 @@ export default defineContentScript({
// Copy to clipboard
const bookingUrl =
eventType.bookingUrl ||
- `https://cal.com/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
+ `${getCalWebUrl()}/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
navigator.clipboard
.writeText(bookingUrl)
.then(() => {
@@ -1942,7 +1943,7 @@ export default defineContentScript({
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
- const editUrl = `https://app.cal.com/event-types/${eventType.id}`;
+ const editUrl = `${getCalAppUrl()}/event-types/${eventType.id}`;
window.open(editUrl, "_blank");
});
editBtn.addEventListener("mouseenter", () => {
@@ -2053,7 +2054,7 @@ export default defineContentScript({
// Construct the Cal.com booking link
const bookingUrl =
eventType.bookingUrl ||
- `https://cal.com/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
+ `${getCalWebUrl()}/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
// Try to insert at cursor position in the message field
const inserted = insertTextAtCursor(bookingUrl);
@@ -2242,7 +2243,7 @@ export default defineContentScript({
.map((slot) => {
// URL-encode the timezone to handle special characters like "/"
const encodedTimezone = encodeURIComponent(timezone);
- const bookingURL = `https://cal.com/${username}/${eventType.slug}?duration=${duration}&date=${slot.isoDate}&slot=${slot.isoTimestamp}&cal.tz=${encodedTimezone}`;
+ const bookingURL = `${getCalWebUrl()}/${username}/${eventType.slug}?duration=${duration}&date=${slot.isoDate}&slot=${slot.isoTimestamp}&cal.tz=${encodedTimezone}`;
return `
@@ -2306,7 +2307,7 @@ export default defineContentScript({
${datesHTML}
-
See all available times →
@@ -3416,7 +3417,7 @@ export default defineContentScript({
const username =
eventTypes.length > 0 ? eventTypes[0].users?.[0]?.username || "user" : "user";
- const createUrl = `https://app.cal.com/event-types?dialog=new&eventPage=${username}`;
+ const createUrl = `${getCalAppUrl()}/event-types?dialog=new&eventPage=${username}`;
window.open(createUrl, "_blank");
showGmailNotification(
`Opening Cal.com to create ${parsedData.detectedDuration}min event type`,
@@ -3694,7 +3695,7 @@ export default defineContentScript({
const selectedUsername = selectedEventType.users?.[0]?.username || "user";
// Generate Cal.com URL with slot parameters
- const baseUrl = `https://cal.com/${selectedUsername}/${selectedSlug}`;
+ const baseUrl = `${getCalWebUrl()}/${selectedUsername}/${selectedSlug}`;
const params = new URLSearchParams({
overlayCalendar: "true",
date: slot.isoDate,
diff --git a/apps/extension/lib/google-calendar.ts b/apps/extension/lib/google-calendar.ts
index c528cb10..ead8d8b9 100644
--- a/apps/extension/lib/google-calendar.ts
+++ b/apps/extension/lib/google-calendar.ts
@@ -1,4 +1,5 @@
///
+import { CAL_WEB_HOSTNAMES } from "./region";
// Google Calendar integration: inject a no-show toggle next to attendees in event popups.
const bookingStatusCache = new Map; timestamp: number }>();
@@ -266,11 +267,12 @@ function injectStyles(): void {
}
/**
- * Extract booking UID from event description
- * Looks for patterns like: https://cal.com/booking/{uid} or https://app.cal.com/booking/{uid}
+ * Extract booking UID from event description.
+ * Matches both regions: https://cal.{com|eu}/booking/{uid} and
+ * https://app.cal.{com|eu}/booking/{uid}.
*/
function extractBookingUid(text: string): string | null {
- const regex = /https:\/\/(?:app\.)?cal\.com\/booking\/([a-zA-Z0-9]+)/;
+ const regex = /https:\/\/(?:app\.)?cal\.(?:com|eu)\/booking\/([a-zA-Z0-9]+)/;
const match = text.match(regex);
return match ? match[1] : null;
}
@@ -1199,10 +1201,10 @@ function observeEventPopups(): void {
}
const text = element.textContent || "";
- // Check if this looks like a Cal.com event
+ const lowerText = text.toLowerCase();
+ // Check if this looks like a Cal.com event (case-insensitive across both regions)
const isCalComEvent =
- text.includes("cal.com") ||
- text.includes("Cal.com") ||
+ CAL_WEB_HOSTNAMES.some((host) => lowerText.includes(host)) ||
text.includes("booking") ||
extractBookingUid(text) !== null;
@@ -1354,11 +1356,11 @@ function observeEventPopups(): void {
return;
}
- // Check if this is a Cal.com event
+ // Check if this is a Cal.com event (case-insensitive across both regions)
const text = popup.textContent || "";
+ const lowerText = text.toLowerCase();
const isCalComEvent =
- text.includes("cal.com") ||
- text.includes("Cal.com") ||
+ CAL_WEB_HOSTNAMES.some((host) => lowerText.includes(host)) ||
text.includes("booking") ||
extractBookingUid(text) !== null;
diff --git a/apps/extension/lib/linkedin.ts b/apps/extension/lib/linkedin.ts
index 6f5b103a..fe12cd3c 100644
--- a/apps/extension/lib/linkedin.ts
+++ b/apps/extension/lib/linkedin.ts
@@ -1,4 +1,5 @@
///
+import { getCalAppUrl, getCalWebUrl } from "./region";
import { escapeHtml } from "./utils";
// LinkedIn integration: inject a Cal.com scheduling button in LinkedIn messaging
@@ -617,7 +618,7 @@ export function initLinkedInIntegration() {
const editBtn = createActionButton(SVG_ICONS.EDIT, "Edit", "0 6px 6px 0", tooltipsToCleanup);
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
- const editUrl = `https://app.cal.com/event-types/${eventType.id}`;
+ const editUrl = `${getCalAppUrl()}/event-types/${eventType.id}`;
window.open(editUrl, "_blank");
});
@@ -668,7 +669,7 @@ export function initLinkedInIntegration() {
function buildBookingUrl(eventType: EventType): string {
return (
eventType.bookingUrl ||
- `https://cal.com/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`
+ `${getCalWebUrl()}/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`
);
}
diff --git a/apps/extension/lib/region.ts b/apps/extension/lib/region.ts
new file mode 100644
index 00000000..3d0094fd
--- /dev/null
+++ b/apps/extension/lib/region.ts
@@ -0,0 +1,138 @@
+/**
+ * Cal.com data region helpers for the Chrome extension.
+ *
+ * Mirrors `apps/mobile/utils/region.ts` but reads from
+ * `chrome.storage.local["cal_region"]` — the same key the background worker
+ * writes via the `sync-oauth-tokens` handler
+ * (`apps/extension/entrypoints/background/index.ts:26`).
+ *
+ * Region is cached synchronously in a module-level variable so content-script
+ * callsites can build URLs without an `await`. The cache is initialized on
+ * module import via the fire-and-forget `preloadRegion()` and kept in sync
+ * thereafter by a `chrome.storage.onChanged` listener.
+ *
+ * NEVER inline literal `cal.com` / `cal.eu` hostnames elsewhere in
+ * `apps/extension/**`. The CI grep `check:no-cal-hostnames` enforces this.
+ */
+
+export type CalRegion = "us" | "eu";
+
+const REGION_STORAGE_KEY = "cal_region";
+const DEFAULT_REGION: CalRegion = "us";
+
+let currentRegion: CalRegion = DEFAULT_REGION;
+let preloaded = false;
+
+function isValidRegion(value: unknown): value is CalRegion {
+ return value === "us" || value === "eu";
+}
+
+function isChromeStorageAvailable(): boolean {
+ return (
+ typeof chrome !== "undefined" &&
+ typeof chrome.storage !== "undefined" &&
+ typeof chrome.storage.local !== "undefined"
+ );
+}
+
+/**
+ * Load the persisted region from chrome.storage.local. Idempotent — subsequent
+ * calls re-read storage but do not re-register listeners. Best-effort: on
+ * storage failure the cache stays at its current value (default US for the
+ * first call, last-known otherwise).
+ */
+export async function preloadRegion(): Promise {
+ if (!isChromeStorageAvailable()) {
+ preloaded = true;
+ return currentRegion;
+ }
+ try {
+ const result = await chrome.storage.local.get([REGION_STORAGE_KEY]);
+ if (isValidRegion(result[REGION_STORAGE_KEY])) {
+ currentRegion = result[REGION_STORAGE_KEY];
+ }
+ } catch {
+ // Best-effort; stay on default.
+ }
+ preloaded = true;
+ return currentRegion;
+}
+
+if (
+ typeof chrome !== "undefined" &&
+ chrome.storage?.onChanged &&
+ typeof chrome.storage.onChanged.addListener === "function"
+) {
+ chrome.storage.onChanged.addListener((changes, areaName) => {
+ if (areaName !== "local") return;
+ if (!changes[REGION_STORAGE_KEY]) return;
+ const next = changes[REGION_STORAGE_KEY].newValue;
+ if (isValidRegion(next)) {
+ currentRegion = next;
+ } else if (next === undefined) {
+ currentRegion = DEFAULT_REGION;
+ }
+ });
+}
+
+// Kick off the initial read on import. Fire-and-forget — the helpers below are
+// safe to call before this resolves (they fall back to the default).
+preloadRegion();
+
+export function getRegion(): CalRegion {
+ return currentRegion;
+}
+
+export function isRegionPreloaded(): boolean {
+ return preloaded;
+}
+
+/**
+ * `https://app.cal.{com|eu}` — Cal.com web app origin for the current region.
+ *
+ * Note: the very first synchronous call after extension load may return the
+ * US default before `preloadRegion()` resolves. In practice this is benign:
+ * content scripts only construct URLs in user-event handlers, by which time
+ * the async preload has completed.
+ */
+export function getCalAppUrl(region: CalRegion = currentRegion): string {
+ return region === "eu" ? "https://app.cal.eu" : "https://app.cal.com";
+}
+
+/**
+ * `https://api.cal.{com|eu}` — Cal.com API origin for the current region.
+ * Same first-call caveat as `getCalAppUrl()`.
+ */
+export function getCalApiUrl(region: CalRegion = currentRegion): string {
+ return region === "eu" ? "https://api.cal.eu" : "https://api.cal.com";
+}
+
+/**
+ * `https://cal.{com|eu}` — Cal.com booking-page origin for the current region.
+ * Same first-call caveat as `getCalAppUrl()`.
+ */
+export function getCalWebUrl(region: CalRegion = currentRegion): string {
+ return region === "eu" ? "https://cal.eu" : "https://cal.com";
+}
+
+/**
+ * Cal.com Framer marketing landing for the "open extension" deep link. Stays
+ * global — the Framer-hosted marketing site does not have an EU mirror.
+ * Same precedent as `getCalSupportUrl()` / `getCalHelpUrl()` in mobile's
+ * region module.
+ */
+export function getCalMarketingAppUrl(): string {
+ return "https://cal.com/app?openExtension=true";
+}
+
+/**
+ * Hostnames the extension recognizes as Cal.com booking surfaces across both
+ * regions. Use for regex / `text.includes(...)` host detection. Do NOT use to
+ * build outbound URLs (use the getters above).
+ */
+export const CAL_WEB_HOSTNAMES: ReadonlyArray = [
+ "cal.com",
+ "cal.eu",
+ "app.cal.com",
+ "app.cal.eu",
+];
diff --git a/apps/mobile/scripts/check-no-cal-hostnames.sh b/apps/mobile/scripts/check-no-cal-hostnames.sh
index 6e8fa059..297ffbc9 100755
--- a/apps/mobile/scripts/check-no-cal-hostnames.sh
+++ b/apps/mobile/scripts/check-no-cal-hostnames.sh
@@ -1,35 +1,48 @@
#!/usr/bin/env bash
-# Fail if any file under apps/mobile inlines a literal `cal.com` or `cal.eu`
-# hostname in code. Region-aware construction must go through the helpers in
-# `apps/mobile/utils/region.ts` so EU users get routed to `app.cal.eu` /
-# `api.cal.eu` / `cal.eu`.
+# Fail if any file under apps/mobile or apps/extension inlines a literal
+# `cal.com` / `cal.eu` hostname in code. Region-aware construction must go
+# through the helpers in:
+# - apps/mobile/utils/region.ts (mobile + web build)
+# - apps/extension/lib/region.ts (Chrome extension)
#
-# Scope: scans the entire `apps/mobile` tree. `rg` honors `.gitignore` so
-# `node_modules/`, build artifacts, and other ignored paths are skipped
-# automatically — no manual directory list to drift out of sync.
+# Scope: scans both `apps/mobile` and `apps/extension`. `rg` honors
+# `.gitignore` so build artifacts and `node_modules/` are skipped.
#
# Filters applied on top of the raw matches:
-# - Word boundaries in `PATTERN` so incidental substrings like
-# `group.com.cal.companion` (an iOS app-group id) are not flagged.
-# - Lines whose content starts with `//`, `*`, or `/*` are filtered, since
-# documentation prose / docstring @example URLs don't cause runtime bugs.
-# - `ALLOWLIST` exempts files that legitimately reference the bare hostnames:
-# the region helpers themselves, the env template, the hostname match-set
-# used for video-call URL detection, and this script.
+# - PATTERN uses word boundaries so incidental substrings like
+# `group.com.cal.companion` (iOS app-group id) and `companion.cal.com`
+# (extension OAuth/iframe origin) ARE captured by the pattern and then
+# filtered out below — see CONTENT_ALLOWLIST.
+# - FILE_ALLOWLIST exempts files that legitimately reference Cal hostnames:
+# the region helpers, env templates, manifests / build configs that must
+# stay static, and the hostname match-sets used for detection.
+# - CONTENT_ALLOWLIST exempts the `companion.cal.com` token (the extension's
+# OAuth landing / iframe origin — it matches the pattern but is not a Cal
+# app URL).
+# - Comment lines (starting with //, *, /*) are filtered.
set -euo pipefail
-cd "$(dirname "$0")/.."
+cd "$(dirname "$0")/../../.." # repo root
-PATTERN='\bcal\.(com|eu)\b'
-ALLOWLIST='^(\./)?(utils/region\.ts|utils/booking\.ts|\.env\.example|scripts/check-no-cal-hostnames\.sh):'
+PATTERN='\b(app\.)?cal\.(com|eu)\b'
-raw=$(rg -n --no-heading "$PATTERN" . 2>/dev/null || true)
-hits=$(echo "$raw" | grep -Ev "$ALLOWLIST" | grep -Ev '^[^:]+:[0-9]+:[[:space:]]*(//|\*|/\*)' || true)
+FILE_ALLOWLIST='^(apps/mobile/utils/region\.ts|apps/mobile/utils/booking\.ts|apps/mobile/\.env\.example|apps/mobile/scripts/check-no-cal-hostnames\.sh|apps/extension/lib/region\.ts|apps/extension/\.env\.example|apps/extension/wxt\.config\.ts|apps/extension/public/manifest\.json):'
+
+# Match-anywhere allowlist for tokens that aren't Cal app URLs but happen to
+# contain a substring matching PATTERN.
+CONTENT_ALLOWLIST='companion\.cal\.com'
+
+raw=$(rg -n --no-heading "$PATTERN" apps/mobile apps/extension 2>/dev/null || true)
+hits=$(echo "$raw" \
+ | grep -Ev "$FILE_ALLOWLIST" \
+ | grep -Ev "$CONTENT_ALLOWLIST" \
+ | grep -Ev '^[^:]+:[0-9]+:[[:space:]]*(//|\*|/\*)' \
+ || true)
if [[ -n "$hits" ]]; then
{
- echo "Hardcoded Cal hostname found. Use helpers from utils/region.ts:"
- echo " getCalAppUrl(), getCalApiUrl(), getCalWebUrl()"
- echo " getCalSupportUrl(), getCalHelpUrl(slug)"
+ echo "Hardcoded Cal hostname found. Use region helpers:"
+ echo " apps/mobile/**: import from utils/region.ts"
+ echo " apps/extension/**: import from lib/region.ts"
echo ""
echo "$hits"
} >&2
|