Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 (
<Box gap="xs" paddingTop="sm">
{[1, 2, 3].map((i) => (
<SkeletonRect key={i} width="full" height="lg" borderRadius="sm" />
))}
</Box>
);
}

// ─── Component ───────────────────────────────────────────────────────────────

export const ActivitiesTabContent = forwardRef<
ActivitiesTabContentHandle,
ActivitiesTabContentProps
>(({ tripID }, ref) => {
const entrySheetRef = useRef<AddActivityEntrySheetHandle>(null);
const manualSheetRef = useRef<AddActivityManualSheetHandle>(null);

const [sortOrder, setSortOrder] = useState<SortOrder>("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 }) => (
<ActivityCard
activity={item}
onPress={() =>
router.push({
pathname: `/trips/${tripID}/activities/${item.id}` as any,
params: { tripID },
})
}
/>
),
[tripID],
);

const renderFooter = useCallback(
() =>
isLoadingMore ? (
<Box paddingVertical="sm" alignItems="center">
<ActivityIndicator size="small" />
</Box>
) : null,
[isLoadingMore],
);

// ─── Render ──────────────────────────────────────────────────────────────

return (
<Box flex={1} backgroundColor="gray25">
{isLoading ? (
<ActivitiesSkeleton />
) : activities.length === 0 ? (
<Box alignItems="center" justifyContent="center" paddingVertical="xl">
<EmptyState
title="No activities yet"
description="Tap + to add the first one!"
/>
</Box>
) : (
<>
{/* Header row: count + sort toggle */}
<Box
flexDirection="row"
justifyContent="space-between"
alignItems="center"
style={styles.headerRow}
>
<Text variant="bodyDefault" color="gray500">
{activities.length}{" "}
{activities.length === 1 ? "option" : "options"} added
</Text>
<Pressable
onPress={toggleSort}
hitSlop={8}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
<Text variant="bodyStrong" color="gray900">
{sortOrder === "newest" ? "Newest first" : "Oldest first"}
</Text>
</Pressable>
</Box>

<FlatList
data={sortedActivities}
keyExtractor={(item) => item.id ?? ""}
renderItem={renderItem}
onEndReached={fetchMore}
onEndReachedThreshold={0.3}
ListFooterComponent={renderFooter}
contentContainerStyle={styles.list}
ItemSeparatorComponent={() => <View style={styles.separator} />}
style={styles.flatList}
scrollEnabled={false}
/>
</>
)}

{/* Add an activity button */}
<Box style={styles.addButton}>
<Button
layout="textOnly"
label="Add an activity"
variant="Secondary"
onPress={() => entrySheetRef.current?.open()}
/>
</Box>

<AddActivityEntrySheet
ref={entrySheetRef}
tripID={tripID}
onManual={handleManual}
onAutofilled={handleAutofilled}
onClose={() => entrySheetRef.current?.close()}
/>

<AddActivityManualSheet
ref={manualSheetRef}
tripID={tripID}
onSaved={handleSaved}
onClose={() => manualSheetRef.current?.close()}
/>
</Box>
);
});

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,
},
});
158 changes: 158 additions & 0 deletions frontend/app/(app)/trips/[id]/activities/components/activity-card.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Pressable
onPress={onPress}
style={({ pressed }) => ({ opacity: pressed ? 0.97 : 1 })}
>
<Box style={styles.card}>
{/* Thumbnail */}
{hasThumbnail && (
<Image
source={{ uri: activity.thumbnail_url! }}
style={styles.thumbnail}
contentFit="cover"
/>
)}

{/* Content: [avatar] [name + price column] [RSVP button] */}
<Box
flexDirection="row"
alignItems="flex-start"
justifyContent="space-between"
gap="xs"
>
{/* Left: avatar + name/price column */}
<Box
flexDirection="row"
alignItems="flex-start"
gap="xs"
style={styles.infoLeft}
>
{activity.proposed_by && (
<Avatar
variant="md"
seed={activity.proposed_by}
profilePhoto={activity.proposer_picture_url ?? undefined}
/>
)}
<Box style={styles.namePrice}>
<Text variant="bodyStrong" color="gray950" numberOfLines={1}>
{activity.name}
</Text>
{activity.estimated_price != null && (
<Box flexDirection="row">
<Text variant="bodyXsStrong" color="gray950">
${activity.estimated_price}
</Text>
<Text variant="bodyXsDefault" color="gray950">
{" per person"}
</Text>
</Box>
)}
</Box>
</Box>

{/* Right: RSVP button — static for now */}
<Box style={styles.rsvpButton}>
<UserPlus size={12} color={ColorPalette.white} />
<Text variant="bodySmStrong" style={styles.rsvpText}>
I'm going
</Text>
</Box>
</Box>

{/* Comment row */}
{goingUsers.length > 0 && (
<Box style={styles.commentRow}>
<Box flexDirection="row" gap="xxs">
{goingUsers.slice(0, 3).map((u) => (
<Avatar
key={u.user_id}
variant="xs"
seed={u.user_id}
profilePhoto={u.profile_picture_url ?? undefined}
/>
))}
</Box>
<Text variant="bodySmStrong" style={styles.commentText}>
1 new comment
</Text>
</Box>
)}
</Box>
</Pressable>
);
}

// ─── 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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tiny nit but probably we can use the design system token here instead of the specific number for padding

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont think 12px for spacing is defined in the design system, is this fine to leave as is?

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,
},
});
Loading
Loading