feat(companion): Event types screen update (#25847)

This commit is contained in:
Beto
2025-12-14 20:59:48 +01:00
committed by GitHub
parent e9c3d7e40c
commit 35cf827cb1
11 changed files with 495 additions and 201 deletions
@@ -0,0 +1,9 @@
import { Stack } from "expo-router";
export default function EventTypesLayout() {
return (
<Stack>
<Stack.Screen name="event-types" options={{}} />
</Stack>
);
}
@@ -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 <br>, <div>, <p>, etc.
.replace(/<[^>]*>/g, " ")
// Convert HTML entities
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/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 (
<TouchableOpacity
className={`bg-white active:bg-[#F8F9FA] ${!isLast ? "border-b border-[#E5E5EA]" : ""}`}
onPress={() => handleEventTypePress(item)}
onLongPress={() => handleEventTypeLongPress(item)}
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
>
<View className="flex-row items-center justify-between">
<View className="mr-4 flex-1">
<View className="mb-1 flex-row items-center">
<Text className="flex-1 text-base font-semibold text-[#333]">{item.title}</Text>
</View>
{item.description && (
<Text className="mb-2 mt-0.5 text-sm leading-5 text-[#666]" numberOfLines={2}>
{normalizeMarkdown(item.description)}
</Text>
)}
<View className="mt-2 flex-row items-center self-start rounded-lg border border-[#E5E5EA] bg-[#E5E5EA] px-2 py-1">
<Ionicons name="time-outline" size={14} color="#000" />
<Text className="ml-1.5 text-xs font-semibold text-black">
{formatDuration(duration)}
</Text>
</View>
{(item.price != null && item.price > 0) || item.requiresConfirmation ? (
<View className="mt-2 flex-row items-center gap-3">
{item.price != null && item.price > 0 && (
<Text className="text-sm font-medium text-[#34C759]">
{item.currency || "$"}
{item.price}
</Text>
)}
{item.requiresConfirmation && (
<View className="rounded bg-[#FF9500] px-2 py-0.5">
<Text className="text-xs font-medium text-white">Requires Confirmation</Text>
</View>
)}
</View>
) : null}
</View>
<View className="flex-row">
<Tooltip text="Preview">
<TouchableOpacity
className="items-center justify-center rounded-l-lg border border-r-0 border-[#E5E5EA]"
style={{ width: 32, height: 32 }}
onPress={() => handlePreview(item)}
>
<Ionicons name="open-outline" size={18} color="#3C3F44" />
</TouchableOpacity>
</Tooltip>
<Tooltip text={copiedEventTypeId === item.id ? "Copied!" : "Copy link"}>
<TouchableOpacity
className="items-center justify-center border border-r-0 border-[#E5E5EA]"
style={{ width: 32, height: 32 }}
onPress={() => handleCopyLink(item)}
>
{copiedEventTypeId === item.id ? (
<Ionicons name="checkmark" size={18} color="#10B981" />
) : (
<Svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="#3C3F44"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<Path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<Path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</Svg>
)}
</TouchableOpacity>
</Tooltip>
<Tooltip text="More">
<TouchableOpacity
className="items-center justify-center rounded-r-lg border border-[#E5E5EA]"
style={{ width: 32, height: 32 }}
onPress={() => handleEventTypeLongPress(item)}
>
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
</TouchableOpacity>
</Tooltip>
</View>
</View>
</TouchableOpacity>
);
};
if (loading) {
return (
<View className="flex-1 bg-gray-100">
<Header />
<Activity mode={Platform.OS === "web" ? "visible" : "hidden"}>
<Header />
</Activity>
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<LoadingSpinner size="large" />
</View>
@@ -531,7 +394,9 @@ export default function EventTypes() {
if (error) {
return (
<View className="flex-1 bg-gray-100">
<Header />
<Activity mode={Platform.OS === "web" ? "visible" : "hidden"}>
<Header />
</Activity>
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-gray-800">
@@ -549,7 +414,9 @@ export default function EventTypes() {
if (eventTypes.length === 0) {
return (
<View className="flex-1 bg-gray-100">
<Header />
<Activity mode={Platform.OS === "web" ? "visible" : "hidden"}>
<Header />
</Activity>
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<EmptyScreen
icon="link-outline"
@@ -566,6 +433,77 @@ export default function EventTypes() {
if (filteredEventTypes.length === 0 && searchQuery.trim() !== "") {
return (
<View className="flex-1 bg-gray-100">
<Activity mode={Platform.OS === "web" ? "visible" : "hidden"}>
<Header />
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
<TextInput
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
placeholder="Search event types"
placeholderTextColor="#9CA3AF"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
<TouchableOpacity
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
onPress={handleCreateNew}
>
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-semibold text-white">New</Text>
</TouchableOpacity>
</View>
</Activity>
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<EmptyScreen
icon="search-outline"
headline={`No results found for "${searchQuery}"`}
description="Try searching with different keywords"
/>
</View>
</View>
);
}
return (
<>
<Stack.Screen
options={{
headerShown: true,
title: "Event Types",
headerLargeTitleEnabled: true,
headerStyle: {
backgroundColor: "transparent",
},
headerSearchBarOptions: {
placeholder: "Search event types",
barTintColor: "#fff",
obscureBackground: false,
onChangeText: (e) => {
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,
},
],
}}
/>
<Activity mode={Platform.OS === "web" ? "visible" : "hidden"}>
<Header />
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
<TextInput
@@ -586,48 +524,32 @@ export default function EventTypes() {
<Text className="text-base font-semibold text-white">New</Text>
</TouchableOpacity>
</View>
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<EmptyScreen
icon="search-outline"
headline={`No results found for "${searchQuery}"`}
description="Try searching with different keywords"
/>
</View>
</View>
);
}
</Activity>
return (
<View className="flex-1 bg-gray-100">
<Header />
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
<TextInput
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
placeholder="Search event types"
placeholderTextColor="#9CA3AF"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
<TouchableOpacity
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
onPress={handleCreateNew}
>
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-semibold text-white">New</Text>
</TouchableOpacity>
</View>
<ScrollView
style={{ backgroundColor: "white" }}
contentContainerStyle={{ paddingBottom: 90 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
>
<View className="px-2 pt-4 md:px-4">
<View className="overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
{filteredEventTypes.map((item, index) => (
<View key={item.id.toString()}>{renderEventType({ item, index })}</View>
<EventTypeListItem
key={item.id.toString()}
item={item}
index={index}
filteredEventTypes={filteredEventTypes}
copiedEventTypeId={copiedEventTypeId}
handleEventTypePress={handleEventTypePress}
handleEventTypeLongPress={handleEventTypeLongPress}
handleCopyLink={handleCopyLink}
handlePreview={handlePreview}
onEdit={handleEdit}
onDuplicate={handleDuplicate}
onDelete={handleDelete}
/>
))}
</View>
</View>
@@ -988,6 +910,6 @@ export default function EventTypes() {
</View>
</View>
)}
</View>
</>
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ export default function TabLayout() {
if (supportsLiquidGlass) {
return (
<NativeTabs>
<NativeTabs.Trigger name="event-types">
<NativeTabs.Trigger name="(event-types)">
<NativeTabs.Trigger.Icon sf="link" />
<NativeTabs.Trigger.Label>Event Types</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
-5
View File
@@ -1,5 +0,0 @@
import { Redirect } from "expo-router";
export default function Index() {
return <Redirect href="/(tabs)/event-types" />;
}
+23 -2
View File
@@ -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=="],
@@ -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 (
<View className={`bg-white active:bg-[#F8F9FA] ${!isLast ? "border-b border-[#E5E5EA]" : ""}`}>
<View className="flex-shrink-1 flex-row items-center justify-between">
<Pressable
onPress={() => handleEventTypePress(item)}
// No need for long press on iOS
// onLongPress={() => handleEventTypeLongPress(item)}
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
className="flex-grow"
>
<View className="mr-4 flex-1">
<View className="mb-1 flex-row items-center">
<Text className="flex-1 text-base font-semibold text-[#333]">{item.title} </Text>
</View>
{item.description && (
<Text className="mb-2 mt-0.5 text-sm leading-5 text-[#666]" numberOfLines={2}>
{normalizeMarkdown(item.description)}
</Text>
)}
<View className="mt-2 flex-row items-center self-start rounded-lg border border-[#E5E5EA] bg-[#E5E5EA] px-2 py-1">
<Ionicons name="time-outline" size={14} color="#000" />
<Text className="ml-1.5 text-xs font-semibold text-black">
{formatDuration(duration)}
</Text>
</View>
{(item.price != null && item.price > 0) || item.requiresConfirmation ? (
<View className="mt-2 flex-row items-center gap-3">
{item.price != null && item.price > 0 && (
<Text className="text-sm font-medium text-[#34C759]">
{item.currency || "$"}
{item.price}
</Text>
)}
{item.requiresConfirmation && (
<View className="rounded bg-[#FF9500] px-2 py-0.5">
<Text className="text-xs font-medium text-white">Requires Confirmation</Text>
</View>
)}
</View>
) : null}
</View>
</Pressable>
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered"), padding()]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{eventTypes.map((eventType) => (
<Button
key={eventType.label}
systemImage={eventType.icon}
onPress={eventType.onPress}
role={eventType.role}
>
{eventType.label}
</Button>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
{/* <Image systemName="ellipsis" modifiers={[]} /> */}
<HStack>
<Image
systemName="ellipsis"
color="primary"
size={24}
modifiers={[frame({ height: 24, width: 17 })]}
/>
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
</View>
</View>
);
};
@@ -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 (
<TouchableOpacity
className={`bg-white active:bg-[#F8F9FA] ${!isLast ? "border-b border-[#E5E5EA]" : ""}`}
onPress={() => handleEventTypePress(item)}
onLongPress={() => handleEventTypeLongPress(item)}
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
>
<View className="flex-row items-center justify-between">
<View className="mr-4 flex-1">
<View className="mb-1 flex-row items-center">
<Text className="flex-1 text-base font-semibold text-[#333]">{item.title}</Text>
</View>
{item.description && (
<Text className="mb-2 mt-0.5 text-sm leading-5 text-[#666]" numberOfLines={2}>
{normalizeMarkdown(item.description)}
</Text>
)}
<View className="mt-2 flex-row items-center self-start rounded-lg border border-[#E5E5EA] bg-[#E5E5EA] px-2 py-1">
<Ionicons name="time-outline" size={14} color="#000" />
<Text className="ml-1.5 text-xs font-semibold text-black">
{formatDuration(duration)}
</Text>
</View>
{(item.price != null && item.price > 0) || item.requiresConfirmation ? (
<View className="mt-2 flex-row items-center gap-3">
{item.price != null && item.price > 0 && (
<Text className="text-sm font-medium text-[#34C759]">
{item.currency || "$"}
{item.price}
</Text>
)}
{item.requiresConfirmation && (
<View className="rounded bg-[#FF9500] px-2 py-0.5">
<Text className="text-xs font-medium text-white">Requires Confirmation</Text>
</View>
)}
</View>
) : null}
</View>
<View className="flex-row">
<Tooltip text="Preview">
<TouchableOpacity
className="items-center justify-center rounded-l-lg border border-r-0 border-[#E5E5EA]"
style={{ width: 32, height: 32 }}
onPress={() => handlePreview(item)}
>
<Ionicons name="open-outline" size={18} color="#3C3F44" />
</TouchableOpacity>
</Tooltip>
<Tooltip text={copiedEventTypeId === item.id ? "Copied!" : "Copy link"}>
<TouchableOpacity
className="items-center justify-center border border-r-0 border-[#E5E5EA]"
style={{ width: 32, height: 32 }}
onPress={() => handleCopyLink(item)}
>
{copiedEventTypeId === item.id ? (
<Ionicons name="checkmark" size={18} color="#10B981" />
) : (
<Svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="#3C3F44"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<Path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<Path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</Svg>
)}
</TouchableOpacity>
</Tooltip>
<Tooltip text="More">
<TouchableOpacity
className="items-center justify-center rounded-r-lg border border-[#E5E5EA]"
style={{ width: 32, height: 32 }}
onPress={() => handleEventTypeLongPress(item)}
>
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
</TouchableOpacity>
</Tooltip>
</View>
</View>
</TouchableOpacity>
);
};
@@ -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;
}
+1
View File
@@ -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",
+6
View File
@@ -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;
};
+37
View File
@@ -0,0 +1,37 @@
export const normalizeMarkdown = (text: string): string => {
if (!text) return "";
return (
text
// Remove HTML tags including <br>, <div>, <p>, etc.
.replace(/<[^>]*>/g, " ")
// Convert HTML entities
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/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()
);
};