diff --git a/companion/app/(tabs)/(event-types)/_layout.tsx b/companion/app/(tabs)/(event-types)/_layout.tsx
new file mode 100644
index 0000000000..92daf791ef
--- /dev/null
+++ b/companion/app/(tabs)/(event-types)/_layout.tsx
@@ -0,0 +1,9 @@
+import { Stack } from "expo-router";
+
+export default function EventTypesLayout() {
+ return (
+
+
+
+ );
+}
diff --git a/companion/app/(tabs)/event-types.tsx b/companion/app/(tabs)/(event-types)/index.tsx
similarity index 79%
rename from companion/app/(tabs)/event-types.tsx
rename to companion/app/(tabs)/(event-types)/index.tsx
index 51f0d881c3..7a2732b38b 100644
--- a/companion/app/(tabs)/event-types.tsx
+++ b/companion/app/(tabs)/(event-types)/index.tsx
@@ -1,10 +1,9 @@
import { Ionicons } from "@expo/vector-icons";
-import { useRouter } from "expo-router";
-import React, { useState, useEffect, useRef, useMemo } from "react";
+import { Stack, useRouter } from "expo-router";
+import React, { useState, useMemo, Activity } from "react";
import {
View,
Text,
- FlatList,
ScrollView,
TouchableOpacity,
RefreshControl,
@@ -13,30 +12,28 @@ import {
Share,
Alert,
Platform,
- Modal,
- KeyboardAvoidingView,
} from "react-native";
import * as Clipboard from "expo-clipboard";
-import Svg, { Path } from "react-native-svg";
-import { CalComAPIService, EventType } from "../../services/calcom";
-import { Header } from "../../components/Header";
-import { Tooltip } from "../../components/Tooltip";
-import { FullScreenModal } from "../../components/FullScreenModal";
-import { LoadingSpinner } from "../../components/LoadingSpinner";
-import { EmptyScreen } from "../../components/EmptyScreen";
-import { slugify } from "../../utils/slugify";
-import { showErrorAlert } from "../../utils/alerts";
-import { offlineAwareRefresh } from "../../utils/network";
-import { openInAppBrowser } from "../../utils/browser";
-import { formatDuration } from "../../components/event-type-detail/utils";
+import { CalComAPIService, EventType } from "../../../services/calcom";
+import { Header } from "../../../components/Header";
+import { FullScreenModal } from "../../../components/FullScreenModal";
+import { LoadingSpinner } from "../../../components/LoadingSpinner";
+import { EmptyScreen } from "../../../components/EmptyScreen";
+import { slugify } from "../../../utils/slugify";
+import { showErrorAlert } from "../../../utils/alerts";
+import { EventTypeListItem } from "../../../components/event-type-list-item/EventTypeListItem";
+import { offlineAwareRefresh } from "../../../utils/network";
+import { openInAppBrowser } from "../../../utils/browser";
import {
useEventTypes,
useCreateEventType,
useDeleteEventType,
useDuplicateEventType,
useUsername,
-} from "../../hooks";
+} from "../../../hooks";
+import { getEventDuration } from "../../../utils/getEventDuration";
+import { normalizeMarkdown } from "../../../utils/normalizeMarkdown";
export default function EventTypes() {
const router = useRouter();
@@ -126,49 +123,6 @@ export default function EventTypes() {
setSearchQuery(query);
};
- const getDuration = (eventType: EventType): number => {
- // Prefer lengthInMinutes (API field), fallback to length for backwards compatibility
- return eventType.lengthInMinutes ?? eventType.length ?? 0;
- };
-
- const normalizeMarkdown = (text: string): string => {
- if (!text) return "";
-
- return (
- text
- // Remove HTML tags including
,
,
, etc.
- .replace(/<[^>]*>/g, " ")
- // Convert HTML entities
- .replace(/&/g, "&")
- .replace(/</g, "<")
- .replace(/>/g, ">")
- .replace(/"/g, '"')
- .replace(/'/g, "'")
- .replace(/ /g, " ")
- // Convert markdown links [text](url) to just text
- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
- // Remove bold/italic markers **text** or *text*
- .replace(/\*\*([^*]+)\*\*/g, "$1")
- .replace(/\*([^*]+)\*/g, "$1")
- // Remove inline code `text`
- .replace(/`([^`]+)`/g, "$1")
- // Remove strikethrough ~~text~~
- .replace(/~~([^~]+)~~/g, "$1")
- // Remove heading markers # ## ###
- .replace(/^#{1,6}\s+/gm, "")
- // Remove blockquote markers >
- .replace(/^>\s+/gm, "")
- // Remove list markers - * +
- .replace(/^[\s]*[-*+]\s+/gm, "")
- // Remove numbered list markers 1. 2. etc
- .replace(/^[\s]*\d+\.\s+/gm, "")
- // Normalize multiple whitespace/newlines to single space
- .replace(/\s+/g, " ")
- // Trim whitespace
- .trim()
- );
- };
-
const handleEventTypePress = (eventType: EventType) => {
handleEdit(eventType);
};
@@ -251,7 +205,7 @@ export default function EventTypes() {
};
const handleEdit = (eventType: EventType) => {
- const duration = getDuration(eventType);
+ const duration = getEventDuration(eventType);
router.push({
pathname: "/event-type-detail",
params: {
@@ -308,7 +262,7 @@ export default function EventTypes() {
Alert.alert("Success", "Event type duplicated successfully");
}
- const duration = getDuration(eventType);
+ const duration = getEventDuration(eventType);
// Navigate to edit the newly created duplicate
router.push({
@@ -424,103 +378,12 @@ export default function EventTypes() {
);
};
- const renderEventType = ({ item, index }: { item: EventType; index: number }) => {
- const duration = getDuration(item);
- const isLast = index === filteredEventTypes.length - 1;
-
- return (
- handleEventTypePress(item)}
- onLongPress={() => handleEventTypeLongPress(item)}
- style={{ paddingHorizontal: 16, paddingVertical: 16 }}
- >
-
-
-
- {item.title}
-
- {item.description && (
-
- {normalizeMarkdown(item.description)}
-
- )}
-
-
-
- {formatDuration(duration)}
-
-
- {(item.price != null && item.price > 0) || item.requiresConfirmation ? (
-
- {item.price != null && item.price > 0 && (
-
- {item.currency || "$"}
- {item.price}
-
- )}
- {item.requiresConfirmation && (
-
- Requires Confirmation
-
- )}
-
- ) : null}
-
-
-
- handlePreview(item)}
- >
-
-
-
-
- handleCopyLink(item)}
- >
- {copiedEventTypeId === item.id ? (
-
- ) : (
-
- )}
-
-
-
- handleEventTypeLongPress(item)}
- >
-
-
-
-
-
-
- );
- };
-
if (loading) {
return (
-
+
+
+
@@ -531,7 +394,9 @@ export default function EventTypes() {
if (error) {
return (
-
+
+
+
@@ -549,7 +414,9 @@ export default function EventTypes() {
if (eventTypes.length === 0) {
return (
-
+
+
+
+
+
+
+
+
+
+ New
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+ <>
+ {
+ handleSearch(e.nativeEvent.text);
+ },
+ },
+ unstable_headerRightItems: () => [
+ {
+ type: "button",
+ label: "New",
+ labelStyle: {
+ // style if needed
+ },
+ variant: "prominent",
+ tintColor: "#000",
+ // icon: {
+ // name: "plus",
+ // type: "sfSymbol",
+ // },
+ onPress: handleCreateNew,
+ },
+ ],
+ }}
+ />
+
+
New
-
-
-
-
- );
- }
+
- return (
-
-
-
-
-
-
- New
-
-
}
showsVerticalScrollIndicator={false}
+ contentInsetAdjustmentBehavior="automatic"
>
{filteredEventTypes.map((item, index) => (
- {renderEventType({ item, index })}
+
))}
@@ -988,6 +910,6 @@ export default function EventTypes() {
)}
-
+ >
);
}
diff --git a/companion/app/(tabs)/_layout.tsx b/companion/app/(tabs)/_layout.tsx
index b34e40ae4d..70553ff161 100644
--- a/companion/app/(tabs)/_layout.tsx
+++ b/companion/app/(tabs)/_layout.tsx
@@ -12,7 +12,7 @@ export default function TabLayout() {
if (supportsLiquidGlass) {
return (
-
+
Event Types
diff --git a/companion/app/index.tsx b/companion/app/index.tsx
deleted file mode 100644
index b8113aefc7..0000000000
--- a/companion/app/index.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { Redirect } from "expo-router";
-
-export default function Index() {
- return ;
-}
diff --git a/companion/bun.lock b/companion/bun.lock
index bb6676d80d..78c5cf52b4 100644
--- a/companion/bun.lock
+++ b/companion/bun.lock
@@ -16,9 +16,10 @@
"base64-js": "^1.5.1",
"expo": "^55.0.0-canary-20251211-7da85ea",
"expo-auth-session": "7.0.11-canary-20251211-7da85ea",
- "expo-clipboard": "~8.0.8",
+ "expo-clipboard": "8.0.9-canary-20251211-7da85ea",
"expo-constants": "18.1.0-canary-20251211-7da85ea",
"expo-crypto": "15.0.9-canary-20251211-7da85ea",
+ "expo-dev-client": "6.1.0-canary-20251211-7da85ea",
"expo-device": "8.0.11-canary-20251211-7da85ea",
"expo-glass-effect": "0.2.0-canary-20251211-7da85ea",
"expo-linking": "8.0.11-canary-20251211-7da85ea",
@@ -633,6 +634,8 @@
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+ "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
+
"anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="],
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
@@ -933,12 +936,20 @@
"expo-auth-session": ["expo-auth-session@7.0.11-canary-20251211-7da85ea", "", { "dependencies": { "expo-application": "7.0.9-canary-20251211-7da85ea", "expo-constants": "18.1.0-canary-20251211-7da85ea", "expo-crypto": "15.0.9-canary-20251211-7da85ea", "expo-linking": "8.0.11-canary-20251211-7da85ea", "expo-web-browser": "15.0.11-canary-20251211-7da85ea", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-1LGdSc0Ekl4Z/BRepAD4qMxE1DsWdg/XW2KQXo43C2hjFYkrQS6hJFMufBrggbpjqAmqlODKORyoU6iPe1b2nQ=="],
- "expo-clipboard": ["expo-clipboard@8.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA=="],
+ "expo-clipboard": ["expo-clipboard@8.0.9-canary-20251211-7da85ea", "", { "peerDependencies": { "expo": "55.0.0-canary-20251211-7da85ea", "react": "*", "react-native": "*" } }, "sha512-AboouYUoShq4RpVueMZtnV5wXK4JIA8DbBhqIrZ8bAxIsVPu++oVjvSZWTbMNz3fkgBENJ0iUuHV2w2Tg7YfZQ=="],
"expo-constants": ["expo-constants@18.1.0-canary-20251211-7da85ea", "", { "dependencies": { "@expo/config": "12.0.13-canary-20251211-7da85ea", "@expo/env": "2.0.9-canary-20251211-7da85ea" }, "peerDependencies": { "expo": "55.0.0-canary-20251211-7da85ea", "react-native": "*" } }, "sha512-9K9+qBGaB9mQ8ae349s8+r20/DKvLPDs39LwMVhQcjhn5v9U+Pq8wvxEjPSMHoX7CnoG6juEC/nieXyw3fJ8xQ=="],
"expo-crypto": ["expo-crypto@15.0.9-canary-20251211-7da85ea", "", { "peerDependencies": { "expo": "55.0.0-canary-20251211-7da85ea" } }, "sha512-+5AlurZ6lpgTgVy1bUF/BuuLhIDXfx0+dmXyZ2arKdI6UWzsQ0+iLiqfxRlMYFNjLYXRbH4KqU8BUYWLjt3Icg=="],
+ "expo-dev-client": ["expo-dev-client@6.1.0-canary-20251211-7da85ea", "", { "dependencies": { "expo-dev-launcher": "6.1.0-canary-20251211-7da85ea", "expo-dev-menu": "7.1.0-canary-20251211-7da85ea", "expo-dev-menu-interface": "2.0.1-canary-20251211-7da85ea", "expo-manifests": "1.0.11-canary-20251211-7da85ea", "expo-updates-interface": "2.0.1-canary-20251211-7da85ea" }, "peerDependencies": { "expo": "55.0.0-canary-20251211-7da85ea" } }, "sha512-uKmsN1pR1yEqfxnRWl4hVI6QgwhYFD9UTPEFPz63b9z6jdfIcRvs1MrIeezOO3eTWkBLXi10DeFW1cmnfYcerQ=="],
+
+ "expo-dev-launcher": ["expo-dev-launcher@6.1.0-canary-20251211-7da85ea", "", { "dependencies": { "ajv": "^8.11.0", "expo-dev-menu": "7.1.0-canary-20251211-7da85ea", "expo-manifests": "1.0.11-canary-20251211-7da85ea" }, "peerDependencies": { "expo": "55.0.0-canary-20251211-7da85ea" } }, "sha512-ECb7DC8lqmLka/ZMzjvNoI9USVvTiMCuWK3l5auUjsB/zleKLw3mNv62XMk326Lwvd68MUvbSipWGhkbVPGKkg=="],
+
+ "expo-dev-menu": ["expo-dev-menu@7.1.0-canary-20251211-7da85ea", "", { "dependencies": { "expo-dev-menu-interface": "2.0.1-canary-20251211-7da85ea" }, "peerDependencies": { "expo": "55.0.0-canary-20251211-7da85ea" } }, "sha512-9axnKD+8UEFaTPRYqr0mlh7RuMa8dwruLQdYJwY2s7p9eNEjcQLoACiCfOnTqEAKa6u5tHAku5z3rS/928WJUQ=="],
+
+ "expo-dev-menu-interface": ["expo-dev-menu-interface@2.0.1-canary-20251211-7da85ea", "", { "peerDependencies": { "expo": "55.0.0-canary-20251211-7da85ea" } }, "sha512-upWPcYkXIqZe56njdLW7oTuPVHZtsYggb1xGgR07D1ioucm6l38d3qPhE9mVxuwGfmoy/tzck71DA7Txus8bmg=="],
+
"expo-device": ["expo-device@8.0.11-canary-20251211-7da85ea", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "55.0.0-canary-20251211-7da85ea" } }, "sha512-JI5UYYSN3c5YrGB42PQf9Wm/wWLpYJ9PfRLog5HwYJ7fWYLe9BOdmy56P77PsQUQ+rBkF4nQF2Qtfvms1TrMjQ=="],
"expo-file-system": ["expo-file-system@19.0.21-canary-20251211-7da85ea", "", { "peerDependencies": { "expo": "55.0.0-canary-20251211-7da85ea", "react-native": "*" } }, "sha512-EQ5ReoU8FBoKewUHmvdmczupAp1Lk9Q9HT/PHN3Gb0edayV/RK3lavQ/EcZgtS4ksnQFxF8dsLQIWHqHi93m7Q=="],
@@ -947,10 +958,14 @@
"expo-glass-effect": ["expo-glass-effect@0.2.0-canary-20251211-7da85ea", "", { "peerDependencies": { "expo": "55.0.0-canary-20251211-7da85ea", "react": "*", "react-native": "*" } }, "sha512-UJaKxrkdVcUSXTU5n4L2FX8pRntaF/7p6YeP3eO69eq1Vx19bxqAhfVU5BVqfVzF61NSqR4HbbnyTRcZjCnoOg=="],
+ "expo-json-utils": ["expo-json-utils@0.15.1-canary-20251211-7da85ea", "", {}, "sha512-0sJzmPF8aa8Pa2n6PdhBZjsVbfp7eJJKRavZb7glJzN33gHI9/7v2dGi0nN6jtgMNkiqhrwCnus6mL73Gcn1kg=="],
+
"expo-keep-awake": ["expo-keep-awake@15.0.9-canary-20251211-7da85ea", "", { "peerDependencies": { "expo": "55.0.0-canary-20251211-7da85ea", "react": "*" } }, "sha512-dTi8y5YWhGBBNOovKKfOfdEH5N2S36kS4EPy/flwXkr2v+yEm3e0FqIoV/yqh7VuzdngPfiIyoUbL7AVoI94nQ=="],
"expo-linking": ["expo-linking@8.0.11-canary-20251211-7da85ea", "", { "dependencies": { "expo-constants": "18.1.0-canary-20251211-7da85ea", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-E/m3I6936rnuw7epd5xWFklcwReaG06qMbEMI0Nb5suOQg1+DmAGpxxouH3RiJ/0Fp6NgzPbqe9YDVz6giLt9Q=="],
+ "expo-manifests": ["expo-manifests@1.0.11-canary-20251211-7da85ea", "", { "dependencies": { "@expo/config": "12.0.13-canary-20251211-7da85ea", "expo-json-utils": "0.15.1-canary-20251211-7da85ea" }, "peerDependencies": { "expo": "55.0.0-canary-20251211-7da85ea" } }, "sha512-9rgYSPiKXoS1kVVqFLGF10Um8TMEldkIE9CZDStUizR48x3nB0E7MLvBDmN7D+O3M5dRebuM0PMy9XNjU2PTbg=="],
+
"expo-modules-autolinking": ["expo-modules-autolinking@3.1.0-canary-20251211-7da85ea", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-ec+23BQqq+X8Nqt8iBSyxdF3dyP1SmsFQGob1NUaWXuSAf2z0QDZ8apbuwmUq7hx5+8ZSKO8G/XKCFsvYxZjhA=="],
"expo-modules-core": ["expo-modules-core@4.0.0-canary-20251211-7da85ea", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-XWrGIXNDyknXlbY8+YfIILeDt444lxJ4i5qVh600xtoKK5PNDBpIS2XPuGCGsm8vcMmTTvdrOt1SOIZTlE+q/g=="],
@@ -965,6 +980,8 @@
"expo-symbols": ["expo-symbols@1.1.0-canary-20251211-7da85ea", "", { "dependencies": { "sf-symbols-typescript": "^2.0.0" }, "peerDependencies": { "@expo-google-fonts/material-symbols": "^0.4.1", "expo": "55.0.0-canary-20251211-7da85ea", "expo-font": "14.1.0-canary-20251211-7da85ea", "react": "*", "react-native": "*" } }, "sha512-E1YiwLl/ZUYF4OibsJeboyOJ7c+vWmDOKodtNp4maustSfooSN2INw0iv2NSWGqIsHP7p4NTtd4Hmkv8V3VKow=="],
+ "expo-updates-interface": ["expo-updates-interface@2.0.1-canary-20251211-7da85ea", "", { "peerDependencies": { "expo": "55.0.0-canary-20251211-7da85ea" } }, "sha512-apAcXjLigov0e3D/oHEnABUvd1cHD1JsujN/cLM3pcY3qlZAT3cJDSXA67YR4IiDd8MdxxXd3eGUo3w4Y4uqpQ=="],
+
"expo-web-browser": ["expo-web-browser@15.0.11-canary-20251211-7da85ea", "", { "peerDependencies": { "expo": "55.0.0-canary-20251211-7da85ea", "react-native": "*" } }, "sha512-d845VCB+u/sSo2ZQJg+CJDmmrCkHEdjBMuw2rfxTEKiYcw7WWmISJ8BRal2VyV/lDU61tJxR2lxxLNAtjHbEQA=="],
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
@@ -979,6 +996,8 @@
"fast-redact": ["fast-redact@3.5.0", "", {}, "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A=="],
+ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
+
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
"fb-dotslash": ["fb-dotslash@0.5.8", "", { "bin": { "dotslash": "bin/dotslash" } }, "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA=="],
@@ -1199,6 +1218,8 @@
"json-parse-even-better-errors": ["json-parse-even-better-errors@3.0.2", "", {}, "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ=="],
+ "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
+
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
diff --git a/companion/components/event-type-list-item/EventTypeListItem.ios.tsx b/companion/components/event-type-list-item/EventTypeListItem.ios.tsx
new file mode 100644
index 0000000000..d07f8edabe
--- /dev/null
+++ b/companion/components/event-type-list-item/EventTypeListItem.ios.tsx
@@ -0,0 +1,165 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { View, Text, TouchableOpacity, Pressable } from "react-native";
+import Svg, { Path } from "react-native-svg";
+import { Host, ContextMenu, Button, Image, Label, HStack } from "@expo/ui/swift-ui";
+
+import { Tooltip } from "../../components/Tooltip";
+import {
+ background,
+ buttonStyle,
+ clipShape,
+ fixedSize,
+ foregroundStyle,
+ frame,
+ padding,
+ shapes,
+} from "@expo/ui/swift-ui/modifiers";
+import { EventTypeListItemProps } from "./types";
+import { isLiquidGlassAvailable } from "expo-glass-effect";
+import { getEventDuration } from "../../utils/getEventDuration";
+import { normalizeMarkdown } from "../../utils/normalizeMarkdown";
+
+export const EventTypeListItem = ({
+ item,
+ index,
+ filteredEventTypes,
+ copiedEventTypeId,
+ handleEventTypePress,
+ handleEventTypeLongPress,
+ handleCopyLink,
+ handlePreview,
+ onEdit,
+ onDuplicate,
+ onDelete,
+}: EventTypeListItemProps) => {
+ const duration = getEventDuration(item);
+ const isLast = index === filteredEventTypes.length - 1;
+
+ const eventTypes: {
+ label: string;
+ icon: any;
+ onPress: () => void;
+ role: "default" | "destructive";
+ }[] = [
+ {
+ label: "Preview",
+ icon: "arrow.up.right.square",
+ onPress: () => handlePreview(item),
+ role: "default",
+ },
+ {
+ label: "Copy link",
+ icon: "link",
+ onPress: () => handleCopyLink(item),
+ role: "default",
+ },
+ {
+ label: "Edit",
+ icon: "pencil",
+ onPress: () => onEdit(item),
+ role: "default",
+ },
+ {
+ label: "Duplicate",
+ icon: "square.on.square",
+ onPress: () => onDuplicate(item),
+ role: "default",
+ },
+ {
+ label: "Delete",
+ icon: "trash",
+ onPress: () => onDelete(item),
+ role: "destructive",
+ },
+ ];
+
+ const formatDuration = (minutes: number | undefined) => {
+ if (!minutes || minutes <= 0) {
+ return "0m";
+ }
+ if (minutes < 60) {
+ return `${minutes}m`;
+ }
+ const hours = Math.floor(minutes / 60);
+ const remainingMinutes = minutes % 60;
+ return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
+ };
+
+ return (
+
+
+ handleEventTypePress(item)}
+ // No need for long press on iOS
+ // onLongPress={() => handleEventTypeLongPress(item)}
+ style={{ paddingHorizontal: 16, paddingVertical: 16 }}
+ className="flex-grow"
+ >
+
+
+ {item.title}
+
+ {item.description && (
+
+ {normalizeMarkdown(item.description)}
+
+ )}
+
+
+
+ {formatDuration(duration)}
+
+
+ {(item.price != null && item.price > 0) || item.requiresConfirmation ? (
+
+ {item.price != null && item.price > 0 && (
+
+ {item.currency || "$"}
+ {item.price}
+
+ )}
+ {item.requiresConfirmation && (
+
+ Requires Confirmation
+
+ )}
+
+ ) : null}
+
+
+
+
+
+
+ {eventTypes.map((eventType) => (
+
+ ))}
+
+
+ {/* */}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/companion/components/event-type-list-item/EventTypeListItem.tsx b/companion/components/event-type-list-item/EventTypeListItem.tsx
new file mode 100644
index 0000000000..3bf3491869
--- /dev/null
+++ b/companion/components/event-type-list-item/EventTypeListItem.tsx
@@ -0,0 +1,123 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { View, Text, TouchableOpacity } from "react-native";
+import Svg, { Path } from "react-native-svg";
+
+import { Tooltip } from "../../components/Tooltip";
+import { EventTypeListItemProps } from "./types";
+import { getEventDuration } from "../../utils/getEventDuration";
+import { normalizeMarkdown } from "../../utils/normalizeMarkdown";
+
+export const EventTypeListItem = ({
+ item,
+ index,
+ filteredEventTypes,
+ copiedEventTypeId,
+ handleEventTypePress,
+ handleEventTypeLongPress,
+ handleCopyLink,
+ handlePreview,
+}: EventTypeListItemProps) => {
+ const duration = getEventDuration(item);
+ const isLast = index === filteredEventTypes.length - 1;
+
+ const formatDuration = (minutes: number | undefined) => {
+ if (!minutes || minutes <= 0) {
+ return "0m";
+ }
+ if (minutes < 60) {
+ return `${minutes}m`;
+ }
+ const hours = Math.floor(minutes / 60);
+ const remainingMinutes = minutes % 60;
+ return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
+ };
+
+ return (
+ handleEventTypePress(item)}
+ onLongPress={() => handleEventTypeLongPress(item)}
+ style={{ paddingHorizontal: 16, paddingVertical: 16 }}
+ >
+
+
+
+ {item.title}
+
+ {item.description && (
+
+ {normalizeMarkdown(item.description)}
+
+ )}
+
+
+
+ {formatDuration(duration)}
+
+
+ {(item.price != null && item.price > 0) || item.requiresConfirmation ? (
+
+ {item.price != null && item.price > 0 && (
+
+ {item.currency || "$"}
+ {item.price}
+
+ )}
+ {item.requiresConfirmation && (
+
+ Requires Confirmation
+
+ )}
+
+ ) : null}
+
+
+
+ handlePreview(item)}
+ >
+
+
+
+
+ handleCopyLink(item)}
+ >
+ {copiedEventTypeId === item.id ? (
+
+ ) : (
+
+ )}
+
+
+
+ handleEventTypeLongPress(item)}
+ >
+
+
+
+
+
+
+ );
+};
diff --git a/companion/components/event-type-list-item/types.ts b/companion/components/event-type-list-item/types.ts
new file mode 100644
index 0000000000..e71980dd57
--- /dev/null
+++ b/companion/components/event-type-list-item/types.ts
@@ -0,0 +1,15 @@
+import { EventType } from "../../services/types/event-types.types";
+
+export interface EventTypeListItemProps {
+ item: EventType;
+ index: number;
+ filteredEventTypes: EventType[];
+ copiedEventTypeId: number | null;
+ handleEventTypePress: (item: EventType) => void;
+ handleEventTypeLongPress: (item: EventType) => void;
+ handleCopyLink: (item: EventType) => void;
+ handlePreview: (item: EventType) => void;
+ onEdit?: (item: EventType) => void;
+ onDuplicate?: (item: EventType) => void;
+ onDelete?: (item: EventType) => void;
+}
diff --git a/companion/package.json b/companion/package.json
index fee39df45c..c7ec2deaff 100644
--- a/companion/package.json
+++ b/companion/package.json
@@ -31,6 +31,7 @@
"expo-clipboard": "8.0.9-canary-20251211-7da85ea",
"expo-constants": "18.1.0-canary-20251211-7da85ea",
"expo-crypto": "15.0.9-canary-20251211-7da85ea",
+ "expo-dev-client": "6.1.0-canary-20251211-7da85ea",
"expo-device": "8.0.11-canary-20251211-7da85ea",
"expo-glass-effect": "0.2.0-canary-20251211-7da85ea",
"expo-linking": "8.0.11-canary-20251211-7da85ea",
diff --git a/companion/utils/getEventDuration.ts b/companion/utils/getEventDuration.ts
new file mode 100644
index 0000000000..8ea961a230
--- /dev/null
+++ b/companion/utils/getEventDuration.ts
@@ -0,0 +1,6 @@
+import { EventType } from "../hooks";
+
+export const getEventDuration = (eventType: EventType): number => {
+ // Prefer lengthInMinutes (API field), fallback to length for backwards compatibility
+ return eventType.lengthInMinutes ?? eventType.length ?? 0;
+};
diff --git a/companion/utils/normalizeMarkdown.ts b/companion/utils/normalizeMarkdown.ts
new file mode 100644
index 0000000000..beff445ee8
--- /dev/null
+++ b/companion/utils/normalizeMarkdown.ts
@@ -0,0 +1,37 @@
+export const normalizeMarkdown = (text: string): string => {
+ if (!text) return "";
+
+ return (
+ text
+ // Remove HTML tags including
, ,
, etc.
+ .replace(/<[^>]*>/g, " ")
+ // Convert HTML entities
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/ /g, " ")
+ // Convert markdown links [text](url) to just text
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
+ // Remove bold/italic markers **text** or *text*
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
+ .replace(/\*([^*]+)\*/g, "$1")
+ // Remove inline code `text`
+ .replace(/`([^`]+)`/g, "$1")
+ // Remove strikethrough ~~text~~
+ .replace(/~~([^~]+)~~/g, "$1")
+ // Remove heading markers # ## ###
+ .replace(/^#{1,6}\s+/gm, "")
+ // Remove blockquote markers >
+ .replace(/^>\s+/gm, "")
+ // Remove list markers - * +
+ .replace(/^[\s]*[-*+]\s+/gm, "")
+ // Remove numbered list markers 1. 2. etc
+ .replace(/^[\s]*\d+\.\s+/gm, "")
+ // Normalize multiple whitespace/newlines to single space
+ .replace(/\s+/g, " ")
+ // Trim whitespace
+ .trim()
+ );
+};