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() + ); +};