501 lines
17 KiB
TypeScript
501 lines
17 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
||
import {
|
||
View,
|
||
Text,
|
||
Image,
|
||
ScrollView,
|
||
ActivityIndicator,
|
||
TouchableOpacity,
|
||
Dimensions,
|
||
} from "react-native";
|
||
import ScreenWrapper from "~/components/ui/ScreenWrapper";
|
||
import { Button } from "~/components/ui/button";
|
||
import { Icons } from "~/assets/icons";
|
||
import { Share } from "react-native";
|
||
import ModalToast from "~/components/ui/toast";
|
||
import { useTranslation } from "react-i18next";
|
||
import { router, useLocalSearchParams } from "expo-router";
|
||
import BackButton from "~/components/ui/backButton";
|
||
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
|
||
import { EventService, type EventDto } from "~/lib/services/eventService";
|
||
import BottomSheet from "~/components/ui/bottomSheet";
|
||
import { ROUTES } from "~/lib/routes";
|
||
import { awardPoints } from "~/lib/services/pointsService";
|
||
|
||
export default function EventDetailScreen() {
|
||
const { t } = useTranslation();
|
||
const params = useLocalSearchParams<{ id?: string }>();
|
||
const { user } = useAuthWithProfile();
|
||
const [event, setEvent] = useState<EventDto | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
||
const [selectedTierId, setSelectedTierId] = useState<string | null>(null);
|
||
const [toastVisible, setToastVisible] = useState(false);
|
||
const [toastTitle, setToastTitle] = useState("");
|
||
const [toastDescription, setToastDescription] = useState<
|
||
string | undefined
|
||
>();
|
||
const [toastVariant, setToastVariant] = useState<
|
||
"success" | "error" | "warning" | "info"
|
||
>("info");
|
||
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const screenWidth = Dimensions.get("window").width;
|
||
const [buySheetVisible, setBuySheetVisible] = useState(false);
|
||
const [ticketCount, setTicketCount] = useState(1);
|
||
|
||
const showToast = (
|
||
title: string,
|
||
description?: string,
|
||
variant: "success" | "error" | "warning" | "info" = "info"
|
||
) => {
|
||
if (toastTimeoutRef.current) {
|
||
clearTimeout(toastTimeoutRef.current);
|
||
}
|
||
|
||
setToastTitle(title);
|
||
setToastDescription(description);
|
||
setToastVariant(variant);
|
||
setToastVisible(true);
|
||
|
||
toastTimeoutRef.current = setTimeout(() => {
|
||
setToastVisible(false);
|
||
toastTimeoutRef.current = null;
|
||
}, 2500);
|
||
};
|
||
|
||
const handleShare = async () => {
|
||
try {
|
||
await Share.share({
|
||
message: t("eventdetail.shareMessage"),
|
||
});
|
||
|
||
try {
|
||
await awardPoints("share_event");
|
||
} catch (error) {
|
||
console.warn("[EventDetail] Failed to award share event points", error);
|
||
}
|
||
} catch (error) {
|
||
console.log("Error sharing event:", error);
|
||
showToast(
|
||
t("eventdetail.toastErrorTitle"),
|
||
t("eventdetail.toastShareError"),
|
||
"error"
|
||
);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
const loadEvent = async () => {
|
||
if (!user || !params.id) return;
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const token = await user.getIdToken();
|
||
const eventData = await EventService.getEventById(
|
||
token,
|
||
String(params.id)
|
||
);
|
||
if (!cancelled) {
|
||
setEvent(eventData);
|
||
// Debug images and description to verify response
|
||
console.log("[EventDetail] event images", eventData?.images);
|
||
console.log(
|
||
"[EventDetail] event description",
|
||
eventData?.description
|
||
);
|
||
}
|
||
} catch (err) {
|
||
if (!cancelled) {
|
||
setError("Failed to load event details");
|
||
}
|
||
} finally {
|
||
if (!cancelled) {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
loadEvent();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [user, params.id]);
|
||
|
||
const fallbackImage =
|
||
"https://images.pexels.com/photos/1190297/pexels-photo-1190297.jpeg?auto=compress&cs=tinysrgb&w=800";
|
||
|
||
const images =
|
||
event?.images && event.images.length > 0 ? event.images : [fallbackImage];
|
||
|
||
const startDate = event?.startDate ? new Date(event.startDate) : null;
|
||
const endDate = event?.endDate ? new Date(event.endDate) : null;
|
||
|
||
const formatEventDateRange = (start: Date, end: Date) => {
|
||
// Format like: Sat, Jan 10, 2026 at 6:00 PM – 1:00 AM (EAT)
|
||
const datePart = start.toLocaleDateString("en-US", {
|
||
weekday: "short",
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
});
|
||
|
||
const startTime = start.toLocaleTimeString("en-US", {
|
||
hour: "numeric",
|
||
minute: "2-digit",
|
||
});
|
||
|
||
const endTime = end.toLocaleTimeString("en-US", {
|
||
hour: "numeric",
|
||
minute: "2-digit",
|
||
});
|
||
|
||
return `${datePart} at ${startTime} – ${endTime} (EAT)`;
|
||
};
|
||
|
||
const formattedDate =
|
||
startDate && endDate
|
||
? formatEventDateRange(startDate, endDate)
|
||
: t("eventdetail.dateTime");
|
||
|
||
const handleBuy = () => {
|
||
if (!event || !selectedTierId) {
|
||
showToast(
|
||
"Ticket selection required",
|
||
"Please select a ticket tier first",
|
||
"error"
|
||
);
|
||
return;
|
||
}
|
||
|
||
const tier = event.ticketTiers?.find((t: any) => t.id === selectedTierId);
|
||
if (!tier) {
|
||
showToast(
|
||
"Ticket selection required",
|
||
"Please select a ticket tier first",
|
||
"error"
|
||
);
|
||
return;
|
||
}
|
||
|
||
console.log("[EventDetail] Buy pressed", {
|
||
eventId: event.id,
|
||
selectedTierId,
|
||
});
|
||
|
||
setTicketCount(1);
|
||
setBuySheetVisible(true);
|
||
};
|
||
|
||
const handleConfirmBuy = () => {
|
||
if (!event || !selectedTierId) {
|
||
showToast(
|
||
"Ticket selection required",
|
||
"Please select a ticket tier first",
|
||
"error"
|
||
);
|
||
return;
|
||
}
|
||
|
||
const tier = event.ticketTiers?.find((t: any) => t.id === selectedTierId);
|
||
if (!tier) {
|
||
showToast(
|
||
"Ticket selection required",
|
||
"Please select a ticket tier first",
|
||
"error"
|
||
);
|
||
return;
|
||
}
|
||
|
||
const priceNumber = Number(tier.price || 0);
|
||
const amountInCents = Math.round(priceNumber * 100 * ticketCount);
|
||
|
||
setBuySheetVisible(false);
|
||
|
||
router.push({
|
||
pathname: ROUTES.CHECKOUT,
|
||
params: {
|
||
amount: String(amountInCents),
|
||
type: "event_ticket",
|
||
ticketTierName: tier.name,
|
||
ticketTierPrice: priceNumber.toFixed(2),
|
||
eventName: event.name,
|
||
eventId: event.id,
|
||
ticketTierId: tier.id,
|
||
ticketCount: String(ticketCount),
|
||
},
|
||
});
|
||
};
|
||
|
||
return (
|
||
<ScreenWrapper edges={[]}>
|
||
<View className="flex-1 bg-white">
|
||
<ScrollView
|
||
className="flex-1"
|
||
contentContainerStyle={{ paddingBottom: 32 }}
|
||
showsVerticalScrollIndicator={false}
|
||
>
|
||
<View className="">
|
||
<BackButton />
|
||
<Text className="mt-4 text-lg px-4 font-dmsans-bold text-[#0F7B4A]">
|
||
{event?.name ?? t("eventdetail.title")}
|
||
</Text>
|
||
</View>
|
||
|
||
<View className="px-4 pt-4">
|
||
{loading && (
|
||
<View className="flex items-center justify-center py-8">
|
||
<ActivityIndicator size="small" color="#0F7B4A" />
|
||
<Text className="mt-2 text-gray-500 font-dmsans text-sm">
|
||
Loading event details...
|
||
</Text>
|
||
</View>
|
||
)}
|
||
|
||
{!loading && error && (
|
||
<View className="flex items-center justify-center py-8">
|
||
<Text className="text-red-500 font-dmsans text-sm mb-2">
|
||
{error}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
|
||
{!loading && !error && (
|
||
<>
|
||
<Text className="text-sm font-dmsans text-[#4B5563] mb-4 leading-5">
|
||
{event?.description ?? t("eventdetail.description")}
|
||
</Text>
|
||
|
||
<Text className="text-sm font-dmsans text-[#111827] mb-1">
|
||
{event?.venue ?? t("eventdetail.location")}
|
||
</Text>
|
||
<Text className="text-sm font-dmsans-bold text-[#0F7B4A] mb-4">
|
||
{formattedDate}
|
||
</Text>
|
||
|
||
<View className="w-full mb-4">
|
||
<ScrollView
|
||
horizontal
|
||
pagingEnabled
|
||
showsHorizontalScrollIndicator={false}
|
||
snapToInterval={screenWidth}
|
||
decelerationRate="fast"
|
||
onMomentumScrollEnd={(e) => {
|
||
const { contentOffset, layoutMeasurement } =
|
||
e.nativeEvent;
|
||
const index = Math.round(
|
||
contentOffset.x / layoutMeasurement.width
|
||
);
|
||
setCurrentImageIndex(index);
|
||
}}
|
||
>
|
||
{images.map((img, index) => (
|
||
<View
|
||
key={index}
|
||
style={{ width: screenWidth, height: 192 }}
|
||
className="rounded-[4px] overflow-hidden bg-gray-300"
|
||
>
|
||
<Image
|
||
source={{ uri: img }}
|
||
style={{ width: "100%", height: "100%" }}
|
||
resizeMode="cover"
|
||
/>
|
||
</View>
|
||
))}
|
||
</ScrollView>
|
||
|
||
<View className="flex-row justify-center mt-2 space-x-1">
|
||
{images.map((_, index) => (
|
||
<View
|
||
key={index}
|
||
className={`w-2 h-2 mr-1 rounded-full ${
|
||
index === currentImageIndex
|
||
? "bg-[#0F7B4A]"
|
||
: "bg-gray-300"
|
||
}`}
|
||
/>
|
||
))}
|
||
</View>
|
||
</View>
|
||
|
||
<View className="flex-row items-center mb-6">
|
||
<View className="flex-row -space-x-2 mr-3">
|
||
{[0, 1, 2, 3, 4].map((i) => (
|
||
<View
|
||
key={i}
|
||
className="w-8 h-8 rounded-full overflow-hidden border border-white -ml-2 first:ml-0"
|
||
>
|
||
<Image
|
||
source={Icons.profileImage}
|
||
style={{ width: 28, height: 28 }}
|
||
resizeMode="cover"
|
||
/>
|
||
</View>
|
||
))}
|
||
</View>
|
||
<Text className="text-xs font-dmsans text-[#4B5563]">
|
||
{t("eventdetail.peopleComing")}
|
||
</Text>
|
||
</View>
|
||
|
||
{event?.ticketTiers && event.ticketTiers.length > 0 && (
|
||
<View className="flex flex-col gap-3 mb-4 pb-2">
|
||
{event.ticketTiers.map((tier: any) => {
|
||
const isSelected = tier.id === selectedTierId;
|
||
return (
|
||
<TouchableOpacity
|
||
key={tier.id}
|
||
activeOpacity={0.9}
|
||
onPress={() => setSelectedTierId(tier.id)}
|
||
>
|
||
<View
|
||
className={`flex-row items-center justify-between rounded-[8px] px-4 py-4 ${
|
||
isSelected ? "bg-[#E9F9F0]" : "bg-[#FFF4E5]"
|
||
}`}
|
||
>
|
||
<View className="flex-row items-center">
|
||
<View className="w-5 h-5 rounded-full border border-[#FFB668] items-center justify-center mr-3">
|
||
{isSelected && (
|
||
<View className="w-2.5 h-2.5 rounded-full bg-[#0F7B4A]" />
|
||
)}
|
||
</View>
|
||
<Text className="text-sm font-dmsans text-[#374151]">
|
||
{tier.name}
|
||
</Text>
|
||
</View>
|
||
<Text className="text-sm font-dmsans-medium text-[#0F7B4A]">
|
||
${tier.price}
|
||
</Text>
|
||
</View>
|
||
</TouchableOpacity>
|
||
);
|
||
})}
|
||
</View>
|
||
)}
|
||
</>
|
||
)}
|
||
</View>
|
||
</ScrollView>
|
||
</View>
|
||
<View className="px-4 pb-4 pt-2 border-t border-gray-100 bg-white">
|
||
<View className="flex flex-col gap-3">
|
||
<Button
|
||
className="h-11 rounded-full bg-[#0F7B4A]"
|
||
disabled={!selectedTierId}
|
||
onPress={handleBuy}
|
||
>
|
||
<Text className="text-white font-dmsans-medium text-sm">
|
||
{t("eventdetail.buyButton")}
|
||
</Text>
|
||
</Button>
|
||
|
||
<Button
|
||
className="h-11 rounded-full bg-[#FFB668]"
|
||
onPress={handleShare}
|
||
>
|
||
<Text className="text-white font-dmsans-medium text-sm">
|
||
{t("eventdetail.shareButton")}
|
||
</Text>
|
||
</Button>
|
||
</View>
|
||
</View>
|
||
<ModalToast
|
||
visible={toastVisible}
|
||
title={toastTitle}
|
||
description={toastDescription}
|
||
variant={toastVariant}
|
||
/>
|
||
<BottomSheet
|
||
visible={buySheetVisible}
|
||
onClose={() => setBuySheetVisible(false)}
|
||
maxHeightRatio={0.5}
|
||
>
|
||
{event && selectedTierId && (
|
||
<View>
|
||
<Text className="text-lg font-dmsans-bold text-black mb-1">
|
||
Confirm tickets
|
||
</Text>
|
||
<Text className="text-xs font-dmsans text-gray-500 mb-4">
|
||
{event.name}
|
||
</Text>
|
||
|
||
{(() => {
|
||
const tier = event.ticketTiers?.find(
|
||
(t: any) => t.id === selectedTierId
|
||
);
|
||
if (!tier) return null;
|
||
|
||
const priceNumber = Number(tier.price || 0);
|
||
const total = priceNumber * ticketCount;
|
||
|
||
return (
|
||
<>
|
||
<View className="flex-row justify-between items-center mb-4">
|
||
<View>
|
||
<Text className="text-base font-dmsans-medium text-black">
|
||
{tier.name}
|
||
</Text>
|
||
<Text className="text-xs font-dmsans text-gray-500 mt-1">
|
||
${priceNumber.toFixed(2)} per ticket
|
||
</Text>
|
||
</View>
|
||
<View className="items-end">
|
||
<Text className="text-xs font-dmsans text-gray-500 mb-1">
|
||
Total
|
||
</Text>
|
||
<Text className="text-lg font-dmsans-bold text-[#0F7B4A]">
|
||
${total.toFixed(2)}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<View className="flex-row items-center justify-between mb-6">
|
||
<Text className="text-sm font-dmsans text-black">
|
||
Ticket count
|
||
</Text>
|
||
<View className="flex-row items-center">
|
||
<TouchableOpacity
|
||
className="w-8 h-8 rounded-full border border-gray-300 items-center justify-center"
|
||
onPress={() =>
|
||
setTicketCount((c) => Math.max(1, c - 1))
|
||
}
|
||
>
|
||
<Text className="text-lg font-dmsans text-gray-700">
|
||
-
|
||
</Text>
|
||
</TouchableOpacity>
|
||
<Text className="mx-4 text-base font-dmsans text-black">
|
||
{ticketCount}
|
||
</Text>
|
||
<TouchableOpacity
|
||
className="w-8 h-8 rounded-full border border-gray-300 items-center justify-center"
|
||
onPress={() => setTicketCount((c) => c + 1)}
|
||
>
|
||
<Text className="text-lg font-dmsans text-gray-700">
|
||
+
|
||
</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
<Button
|
||
className="h-11 rounded-full bg-[#0F7B4A]"
|
||
onPress={handleConfirmBuy}
|
||
>
|
||
<Text className="text-white font-dmsans-medium text-sm">
|
||
Confirm purchase
|
||
</Text>
|
||
</Button>
|
||
</>
|
||
);
|
||
})()}
|
||
</View>
|
||
)}
|
||
</BottomSheet>
|
||
</ScreenWrapper>
|
||
);
|
||
}
|