📖 Overview
Di bagian ini, kita akan membangun fitur manajemen event:
- ✅ Event list dengan pull-to-refresh
- ✅ Event card component yang reusable
- ✅ Event detail screen dengan tab (Info & Peserta)
- ✅ Participant list dengan status kehadiran
🃏 Step 1: Buat EventCard Component
Komponen reusable untuk menampilkan event di list.
File: components/EventCard.tsx
import React from 'react';
import { View, Text, TouchableOpacity, Image } from 'react-native';
import { Calendar, MapPin, Users, CheckCircle } from 'lucide-react-native';
import { router } from 'expo-router';
import { Event } from '@/services/events';
interface EventCardProps {
event: Event;
}
export function EventCard({ event }: EventCardProps) {
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
};
const handlePress = () => {
router.push(`/event/${event.id}`);
};
return (
<TouchableOpacity
className="bg-gray-800 rounded-2xl overflow-hidden mb-4"
onPress={handlePress}
activeOpacity={0.8}
>
{/* Event Image */}
{event.imageUrl ? (
<Image
source={{ uri: event.imageUrl }}
className="w-full h-40"
resizeMode="cover"
/>
) : (
<View className="w-full h-40 bg-gray-700 items-center justify-center">
<Text className="text-gray-500 text-lg">No Image</Text>
</View>
)}
{/* Content */}
<View className="p-4">
{/* Title */}
<Text className="text-white text-lg font-bold mb-2" numberOfLines={2}>
{event.title}
</Text>
{/* Date */}
<View className="flex-row items-center mb-2">
<Calendar size={16} color="#f0880a" />
<Text className="text-gray-400 ml-2 text-sm">
{formatDate(event.date)}
</Text>
</View>
{/* Location */}
<View className="flex-row items-center mb-3">
<MapPin size={16} color="#f0880a" />
<Text className="text-gray-400 ml-2 text-sm" numberOfLines={1}>
{event.location}
</Text>
</View>
{/* Stats */}
<View className="flex-row justify-between pt-3 border-t border-gray-700">
{/* Registrations */}
<View className="flex-row items-center">
<Users size={16} color="#9BA1A6" />
<Text className="text-gray-400 ml-2 text-sm">
{event.registrationCount} terdaftar
</Text>
</View>
{/* Attendance */}
<View className="flex-row items-center">
<CheckCircle size={16} color="#22c55e" />
<Text className="text-green-500 ml-2 text-sm">
{event.attendanceCount} hadir
</Text>
</View>
</View>
</View>
</TouchableOpacity>
);
}
📋 Step 2: Buat Home Screen (Event List)
File: app/(tabs)/index.tsx
import React, { useEffect, useState, useCallback } from 'react';
import {
View,
Text,
FlatList,
RefreshControl,
ActivityIndicator,
} from 'react-native';
import { getAdminEvents, Event } from '@/services/events';
import { EventCard } from '@/components/EventCard';
import { useAuthStore } from '@/store/auth';
export default function HomeScreen() {
const [events, setEvents] = useState<Event[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const { user } = useAuthStore();
const fetchEvents = async (showRefreshIndicator = false) => {
if (showRefreshIndicator) {
setIsRefreshing(true);
} else {
setIsLoading(true);
}
setError(null);
try {
const response = await getAdminEvents();
if (response.success) {
setEvents(response.data.events);
} else {
setError('Gagal mengambil data events');
}
} catch (err: any) {
console.error('Error fetching events:', err);
setError(err.message || 'Terjadi kesalahan');
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
};
useEffect(() => {
fetchEvents();
}, []);
const onRefresh = useCallback(() => {
fetchEvents(true);
}, []);
// Loading state
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-secondary">
<ActivityIndicator size="large" color="#f0880a" />
<Text className="text-gray-400 mt-4">Memuat events...</Text>
</View>
);
}
// Error state
if (error) {
return (
<View className="flex-1 items-center justify-center bg-secondary px-4">
<Text className="text-red-500 text-center mb-4">{error}</Text>
<Text
className="text-primary underline"
onPress={() => fetchEvents()}
>
Coba lagi
</Text>
</View>
);
}
return (
<View className="flex-1 bg-secondary">
{/* Header */}
<View className="px-4 pt-4 pb-2">
<Text className="text-white text-2xl font-bold">
Halo, {user?.name?.split(' ')[0]}! 👋
</Text>
<Text className="text-gray-400 mt-1">
{events.length} event tersedia
</Text>
</View>
{/* Event List */}
<FlatList
data={events}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <EventCard event={item} />}
contentContainerStyle={{ padding: 16 }}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColor="#f0880a"
colors={['#f0880a']}
/>
}
ListEmptyComponent={
<View className="items-center py-20">
<Text className="text-gray-500 text-lg">Belum ada event</Text>
</View>
}
/>
</View>
);
}
📄 Step 3: Buat Event Detail Screen
File: app/event/[id].tsx
import React, { useEffect, useState } from 'react';
import {
View,
Text,
ScrollView,
Image,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { useLocalSearchParams, Stack } from 'expo-router';
import { Calendar, MapPin, Users, CheckCircle, Clock } from 'lucide-react-native';
import { getEventDetail } from '@/services/events';
interface Participant {
id: string;
name: string;
email: string;
checkedInAt: string | null;
}
interface EventDetail {
id: string;
title: string;
description: string;
date: string;
time: string;
location: string;
imageUrl?: string;
registrationCount: number;
attendanceCount: number;
participants: Participant[];
}
export default function EventDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const [event, setEvent] = useState<EventDetail | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'info' | 'participants'>('info');
useEffect(() => {
const fetchDetail = async () => {
try {
const response = await getEventDetail(id);
if (response.success) {
setEvent(response.data.event);
}
} catch (error) {
console.error('Error fetching event detail:', error);
} finally {
setIsLoading(false);
}
};
fetchDetail();
}, [id]);
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
};
const formatTime = (dateString: string) => {
return new Date(dateString).toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
});
};
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-secondary">
<ActivityIndicator size="large" color="#f0880a" />
</View>
);
}
if (!event) {
return (
<View className="flex-1 items-center justify-center bg-secondary">
<Text className="text-gray-400">Event tidak ditemukan</Text>
</View>
);
}
return (
<>
<Stack.Screen
options={{
title: event.title,
headerStyle: { backgroundColor: '#1a1a1a' },
headerTintColor: '#fff',
}}
/>
<View className="flex-1 bg-secondary">
{/* Image */}
{event.imageUrl && (
<Image
source={{ uri: event.imageUrl }}
className="w-full h-48"
resizeMode="cover"
/>
)}
{/* Tabs */}
<View className="flex-row border-b border-gray-700">
<TouchableOpacity
className={`flex-1 py-4 ${
activeTab === 'info' ? 'border-b-2 border-primary' : ''
}`}
onPress={() => setActiveTab('info')}
>
<Text
className={`text-center font-semibold ${
activeTab === 'info' ? 'text-primary' : 'text-gray-400'
}`}
>
Info Event
</Text>
</TouchableOpacity>
<TouchableOpacity
className={`flex-1 py-4 ${
activeTab === 'participants' ? 'border-b-2 border-primary' : ''
}`}
onPress={() => setActiveTab('participants')}
>
<Text
className={`text-center font-semibold ${
activeTab === 'participants' ? 'text-primary' : 'text-gray-400'
}`}
>
Peserta ({event.participants?.length || 0})
</Text>
</TouchableOpacity>
</View>
{activeTab === 'info' ? (
<ScrollView className="flex-1 px-4 py-4">
{/* Title */}
<Text className="text-white text-2xl font-bold mb-4">
{event.title}
</Text>
{/* Stats */}
<View className="flex-row mb-6">
<View className="flex-row items-center mr-6">
<Users size={18} color="#9BA1A6" />
<Text className="text-gray-400 ml-2">
{event.registrationCount} terdaftar
</Text>
</View>
<View className="flex-row items-center">
<CheckCircle size={18} color="#22c55e" />
<Text className="text-green-500 ml-2">
{event.attendanceCount} hadir
</Text>
</View>
</View>
{/* Date & Time */}
<View className="bg-gray-800 rounded-xl p-4 mb-4">
<View className="flex-row items-center mb-3">
<Calendar size={20} color="#f0880a" />
<Text className="text-white ml-3">
{formatDate(event.date)}
</Text>
</View>
<View className="flex-row items-center mb-3">
<Clock size={20} color="#f0880a" />
<Text className="text-white ml-3">{event.time} WIB</Text>
</View>
<View className="flex-row items-center">
<MapPin size={20} color="#f0880a" />
<Text className="text-white ml-3">{event.location}</Text>
</View>
</View>
{/* Description */}
<Text className="text-gray-300 leading-6">
{event.description}
</Text>
</ScrollView>
) : (
<ParticipantList participants={event.participants || []} />
)}
</View>
</>
);
}
// Participant List Component
function ParticipantList({ participants }: { participants: Participant[] }) {
return (
<ScrollView className="flex-1 px-4 py-4">
{participants.length === 0 ? (
<View className="items-center py-20">
<Text className="text-gray-500">Belum ada peserta terdaftar</Text>
</View>
) : (
participants.map((participant) => (
<View
key={participant.id}
className="flex-row items-center bg-gray-800 rounded-xl p-4 mb-3"
>
{/* Avatar */}
<View className="w-12 h-12 rounded-full bg-gray-700 items-center justify-center">
<Text className="text-white text-lg font-bold">
{participant.name.charAt(0).toUpperCase()}
</Text>
</View>
{/* Info */}
<View className="flex-1 ml-3">
<Text className="text-white font-semibold">
{participant.name}
</Text>
<Text className="text-gray-400 text-sm" numberOfLines={1}>
{participant.email}
</Text>
</View>
{/* Status */}
{participant.checkedInAt ? (
<View className="flex-row items-center">
<CheckCircle size={20} color="#22c55e" />
<Text className="text-green-500 text-xs ml-1">
{new Date(participant.checkedInAt).toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</View>
) : (
<View className="bg-gray-700 px-3 py-1 rounded-full">
<Text className="text-gray-400 text-xs">Belum hadir</Text>
</View>
)}
</View>
))
)}
</ScrollView>
);
}
🔄 Step 4: Update Event Service untuk Detail
Tambahkan response type untuk detail event.
Update: services/events.ts
// Tambahkan di akhir file
export interface Participant {
id: string;
name: string;
email: string;
checkedInAt: string | null;
}
export interface EventDetailResponse {
success: boolean;
data: {
event: {
id: string;
title: string;
description: string;
date: string;
time: string;
location: string;
imageUrl?: string;
registrationCount: number;
attendanceCount: number;
participants: Participant[];
};
};
}
export const getEventDetail = async (id: string): Promise<EventDetailResponse> => {
const response = await api.get(`/admin/events/${id}`);
return response.data;
};
🧩 Step 5: Buat Loading Spinner Component
File: components/LoadingSpinner.tsx
import React from 'react';
import { View, ActivityIndicator, Text } from 'react-native';
interface LoadingSpinnerProps {
message?: string;
}
export function LoadingSpinner({ message = 'Memuat...' }: LoadingSpinnerProps) {
return (
<View className="flex-1 items-center justify-center bg-secondary">
<ActivityIndicator size="large" color="#f0880a" />
<Text className="text-gray-400 mt-4">{message}</Text>
</View>
);
}
✅ Checklist
- EventCard component dibuat
- Home screen dengan event list dibuat
- Event detail screen dengan tabs dibuat
- Participant list dengan status dibuat
- Pull-to-refresh berfungsi
🚀 Selanjutnya
Di Part 6, kita akan membuat fitur QR Scanner untuk mencatat kehadiran peserta dengan feedback animasi dan error handling.
← Part 4: Autentikasi | Part 6: QR Scanner →
Series ini dibuat untuk mendukung materi Casual Meetup #15 di LampungDev.
