diff --git a/frontend/app/(app)/trips/[id]/activities/components/activities-tab-content.tsx b/frontend/app/(app)/trips/[id]/activities/components/activities-tab-content.tsx
new file mode 100644
index 00000000..7a91b365
--- /dev/null
+++ b/frontend/app/(app)/trips/[id]/activities/components/activities-tab-content.tsx
@@ -0,0 +1,235 @@
+import { useActivitiesList } from "@/api/activities/custom/useActivitiesList";
+import { Box, Button, EmptyState, SkeletonRect, Text } from "@/design-system";
+import { ColorPalette } from "@/design-system/tokens/color";
+import { Layout } from "@/design-system/tokens/layout";
+import type {
+ ModelsActivityAPIResponse,
+ ModelsParsedActivityData,
+} from "@/types/types.gen";
+import { router } from "expo-router";
+import {
+ forwardRef,
+ useCallback,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ ActivityIndicator,
+ FlatList,
+ Pressable,
+ StyleSheet,
+ View,
+} from "react-native";
+import { ActivityCard } from "./activity-card";
+import {
+ AddActivityEntrySheet,
+ type AddActivityEntrySheetHandle,
+} from "./add-activity-entry-sheet";
+import {
+ AddActivityManualSheet,
+ type AddActivityManualSheetHandle,
+} from "./add-activity-manual-sheet";
+
+// ─── Types ───────────────────────────────────────────────────────────────────
+
+export type ActivitiesTabContentHandle = {
+ openAddActivity: () => void;
+};
+
+type ActivitiesTabContentProps = {
+ tripID: string;
+};
+
+type SortOrder = "newest" | "oldest";
+
+// ─── Skeleton ────────────────────────────────────────────────────────────────
+
+function ActivitiesSkeleton() {
+ return (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ );
+}
+
+// ─── Component ───────────────────────────────────────────────────────────────
+
+export const ActivitiesTabContent = forwardRef<
+ ActivitiesTabContentHandle,
+ ActivitiesTabContentProps
+>(({ tripID }, ref) => {
+ const entrySheetRef = useRef(null);
+ const manualSheetRef = useRef(null);
+
+ const [sortOrder, setSortOrder] = useState("newest");
+
+ const { activities, isLoading, isLoadingMore, fetchMore, prependActivity } =
+ useActivitiesList(tripID);
+
+ // ─── Sort activities ─────────────────────────────────────────────────────
+
+ const sortedActivities = useMemo(() => {
+ if (sortOrder === "newest") return activities;
+ return [...activities].reverse();
+ }, [activities, sortOrder]);
+
+ useImperativeHandle(ref, () => ({
+ openAddActivity: () => entrySheetRef.current?.open(),
+ }));
+
+ // ─── Handlers ────────────────────────────────────────────────────────────
+
+ const handleAutofilled = useCallback((data: ModelsParsedActivityData) => {
+ entrySheetRef.current?.close();
+ setTimeout(() => manualSheetRef.current?.open(data), 300);
+ }, []);
+
+ const handleManual = useCallback(() => {
+ entrySheetRef.current?.close();
+ setTimeout(() => manualSheetRef.current?.open(), 300);
+ }, []);
+
+ const handleSaved = useCallback(
+ (activity: ModelsActivityAPIResponse) => {
+ manualSheetRef.current?.close();
+ prependActivity(activity);
+ },
+ [prependActivity],
+ );
+
+ const toggleSort = useCallback(() => {
+ setSortOrder((prev) => (prev === "newest" ? "oldest" : "newest"));
+ }, []);
+
+ const renderItem = useCallback(
+ ({ item }: { item: ModelsActivityAPIResponse }) => (
+
+ router.push({
+ pathname: `/trips/${tripID}/activities/${item.id}` as any,
+ params: { tripID },
+ })
+ }
+ />
+ ),
+ [tripID],
+ );
+
+ const renderFooter = useCallback(
+ () =>
+ isLoadingMore ? (
+
+
+
+ ) : null,
+ [isLoadingMore],
+ );
+
+ // ─── Render ──────────────────────────────────────────────────────────────
+
+ return (
+
+ {isLoading ? (
+
+ ) : activities.length === 0 ? (
+
+
+
+ ) : (
+ <>
+ {/* Header row: count + sort toggle */}
+
+
+ {activities.length}{" "}
+ {activities.length === 1 ? "option" : "options"} added
+
+ ({ opacity: pressed ? 0.7 : 1 })}
+ >
+
+ {sortOrder === "newest" ? "Newest first" : "Oldest first"}
+
+
+
+
+ item.id ?? ""}
+ renderItem={renderItem}
+ onEndReached={fetchMore}
+ onEndReachedThreshold={0.3}
+ ListFooterComponent={renderFooter}
+ contentContainerStyle={styles.list}
+ ItemSeparatorComponent={() => }
+ style={styles.flatList}
+ scrollEnabled={false}
+ />
+ >
+ )}
+
+ {/* Add an activity button */}
+
+
+
+ entrySheetRef.current?.close()}
+ />
+
+ manualSheetRef.current?.close()}
+ />
+
+ );
+});
+
+ActivitiesTabContent.displayName = "ActivitiesTabContent";
+
+// ─── Styles ──────────────────────────────────────────────────────────────────
+
+const styles = StyleSheet.create({
+ headerRow: {
+ paddingTop: Layout.spacing.xs,
+ paddingBottom: Layout.spacing.xxs,
+ },
+ flatList: {
+ backgroundColor: ColorPalette.gray25,
+ },
+ list: {
+ paddingBottom: Layout.spacing.xs,
+ },
+ separator: {
+ height: Layout.spacing.sm,
+ backgroundColor: ColorPalette.gray25,
+ },
+ addButton: {
+ marginTop: Layout.spacing.xs,
+ },
+});
diff --git a/frontend/app/(app)/trips/[id]/activities/components/activity-card.tsx b/frontend/app/(app)/trips/[id]/activities/components/activity-card.tsx
new file mode 100644
index 00000000..18b36003
--- /dev/null
+++ b/frontend/app/(app)/trips/[id]/activities/components/activity-card.tsx
@@ -0,0 +1,158 @@
+import { Box, Text } from "@/design-system";
+import { Avatar } from "@/design-system/components/avatars/avatar";
+import { ColorPalette } from "@/design-system/tokens/color";
+import { CornerRadius } from "@/design-system/tokens/corner-radius";
+import { Layout } from "@/design-system/tokens/layout";
+import type { ModelsActivityAPIResponse } from "@/types/types.gen";
+import { Image } from "expo-image";
+import { UserPlus } from "lucide-react-native";
+import { Pressable, StyleSheet } from "react-native";
+
+// ─── Types ───────────────────────────────────────────────────────────────────
+
+type ActivityCardProps = {
+ activity: ModelsActivityAPIResponse;
+ onPress?: () => void;
+};
+
+// ─── Component ───────────────────────────────────────────────────────────────
+
+export function ActivityCard({ activity, onPress }: ActivityCardProps) {
+ const hasThumbnail = !!activity.thumbnail_url;
+ const goingUsers = activity.going_users ?? [];
+
+ return (
+ ({ opacity: pressed ? 0.97 : 1 })}
+ >
+
+ {/* Thumbnail */}
+ {hasThumbnail && (
+
+ )}
+
+ {/* Content: [avatar] [name + price column] [RSVP button] */}
+
+ {/* Left: avatar + name/price column */}
+
+ {activity.proposed_by && (
+
+ )}
+
+
+ {activity.name}
+
+ {activity.estimated_price != null && (
+
+
+ ${activity.estimated_price}
+
+
+ {" per person"}
+
+
+ )}
+
+
+
+ {/* Right: RSVP button — static for now */}
+
+
+
+ I'm going
+
+
+
+
+ {/* Comment row */}
+ {goingUsers.length > 0 && (
+
+
+ {goingUsers.slice(0, 3).map((u) => (
+
+ ))}
+
+
+ 1 new comment
+
+
+ )}
+
+
+ );
+}
+
+// ─── Styles ──────────────────────────────────────────────────────────────────
+
+const styles = StyleSheet.create({
+ card: {
+ backgroundColor: ColorPalette.white,
+ borderRadius: 20,
+ padding: 12,
+ gap: 12,
+ },
+ thumbnail: {
+ width: 346,
+ height: 230,
+ borderRadius: CornerRadius.md,
+ },
+ infoLeft: {
+ flex: 1,
+ minWidth: 0,
+ },
+ namePrice: {
+ flex: 1,
+ gap: 2,
+ minWidth: 0,
+ },
+ rsvpButton: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ backgroundColor: ColorPalette.brand500,
+ borderRadius: CornerRadius.md,
+ paddingHorizontal: 12,
+ paddingVertical: Layout.spacing.xs,
+ flexShrink: 0,
+ },
+ rsvpText: {
+ color: ColorPalette.white,
+ lineHeight: 16,
+ },
+ commentRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ backgroundColor: ColorPalette.blue25,
+ borderRadius: CornerRadius.md,
+ paddingHorizontal: Layout.spacing.xs,
+ paddingVertical: 6,
+ },
+ commentText: {
+ color: ColorPalette.blue500,
+ },
+});
diff --git a/frontend/app/(app)/trips/[id]/activities/components/add-activity-entry-sheet.tsx b/frontend/app/(app)/trips/[id]/activities/components/add-activity-entry-sheet.tsx
new file mode 100644
index 00000000..1eb083ed
--- /dev/null
+++ b/frontend/app/(app)/trips/[id]/activities/components/add-activity-entry-sheet.tsx
@@ -0,0 +1,37 @@
+import { parseActivityLink } from "@/api/activities";
+import type { ModelsParsedActivityData } from "@/types/types.gen";
+import { forwardRef } from "react";
+import {
+ AddItemEntrySheet,
+ type AddItemEntrySheetHandle,
+} from "../../components//add-item-entry-sheet";
+
+export type { AddItemEntrySheetHandle as AddActivityEntrySheetHandle };
+
+type AddActivityEntrySheetProps = {
+ tripID: string;
+ onManual: () => void;
+ onAutofilled: (data: ModelsParsedActivityData) => void;
+ onClose: () => void;
+};
+
+export const AddActivityEntrySheet = forwardRef<
+ AddItemEntrySheetHandle,
+ AddActivityEntrySheetProps
+>(({ tripID, onManual, onAutofilled, onClose }, ref) => (
+ parseActivityLink(tripID, { url })}
+ onAutofilled={onAutofilled}
+ onManual={onManual}
+ onClose={onClose}
+ />
+));
+
+AddActivityEntrySheet.displayName = "AddActivityEntrySheet";
diff --git a/frontend/app/(app)/trips/[id]/activities/components/add-activity-manual-sheet.tsx b/frontend/app/(app)/trips/[id]/activities/components/add-activity-manual-sheet.tsx
new file mode 100644
index 00000000..831445d3
--- /dev/null
+++ b/frontend/app/(app)/trips/[id]/activities/components/add-activity-manual-sheet.tsx
@@ -0,0 +1,329 @@
+import { useCreateActivity } from "@/api/activities";
+import { getPlaceDetailsCustom } from "@/api/places/custom";
+import { Box, DateRangePicker, Text } from "@/design-system";
+import type { DateRange } from "@/design-system/primitives/date-picker";
+import { PricePicker } from "@/design-system/primitives/price-picker";
+import { ColorPalette } from "@/design-system/tokens/color";
+import { FontFamily } from "@/design-system/tokens/typography";
+import type {
+ ModelsActivityAPIResponse,
+ ModelsParsedActivityData,
+} from "@/types/types.gen";
+import { locationSelectStore } from "@/utilities/locationSelectStore";
+import { router } from "expo-router";
+import { Calendar, DollarSign, Link, MapPin } from "lucide-react-native";
+import {
+ forwardRef,
+ useCallback,
+ useImperativeHandle,
+ useRef,
+ useState,
+} from "react";
+import { Pressable, StyleSheet, TextInput, View } from "react-native";
+import {
+ AddItemManualSheet,
+ type AddItemManualSheetHandle,
+ type ItemManualSheetBasePrefill,
+} from "../../components/add-item-manual-sheet";
+
+// ─── Types ───────────────────────────────────────────────────────────────────
+
+type PendingLocation = { name: string; lat: number; lng: number };
+
+export type AddActivityManualSheetHandle = {
+ open: (prefill?: Partial) => void;
+ close: () => void;
+ setLocation: (loc: PendingLocation) => void;
+};
+
+type AddActivityManualSheetProps = {
+ tripID: string;
+ onSaved: (activity: ModelsActivityAPIResponse) => void;
+ onClose: () => void;
+};
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function formatDateRange(range: DateRange): string | null {
+ if (!range.start) return null;
+ const fmt = (d: Date) =>
+ d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
+ if (!range.end || range.start.getTime() === range.end.getTime())
+ return fmt(range.start);
+ return `${fmt(range.start)} – ${fmt(range.end)}`;
+}
+
+// ─── Component ───────────────────────────────────────────────────────────────
+
+export const AddActivityManualSheet = forwardRef<
+ AddActivityManualSheetHandle,
+ AddActivityManualSheetProps
+>(({ tripID, onSaved, onClose }, ref) => {
+ const createActivity = useCreateActivity();
+ const sheetRef = useRef(null);
+
+ // Activity-specific form state
+ const [price, setPrice] = useState(null);
+ const [dateRange, setDateRange] = useState({
+ start: null,
+ end: null,
+ });
+ const [locationName, setLocationName] = useState(null);
+ const [locationLat, setLocationLat] = useState(null);
+ const [locationLng, setLocationLng] = useState(null);
+ const [link, setLink] = useState("");
+ const [isPricePickerVisible, setIsPricePickerVisible] = useState(false);
+ const [isDatePickerVisible, setIsDatePickerVisible] = useState(false);
+
+ // ─── Ref API ───────────────────────────────────────────────────────────────
+
+ useImperativeHandle(ref, () => ({
+ open: (prefill) => {
+ setPrice(null);
+ setDateRange({ start: null, end: null });
+ setLocationName(null);
+ setLocationLat(null);
+ setLocationLng(null);
+ setLink(prefill?.media_url ?? "");
+ const basePrefill: ItemManualSheetBasePrefill = {
+ name: prefill?.name,
+ description: prefill?.description ?? "",
+ thumbnailUri: prefill?.thumbnail_url ?? undefined,
+ };
+ sheetRef.current?.open(basePrefill);
+ },
+ close: () => sheetRef.current?.close(),
+ setLocation: (loc) => {
+ setLocationName(loc.name);
+ setLocationLat(loc.lat);
+ setLocationLng(loc.lng);
+ },
+ }));
+
+ // ─── Location ──────────────────────────────────────────────────────────────
+
+ const handleLocationPress = useCallback(() => {
+ locationSelectStore.set(async (prediction) => {
+ try {
+ const res = await getPlaceDetailsCustom({
+ place_id: prediction.place_id,
+ });
+ setLocationName(
+ res.data.formatted_address || prediction.description || res.data.name,
+ );
+ setLocationLat(res.data.geometry.location.lat);
+ setLocationLng(res.data.geometry.location.lng);
+ } catch {
+ setLocationName(prediction.description ?? null);
+ }
+ });
+ router.push(`/trips/${tripID}/search-location?mode=select`);
+ }, [tripID]);
+
+ // ─── Save ──────────────────────────────────────────────────────────────────
+
+ const handleSave = useCallback(
+ async ({
+ name,
+ description,
+ thumbnailURL,
+ }: {
+ name: string;
+ description: string;
+ thumbnailURL?: string;
+ }) => {
+ const dates =
+ dateRange.start && dateRange.end
+ ? [
+ {
+ start: dateRange.start.toISOString().split("T")[0]!,
+ end: dateRange.end.toISOString().split("T")[0]!,
+ },
+ ]
+ : undefined;
+
+ return createActivity.mutateAsync({
+ tripID,
+ data: {
+ name,
+ description: description || undefined,
+ estimated_price: price ?? undefined,
+ dates,
+ location_name: locationName ?? undefined,
+ location_lat: locationLat ?? undefined,
+ location_lng: locationLng ?? undefined,
+ thumbnail_url: thumbnailURL,
+ media_url: link.trim() || undefined,
+ },
+ });
+ },
+ [
+ createActivity,
+ tripID,
+ price,
+ dateRange,
+ locationName,
+ locationLat,
+ locationLng,
+ link,
+ ],
+ );
+
+ const formattedDate = formatDateRange(dateRange);
+ const priceLabel =
+ price != null ? `$${price.toLocaleString()} per person` : null;
+
+ // ─── Render ────────────────────────────────────────────────────────────────
+
+ return (
+ <>
+
+ {/* Price */}
+ setIsPricePickerVisible(true)}
+ >
+
+
+ {priceLabel ?? "Add price"}
+
+
+
+ {/* Date */}
+ setIsDatePickerVisible(true)}
+ >
+
+
+ {formattedDate ?? "Add date"}
+
+
+
+ {/* Location */}
+
+
+
+ {locationName ?? "Add location"}
+
+
+
+ {/* Link */}
+
+
+
+
+
+ }
+ />
+
+ setPrice(p)}
+ onClose={() => setIsPricePickerVisible(false)}
+ />
+
+ setIsDatePickerVisible(false)}
+ onSave={(range) => {
+ setDateRange(range);
+ setIsDatePickerVisible(false);
+ }}
+ initialRange={dateRange}
+ />
+ >
+ );
+});
+
+AddActivityManualSheet.displayName = "AddActivityManualSheet";
+
+// ─── Styles ──────────────────────────────────────────────────────────────────
+
+const styles = StyleSheet.create({
+ formRows: {
+ gap: 16,
+ },
+ formRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ },
+ formRowPlaceholder: {
+ color: ColorPalette.blue500,
+ flex: 1,
+ },
+ formRowValue: {
+ color: ColorPalette.gray700,
+ flex: 1,
+ },
+ formRowInput: {
+ flex: 1,
+ fontFamily: FontFamily.semiBold,
+ fontSize: 16,
+ lineHeight: 20,
+ paddingVertical: 0,
+ },
+ formRowInputEmpty: {
+ color: ColorPalette.blue500,
+ },
+ formRowInputFilled: {
+ color: ColorPalette.gray700,
+ },
+});
diff --git a/frontend/app/(app)/trips/[id]/activities/components/categories-sheet.tsx b/frontend/app/(app)/trips/[id]/activities/components/categories-sheet.tsx
new file mode 100644
index 00000000..c5a70a17
--- /dev/null
+++ b/frontend/app/(app)/trips/[id]/activities/components/categories-sheet.tsx
@@ -0,0 +1,123 @@
+import { useGetCategoriesByTripID } from "@/api/categories";
+import { Box, Button, Text } from "@/design-system";
+import BottomSheet from "@/design-system/components/bottom-sheet/bottom-sheet";
+import { ColorPalette } from "@/design-system/tokens/color";
+import { CornerRadius } from "@/design-system/tokens/corner-radius";
+import { BottomSheetMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
+import { Plus } from "lucide-react-native";
+import { forwardRef } from "react";
+import { Pressable, StyleSheet } from "react-native";
+
+type CategoriesSheetProps = {
+ tripID: string;
+ selected: string[];
+ onChange: (categories: string[]) => void;
+ onDone: () => void;
+};
+
+export const CategoriesSheet = forwardRef<
+ BottomSheetMethods,
+ CategoriesSheetProps
+>(({ tripID, selected, onChange, onDone }, ref) => {
+ const { data } = useGetCategoriesByTripID(tripID);
+ const categories = data?.categories ?? [];
+
+ const toggle = (name: string) => {
+ onChange(
+ selected.includes(name)
+ ? selected.filter((n) => n !== name)
+ : [...selected, name],
+ );
+ };
+
+ return (
+
+
+
+ }
+ >
+
+
+
+ Categories
+
+
+
+ Done
+
+
+
+
+
+ {categories.map((cat) => {
+ const isSelected = selected.includes(cat.name);
+ return (
+ toggle(cat.name)}>
+
+
+ {cat.label ?? cat.name}
+
+
+
+ );
+ })}
+
+
+
+
+
+ Create amenity
+
+
+
+
+
+
+ );
+});
+
+CategoriesSheet.displayName = "CategoriesSheet";
+
+const styles = StyleSheet.create({
+ chip: {
+ borderWidth: 1,
+ borderColor: ColorPalette.gray200,
+ borderRadius: CornerRadius.full,
+ },
+ chipSelected: {
+ backgroundColor: ColorPalette.gray900,
+ borderColor: ColorPalette.gray900,
+ },
+});
diff --git a/frontend/app/(app)/trips/[id]/activities/components/form-row.tsx b/frontend/app/(app)/trips/[id]/activities/components/form-row.tsx
new file mode 100644
index 00000000..e85f4295
--- /dev/null
+++ b/frontend/app/(app)/trips/[id]/activities/components/form-row.tsx
@@ -0,0 +1,43 @@
+import { Box, Text } from "@/design-system";
+import { ColorPalette } from "@/design-system/tokens/color";
+import { LucideProps } from "lucide-react-native";
+import React from "react";
+import { Pressable } from "react-native";
+
+type FormRowProps = {
+ icon: React.ComponentType;
+ value?: string;
+ placeholder: string;
+ onPress?: () => void;
+};
+
+export function FormRow({
+ icon: Icon,
+ value,
+ placeholder,
+ onPress,
+}: FormRowProps) {
+ const hasValue = !!value;
+ return (
+
+
+
+
+ {value ?? placeholder}
+
+
+
+ );
+}
diff --git a/frontend/app/(app)/trips/[id]/activities/creation/index.tsx b/frontend/app/(app)/trips/[id]/activities/creation/index.tsx
deleted file mode 100644
index 1a19e108..00000000
--- a/frontend/app/(app)/trips/[id]/activities/creation/index.tsx
+++ /dev/null
@@ -1,246 +0,0 @@
-import { useCreateActivity } from "@/api/activities";
-import { useDeleteImage, useUploadImage } from "@/api/files/custom";
-import { Box, Button, Screen, Spinner, Text } from "@/design-system";
-import type { ModelsActivityTimeOfDay } from "@/types/types.gen";
-import { ColorPalette } from "@/design-system/tokens/color";
-import * as ImagePicker from "expo-image-picker";
-import { useLocalSearchParams, useRouter } from "expo-router";
-import { useState } from "react";
-import { Alert, Image, Pressable, ScrollView, TextInput } from "react-native";
-
-export default function CreateActivity() {
- const {
- id: tripID,
- date,
- timeOfDay,
- } = useLocalSearchParams<{
- id: string;
- date?: string;
- timeOfDay?: string;
- }>();
- const router = useRouter();
-
- const [name, setName] = useState("");
- const [description, setDescription] = useState("");
- const [imageUris, setImageUris] = useState([]);
- const [isSubmitting, setIsSubmitting] = useState(false);
-
- const uploadImageMutation = useUploadImage();
- const deleteImageMutation = useDeleteImage();
- const createActivityMutation = useCreateActivity();
-
- const pickImage = async () => {
- if (imageUris.length >= 5) {
- Alert.alert("Limit reached", "You can add up to 5 images.");
- return;
- }
-
- const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
- if (status !== "granted") {
- Alert.alert("Permission denied", "Media library access is required.");
- return;
- }
-
- const result = await ImagePicker.launchImageLibraryAsync({
- mediaTypes: ["images"],
- quality: 1,
- });
-
- const uri = result.assets?.[0]?.uri;
- if (!result.canceled && uri) {
- setImageUris((prev) => [...prev, uri]);
- }
- };
-
- const removeImage = (index: number) => {
- setImageUris((prev) => prev.filter((_, i) => i !== index));
- };
-
- const handleCreate = async () => {
- if (!name.trim()) {
- Alert.alert("Validation", "Activity name is required.");
- return;
- }
-
- setIsSubmitting(true);
-
- const uploadedImageIds: string[] = [];
-
- try {
- for (const uri of imageUris) {
- const res = await uploadImageMutation.mutateAsync({
- uri,
- sizes: ["medium"],
- });
- uploadedImageIds.push(res.imageId);
- }
-
- const activity = await createActivityMutation.mutateAsync({
- tripID,
- data: {
- trip_id: tripID,
- name: name.trim(),
- description: description.trim() || undefined,
- image_ids: uploadedImageIds,
- ...(date ? { dates: [{ start: date, end: date }] } : {}),
- ...(timeOfDay
- ? { time_of_day: timeOfDay as ModelsActivityTimeOfDay }
- : {}),
- },
- });
-
- router.replace(
- `/trips/${tripID}/activities/${activity.id}?tripID=${tripID}`,
- );
- } catch {
- await Promise.allSettled(
- uploadedImageIds.map((id) => deleteImageMutation.mutateAsync(id)),
- );
- Alert.alert("Error", "Failed to create activity. Please try again.");
- setIsSubmitting(false);
- }
- };
-
- return (
-
-
-
-
- NEW
-
-
- Create Activity
-
-
-
-
-
-
-
- NAME *
-
-
-
-
-
-
-
-
- DESCRIPTION
-
-
-
-
-
-
-
-
- IMAGES ({imageUris.length}/5)
-
-
- {imageUris.map((uri, i) => (
-
-
- removeImage(i)}
- style={{
- position: "absolute",
- top: -6,
- right: -6,
- width: 20,
- height: 20,
- borderRadius: 10,
- backgroundColor: ColorPalette.gray900,
- justifyContent: "center",
- alignItems: "center",
- }}
- >
-
- ×
-
-
-
- ))}
-
- {imageUris.length < 5 && (
-
-
-
- + Add
-
-
-
- )}
-
-
-
- {isSubmitting ? (
-
-
-
- ) : (
-
- )}
-
-
-
-
- );
-}
diff --git a/frontend/app/(app)/trips/[id]/activities/index.tsx b/frontend/app/(app)/trips/[id]/activities/index.tsx
index 4321f3e5..f84256c7 100644
--- a/frontend/app/(app)/trips/[id]/activities/index.tsx
+++ b/frontend/app/(app)/trips/[id]/activities/index.tsx
@@ -1,12 +1,12 @@
import { useCreateActivity } from "@/api/activities";
import { useActivitiesList } from "@/api/activities/custom/useActivitiesList";
import { useEntityComments } from "@/api/comments/custom/useEntityComments";
+import { useUser } from "@/contexts/user";
import { Box, Screen, Spinner, Text } from "@/design-system";
import CommentSection from "@/design-system/components/comments/comment-section";
import { ColorPalette } from "@/design-system/tokens/color";
-import { useUser } from "@/contexts/user";
-import { modelsEntityType } from "@/types/types.gen";
import type { ModelsActivity } from "@/types/types.gen";
+import { modelsEntityType } from "@/types/types.gen";
import {
activityHasMapLocation,
encodeMapViewActivitiesParam,
diff --git a/frontend/app/(app)/trips/[id]/components/add-item-entry-sheet.tsx b/frontend/app/(app)/trips/[id]/components/add-item-entry-sheet.tsx
new file mode 100644
index 00000000..7c84fc19
--- /dev/null
+++ b/frontend/app/(app)/trips/[id]/components/add-item-entry-sheet.tsx
@@ -0,0 +1,359 @@
+import { Box, Divider, Text, TextField, useToast } from "@/design-system";
+import { ColorPalette } from "@/design-system/tokens/color";
+import { CornerRadius } from "@/design-system/tokens/corner-radius";
+import { FontFamily } from "@/design-system/tokens/typography";
+import { LinearGradient } from "expo-linear-gradient";
+import { Link, X } from "lucide-react-native";
+import {
+ forwardRef,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useState,
+} from "react";
+import {
+ Animated,
+ Image,
+ ImageSourcePropType,
+ KeyboardAvoidingView,
+ Modal,
+ Platform,
+ Pressable,
+ Text as RNText,
+ StyleSheet,
+} from "react-native";
+
+// ─── Types ───────────────────────────────────────────────────────────────────
+
+export type AddItemEntrySheetHandle = {
+ open: () => void;
+ close: () => void;
+};
+
+type AddItemEntrySheetProps = {
+ /** Image shown above the title (e.g. binoculars, house) */
+ illustration: ImageSourcePropType;
+ /** Normal title, e.g. "Add an activity" */
+ title: string;
+ /** Title shown while autofilling, e.g. "Add activity details" */
+ loadingTitle: string;
+ /** Normal subtitle */
+ subtitle: string;
+ /** Subtitle shown while autofilling */
+ loadingSubtitle: string;
+ /** Placeholder for the URL input */
+ urlPlaceholder: string;
+ /** Called with the URL — should resolve with parsed data or throw */
+ onParseLink: (url: string) => Promise;
+ /** Called after successful autofill with the parsed data */
+ onAutofilled: (data: T) => void;
+ onManual: () => void;
+ onClose: () => void;
+};
+
+// ─── Autofill Button with shimmer ────────────────────────────────────────────
+
+function AutofillButton({
+ label,
+ disabled,
+ loading,
+ onPress,
+}: {
+ label: string;
+ disabled: boolean;
+ loading: boolean;
+ onPress: () => void;
+}) {
+ const shimmerX = useMemo(() => new Animated.Value(-300), []);
+
+ useEffect(() => {
+ if (loading) {
+ const anim = Animated.loop(
+ Animated.timing(shimmerX, {
+ toValue: 300,
+ duration: 1000,
+ useNativeDriver: true,
+ }),
+ );
+ anim.start();
+ return () => anim.stop();
+ } else {
+ shimmerX.setValue(-300);
+ }
+ }, [loading, shimmerX]);
+
+ return (
+
+ {loading && (
+
+
+
+ )}
+
+ {label}
+
+
+ );
+}
+
+// ─── Component ───────────────────────────────────────────────────────────────
+
+function AddItemEntrySheetInner(
+ {
+ illustration,
+ title,
+ loadingTitle,
+ subtitle,
+ loadingSubtitle,
+ urlPlaceholder,
+ onParseLink,
+ onAutofilled,
+ onManual,
+ onClose,
+ }: AddItemEntrySheetProps,
+ ref: React.ForwardedRef,
+) {
+ const toast = useToast();
+ const [visible, setVisible] = useState(false);
+ const [url, setUrl] = useState("");
+ const [isAutofilling, setIsAutofilling] = useState(false);
+
+ useImperativeHandle(ref, () => ({
+ open: () => setVisible(true),
+ close: () => {
+ setVisible(false);
+ setUrl("");
+ },
+ }));
+
+ const handleClose = () => {
+ if (isAutofilling) return;
+ setVisible(false);
+ setUrl("");
+ onClose();
+ };
+
+ const handleManual = () => {
+ setVisible(false);
+ setUrl("");
+ onManual();
+ };
+
+ const handleAutofill = async () => {
+ if (!url.trim()) return;
+ setIsAutofilling(true);
+ try {
+ const data = await onParseLink(url.trim());
+ setIsAutofilling(false);
+ setVisible(false);
+ setUrl("");
+ onAutofilled(data);
+ } catch {
+ setIsAutofilling(false);
+ toast.show({ message: "Couldn't fetch that link. Try adding manually." });
+ }
+ };
+
+ return (
+
+
+
+ {}}>
+ {/* X button */}
+
+
+
+
+ {/* Illustration */}
+
+
+
+
+ {/* Title + subtitle */}
+
+
+ {isAutofilling ? loadingTitle : title}
+
+
+ {isAutofilling ? loadingSubtitle : subtitle}
+
+
+
+ {/* URL field */}
+
+ }
+ autoCapitalize="none"
+ keyboardType="url"
+ disabled={isAutofilling}
+ />
+
+
+ {/* Autofill button with shimmer */}
+
+
+
+
+
+
+ {/* Manual fallback */}
+
+
+ Add manually
+
+
+
+
+
+
+ );
+}
+
+export const AddItemEntrySheet = forwardRef(AddItemEntrySheetInner) as (
+ props: AddItemEntrySheetProps & {
+ ref?: React.ForwardedRef;
+ },
+) => React.ReactElement;
+
+// ─── Styles ──────────────────────────────────────────────────────────────────
+
+const styles = StyleSheet.create({
+ backdrop: {
+ flex: 1,
+ backgroundColor: "rgba(0,0,0,0.4)",
+ justifyContent: "flex-end",
+ },
+ sheetWrapper: {
+ justifyContent: "flex-end",
+ },
+ sheet: {
+ backgroundColor: ColorPalette.white,
+ borderTopLeftRadius: 24,
+ borderTopRightRadius: 24,
+ paddingTop: 16,
+ paddingHorizontal: 16,
+ paddingBottom: 32,
+ gap: 20,
+ position: "relative",
+ },
+ closeButton: {
+ position: "absolute",
+ top: 16,
+ right: 16,
+ zIndex: 1,
+ },
+ illustrationContainer: {
+ width: "100%",
+ marginTop: 8,
+ },
+ illustration: {
+ width: 94,
+ height: 94,
+ },
+ illustrationLoading: {
+ opacity: 0.5,
+ },
+ headerGroup: {
+ gap: 6,
+ },
+ fullWidth: {
+ width: "100%",
+ },
+ divider: {
+ width: "100%",
+ marginVertical: 0,
+ backgroundColor: ColorPalette.gray10,
+ },
+ autofillButton: {
+ height: 44,
+ borderRadius: CornerRadius.md,
+ backgroundColor: ColorPalette.brand500,
+ alignItems: "center",
+ justifyContent: "center",
+ overflow: "hidden",
+ },
+ autofillButtonDisabled: {
+ backgroundColor: ColorPalette.gray300,
+ },
+ autofillButtonText: {
+ fontFamily: FontFamily.medium,
+ fontSize: 14,
+ color: ColorPalette.white,
+ },
+ autofillButtonTextDisabled: {
+ color: ColorPalette.gray400,
+ },
+ shimmer: {
+ width: 200,
+ height: "100%",
+ },
+ manualButton: {
+ height: 44,
+ borderRadius: CornerRadius.md,
+ backgroundColor: ColorPalette.gray50,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ manualButtonDisabled: {
+ opacity: 0.5,
+ },
+ manualButtonText: {
+ fontFamily: FontFamily.medium,
+ fontSize: 14,
+ color: ColorPalette.gray900,
+ },
+});
diff --git a/frontend/app/(app)/trips/[id]/components/add-item-manual-sheet.tsx b/frontend/app/(app)/trips/[id]/components/add-item-manual-sheet.tsx
new file mode 100644
index 00000000..dde694ff
--- /dev/null
+++ b/frontend/app/(app)/trips/[id]/components/add-item-manual-sheet.tsx
@@ -0,0 +1,360 @@
+import { useUploadImage } from "@/api/files/custom";
+import { Box, Button, Dialog, Text, useToast } from "@/design-system";
+import BottomSheet from "@/design-system/components/bottom-sheet/bottom-sheet";
+import { ColorPalette } from "@/design-system/tokens/color";
+import { CornerRadius } from "@/design-system/tokens/corner-radius";
+import { Layout } from "@/design-system/tokens/layout";
+import { getImageURL } from "@/services/imageService";
+import * as ExpoImagePicker from "expo-image-picker";
+import { ImagePlus, X } from "lucide-react-native";
+import {
+ forwardRef,
+ ReactNode,
+ useImperativeHandle,
+ useRef,
+ useState,
+} from "react";
+import {
+ Image,
+ Pressable,
+ StyleSheet,
+ TextInput,
+ TouchableOpacity,
+} from "react-native";
+
+// ─── Types ───────────────────────────────────────────────────────────────────
+
+export type ItemManualSheetBasePrefill = {
+ name?: string;
+ description?: string | undefined;
+ thumbnailUri?: string | undefined;
+};
+
+/** Data the generic sheet resolves before calling onSave */
+export type ItemManualSheetBaseData = {
+ name: string;
+ description: string;
+ /** Already uploaded/resolved URL, or undefined if no thumbnail */
+ thumbnailURL: string | undefined;
+};
+
+export type AddItemManualSheetHandle = {
+ open: (prefill?: ItemManualSheetBasePrefill) => void;
+ close: () => void;
+};
+
+type AddItemManualSheetProps = {
+ /** Sheet header title */
+ title: string;
+ /** Placeholder for the name input (default: "New Item") */
+ namePlaceholder?: string;
+ /** Label for the save button (default: "Save") */
+ saveLabel?: string;
+ /** Success toast message (default: "Saved") */
+ successMessage?: string;
+ /** Item-specific form rows rendered between description and the footer */
+ formRows?: ReactNode;
+ /**
+ * Called with the resolved base data when the user taps save.
+ * Should throw on failure. Return value is passed to onSaved.
+ */
+ onSave: (baseData: ItemManualSheetBaseData) => Promise;
+ onSaved: (result: T) => void;
+ onClose: () => void;
+};
+
+// ─── Component ───────────────────────────────────────────────────────────────
+
+function AddItemManualSheetInner(
+ {
+ title,
+ namePlaceholder = "New Item",
+ saveLabel = "Save",
+ successMessage = "Saved",
+ formRows,
+ onSave,
+ onSaved,
+ onClose,
+ }: AddItemManualSheetProps,
+ ref: React.ForwardedRef,
+) {
+ const toast = useToast();
+ const uploadImage = useUploadImage();
+ const bottomSheetRef = useRef(null);
+ const savedRef = useRef(false);
+
+ const [thumbnailUri, setThumbnailUri] = useState(null);
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [isSaving, setIsSaving] = useState(false);
+ const [showCancelConfirm, setShowCancelConfirm] = useState(false);
+
+ useImperativeHandle(ref, () => ({
+ open: (prefill) => {
+ // Always reset first so stale values from the last session don't linger
+ setName(prefill?.name ?? "");
+ setDescription(prefill?.description ?? "");
+ setThumbnailUri(prefill?.thumbnailUri ?? null);
+ bottomSheetRef.current?.snapToIndex(0);
+ },
+ close: () => bottomSheetRef.current?.close(),
+ }));
+
+ const resetForm = () => {
+ setName("");
+ setDescription("");
+ setThumbnailUri(null);
+ };
+
+ const handlePickImage = async () => {
+ const { status } =
+ await ExpoImagePicker.requestMediaLibraryPermissionsAsync();
+ if (status !== "granted") return;
+ const result = await ExpoImagePicker.launchImageLibraryAsync({
+ mediaTypes: ["images"],
+ allowsEditing: true,
+ aspect: [346, 180],
+ quality: 0.8,
+ });
+ if (!result.canceled && result.assets[0]) {
+ setThumbnailUri(result.assets[0].uri);
+ }
+ };
+
+ const handleSave = async () => {
+ if (!name.trim()) return;
+ setIsSaving(true);
+ try {
+ // Resolve thumbnail URL
+ let thumbnailURL: string | undefined;
+ if (thumbnailUri?.startsWith("http")) {
+ thumbnailURL = thumbnailUri;
+ } else if (thumbnailUri) {
+ try {
+ const res = await uploadImage.mutateAsync({
+ uri: thumbnailUri,
+ sizes: ["medium"],
+ });
+ const urlRes = await getImageURL(res.imageId, "medium");
+ thumbnailURL = urlRes.url;
+ } catch {
+ // Non-blocking — save without thumbnail
+ }
+ }
+
+ const result = await onSave({
+ name: name.trim(),
+ description: description.trim(),
+ thumbnailURL,
+ });
+
+ toast.show({
+ message: successMessage,
+ action: { label: "View", onPress: () => {} },
+ });
+ savedRef.current = true;
+ resetForm();
+ onSaved(result);
+ } catch {
+ toast.show({ message: "Couldn't save. Try again." });
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const handleCancel = () => {
+ if (savedRef.current) {
+ savedRef.current = false;
+ return;
+ }
+ const hasData = name.trim() || description.trim() || thumbnailUri;
+ if (hasData) {
+ setShowCancelConfirm(true);
+ } else {
+ resetForm();
+ onClose();
+ }
+ };
+
+ const isValid = name.trim().length > 0;
+ const itemLabel = name || "New Item";
+
+ return (
+ <>
+
+
+
+
+ }
+ >
+ {/* Header */}
+
+
+ {title}
+
+
+
+
+
+
+
+ {/* Thumbnail */}
+
+
+ {thumbnailUri ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Name + description */}
+
+
+
+
+
+ {/* Item-specific form rows slot */}
+ {formRows}
+
+
+
+