Skip to content
Open
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
145 changes: 145 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,145 @@
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 { Elevation } from "@/design-system/tokens/elevation";
import { Layout } from "@/design-system/tokens/layout";
import type { ModelsActivityAPIResponse } from "@/types/types.gen";
import { Image } from "expo-image";
import { Heart } from "lucide-react-native";
import { Pressable, StyleSheet } from "react-native";

type ActivityCardProps = {
activity: ModelsActivityAPIResponse;
onPress?: () => void;
isNew?: boolean;
};

export function ActivityCard({ activity, onPress, isNew }: ActivityCardProps) {
const hasThumbnail = !!activity.thumbnail_url;
const goingCount = activity.going_count ?? 0;
const goingUsers = activity.going_users ?? [];

return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({ opacity: pressed ? 0.97 : 1 })}
>
<Box style={styles.card}>
{/* Thumbnail */}
{hasThumbnail && (
<Box style={styles.thumbnailContainer}>
<Image
source={{ uri: activity.thumbnail_url! }}
style={styles.thumbnail}
contentFit="cover"
/>
{isNew && (
<Box style={styles.newBadge}>
<Text variant="bodyXxsMedium" color="white">
New • 1m
</Text>
</Box>
)}
</Box>
Comment on lines +37 to +44
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.

⚠️ Potential issue | 🟡 Minor

Hardcoded placeholder text when isNew is true.

Lines 40 and 101 display static strings ("New • 1m", "1 new comment") that don't reflect actual activity data. If isNew is meant to indicate genuinely new activities, the badge timestamp and comment count should come from the activity model. If these are intentional placeholders, consider removing them until the feature is implemented.

Also applies to: 98-104

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`(app)/trips/[id]/activities/components/activity-card.tsx around
lines 37 - 44, The "New • 1m" badge and "1 new comment" text are hardcoded
placeholders; update the ActivityCard to derive these from the activity model
instead of static strings: use isNew together with activity.createdAt to render
a relative timestamp (e.g., formatDistanceToNow(activity.createdAt) or your
app's date util) for the badge text, and replace "1 new comment" with the actual
count from activity.commentCount or activity.comments?.length (render nothing or
hide the element if the value is undefined/zero). If these UI bits are
intentionally not implemented yet, remove the placeholder strings and the
conditional render blocks around them (isNew badge and comment-count span) until
real data is available. Ensure you update the JSX locations referencing isNew,
activity.createdAt, activity.commentCount, and activity.comments accordingly.

)}

{/* Content */}
<Box style={styles.content}>
<Box
flexDirection="row"
alignItems="center"
justifyContent="space-between"
>
<Text
variant="bodySmMedium"
color="gray900"
style={{ flex: 1 }}
numberOfLines={1}
>
{activity.name}
</Text>
<Box flexDirection="row" alignItems="center" gap="xs">
{goingCount > 0 && (
<Box flexDirection="row" alignItems="center" gap="xxs">
<Heart
size={14}
color={ColorPalette.statusError}
fill={ColorPalette.statusError}
/>
<Text variant="bodyXsMedium" color="gray500">
{goingCount}
</Text>
</Box>
)}
</Box>
</Box>

{activity.estimated_price != null && (
<Text variant="bodyXsDefault" color="gray500">
${activity.estimated_price} per person
</Text>
)}

{goingUsers.length > 0 && (
<Box flexDirection="row" alignItems="center" 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>
)}
</Box>

{/* Comments row — placeholder, comments ticket is separate */}
{isNew && (
<Box style={styles.commentRow}>
<Text variant="bodyXsMedium" color="blue500">
1 new comment
</Text>
</Box>
)}
</Box>
</Pressable>
);
}

const styles = StyleSheet.create({
card: {
backgroundColor: ColorPalette.white,
borderRadius: CornerRadius.lg,
overflow: "hidden",
...Elevation.xs,
},
thumbnailContainer: {
height: 160,
width: "100%",
position: "relative",
},
thumbnail: {
width: "100%",
height: "100%",
},
newBadge: {
position: "absolute",
top: Layout.spacing.xs,
left: Layout.spacing.xs,
backgroundColor: ColorPalette.brand500,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: CornerRadius.sm,
},
content: {
padding: Layout.spacing.xs,
gap: 4,
},
commentRow: {
paddingHorizontal: Layout.spacing.xs,
paddingVertical: Layout.spacing.xs,
backgroundColor: ColorPalette.blue50,
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { parseActivityLink } from "@/api/activities";
import { Box, Button, Text, TextField, useToast } from "@/design-system";
import { ColorPalette } from "@/design-system/tokens/color";
import { CornerRadius } from "@/design-system/tokens/corner-radius";
import { Layout } from "@/design-system/tokens/layout";
import type { ModelsParsedActivityData } from "@/types/types.gen";
import { Link } from "lucide-react-native";
import { forwardRef, useImperativeHandle, useState } from "react";
import {
Image,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
StyleSheet,
} from "react-native";

// ─── Types ───────────────────────────────────────────────────────────────────

type AddActivityEntrySheetProps = {
tripID: string;
onManual: () => void;
onAutofilled: (data: ModelsParsedActivityData) => void;
onClose: () => void;
};

export type AddActivityEntrySheetHandle = {
open: () => void;
close: () => void;
};

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

export const AddActivityEntrySheet = forwardRef<
AddActivityEntrySheetHandle,
AddActivityEntrySheetProps
>(({ tripID, onManual, onAutofilled, onClose }, ref) => {
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 = () => {
setVisible(false);
setUrl("");
onClose();
};
Comment on lines +51 to +55
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.

⚠️ Potential issue | 🟠 Major

Do not apply autofill results after the sheet is dismissed.

Backdrop close stays active while parseActivityLink is in flight. If the user dismisses the sheet, the pending request can still resolve and call onAutofilled, reopening the manual flow after the user canceled. Disable dismissal during autofill or ignore late responses with a request token/cancel flag.

Also applies to: 63-75, 85-85

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@frontend/app/`(app)/trips/[id]/activities/components/add-activity-entry-sheet.tsx
around lines 51 - 55, The sheet can be dismissed while parseActivityLink is
still running, allowing its late resolution to call onAutofilled and reopen the
manual flow; update the flow around parseActivityLink/handleClose to prevent
this by adding a request token or cancel flag (e.g., a local incrementing
requestId or AbortController) that is set when starting parseActivityLink and
checked before calling onAutofilled, and ensure handleClose sets the cancel flag
(or increments a token) and clears state via setVisible, setUrl, onClose so any
in-flight response is ignored; alternatively disable backdrop/close while an
autofill promise is pending by tracking an isAutofilling boolean and preventing
dismissal in handleClose and the backdrop handler until it is false (apply same
guard where parseActivityLink resolves and where onAutofilled is invoked).


const handleManual = () => {
setVisible(false);
setUrl("");
onManual();
};

const handleAutofill = async () => {
if (!url.trim()) return;
setIsAutofilling(true);
try {
const data = await parseActivityLink(tripID, { url: url.trim() });
setIsAutofilling(false);
setVisible(false);
setUrl("");
onAutofilled(data);
} catch {
setIsAutofilling(false);
toast.show({ message: "Couldn't fetch that link. Try adding manually." });
}
};

return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={handleClose}
>
<Pressable style={styles.backdrop} onPress={handleClose}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.sheetWrapper}
>
{/* Prevent backdrop tap from closing when tapping sheet */}
<Pressable style={styles.sheet} onPress={() => {}}>
{/* Handle */}
<Box alignItems="center" paddingTop="sm" paddingBottom="xs">
<Box style={styles.handle} />
</Box>

<Box padding="sm" gap="md" alignItems="center">
{/* Illustration */}
<Image
source={require("@/assets/images/binoculars.png")}
style={{ width: 80, height: 80 }}
resizeMode="contain"
/>

{/* Header — changes during autofill */}
<Box gap="xxs" alignItems="center">
<Text variant="headingSm" color="gray900">
{isAutofilling
? "Fetching listing details..."
: "Add an activity"}
</Text>
<Text
variant="bodySmDefault"
color="gray500"
textAlign="center"
>
{isAutofilling
? "Hang tight while we pull the details from your link. This only takes a second or two."
: "Easily import from yelp, instagram, tiktok, etc."}
</Text>
</Box>

{/* URL input */}
<Box style={{ width: "100%" }}>
<TextField
value={url}
onChangeText={setUrl}
placeholder="https://..."
leftIcon={<Link size={16} color={ColorPalette.gray400} />}
autoCapitalize="none"
keyboardType="url"
/>
</Box>

{/* Autofill button */}
<Box style={{ width: "100%" }}>
<Button
layout="textOnly"
label={isAutofilling ? "Autofilling..." : "Autofill from link"}
variant="Primary"
loading={isAutofilling}
disabled={!url.trim() || isAutofilling}
onPress={handleAutofill}
/>
</Box>

{/* Manual fallback */}
<Box style={{ width: "100%" }}>
<Button
layout="textOnly"
label="Add manually"
variant="Secondary"
disabled={isAutofilling}
onPress={handleManual}
/>
</Box>
</Box>
</Pressable>
</KeyboardAvoidingView>
</Pressable>
</Modal>
);
});

AddActivityEntrySheet.displayName = "AddActivityEntrySheet";

// ─── 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: CornerRadius.lg,
borderTopRightRadius: CornerRadius.lg,
paddingBottom: Layout.spacing.xl,
},
handle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: ColorPalette.gray300,
},
});
Loading
Loading