1490 lines
48 KiB
TypeScript

import React, { useState, useEffect, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
Switch,
ScrollView,
TouchableOpacity,
Platform,
Alert,
Image,
ActivityIndicator,
Modal,
Dimensions,
TextInput, // Needed for the new ReviewForm
Button, // Needed for the new ReviewForm
Linking,
} from 'react-native';
import { Picker } from '@react-native-picker/picker';
import Slider from '@react-native-community/slider';
import { useRouter } from 'expo-router';
import * as Location from 'expo-location';
import ImageViewerModal from '../ImageViewerModal'; // Assuming this is still a separate file
// --- Interfaces ---
export interface ImageObject {
id?: string; // ID should be optional for frontend if not always present or generated by backend
url: string;
note?: string;
}
export interface Review {
ID: string;
Rating?: number; // Calculated average (quiet+clean+private)/3 from backend, optional if not always present
Quiet: number;
Clean: number;
Private: number;
Comment?: string; // Optional: if you add a comment column to your DB later
User?: string; // Optional: The reviewer's username, derived from a join with users table in backend
// NEW fields in Review from your provided code
CleanWudu?: number;
ChildFriendly?: number;
Safe?: number;
}
interface PrayerSpaceBase {
id: string;
name: string;
latitude: number;
longitude: number;
address: string;
type: 'Mosque' | 'Prayer Room' | 'Other' | 'Outdoor Space' | 'Multi-Faith Room';
womensSpace: boolean;
wudu: boolean;
openingHours: string;
clean: number; // Average cleanliness rating from reviews
// NEW fields in PrayerSpaceBase from your provided code
cleanWudu: number; // Average wudu cleanliness rating from reviews
quiet: number; // Average quietness rating from reviews
privateness: number; // Average privateness rating from reviews
childFriendly: number; // Average child friendly rating from reviews
safe: number; // Average safety rating from reviews
numberOfReviews: number;
images: ImageObject[];
notes?: string;
website: string;
reviews: Review[]; // Array of detailed review objects for this space
}
// Function to map backend data to frontend PrayerSpaceBase structure
function mapBackendSpaceToPrayerSpace(backendSpace: any): PrayerSpaceBase {
const { Reviews = [], Images = [] } = backendSpace;
const totalClean = Reviews.reduce((acc: number, r: Review) => acc + (r.Clean || 0), 0);
const totalCleanWudu = Reviews.reduce((acc: number, r: Review) => acc + (r.CleanWudu || 0), 0); // NEW
const totalQuiet = Reviews.reduce((acc: number, r: Review) => acc + (r.Quiet || 0), 0);
const totalPrivate = Reviews.reduce((acc: number, r: Review) => acc + (r.Private || 0), 0);
const totalChildFriendly = Reviews.reduce((acc: number, r: Review) => acc + (r.ChildFriendly || 0), 0); // NEW
const totalSafe = Reviews.reduce((acc: number, r: Review) => acc + (r.Safe || 0), 0); // NEW
const totalRating = Reviews.reduce((acc: number, r: Review) => acc + (r.Rating || 0), 0);
const averageClean = Reviews.length ? (totalClean / Reviews.length) : 0;
const averageCleanWudu = Reviews.length ? (totalCleanWudu / Reviews.length) : 0; // NEW
const averageQuiet = Reviews.length ? (totalQuiet / Reviews.length) : 0;
const averagePrivate = Reviews.length ? (totalPrivate / Reviews.length) : 0;
const averageChildFriendly = Reviews.length ? (totalChildFriendly / Reviews.length) : 0; // NEW
const averageSafe = Reviews.length ? (totalSafe / Reviews.length) : 0; // NEW
const averageOverallRating = Reviews.length ? (totalRating / Reviews.length) : 0;
return {
id: backendSpace.ID,
name: backendSpace.Name,
latitude: backendSpace.Latitude,
longitude: backendSpace.Longitude,
address: backendSpace.Address,
type: prayerSpaceTypeMap(backendSpace.LocationType), // Using prayerSpaceTypeMap
womensSpace: backendSpace.WomensSpace,
wudu: backendSpace.Wudu,
openingHours: '',
clean: averageClean,
cleanWudu: averageCleanWudu, // NEW
quiet: averageQuiet,
privateness: averagePrivate,
childFriendly: averageChildFriendly, // NEW
safe: averageSafe, // NEW
numberOfReviews: Reviews.length,
images: Images.map((img: any) => ({
id: img.ID,
url: convertToFullImageUrl(img.ImageURL), // Applying convertToFullImageUrl
note: img.Notes,
})) || [], // Ensure images is an array even if backend returns null/undefined
notes: backendSpace.Notes,
website: backendSpace.WebsiteURL,
reviews: Reviews,
};
}
// --- Image URL Conversion ---
function convertToFullImageUrl(imageUrl: string): string {
// If it's already a full URL (starts with http:// or https://), return as is
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
return imageUrl;
}
// If it's a server path, convert it to a full URL
// IMPORTANT: Replace this IP with your actual backend's accessible IP/domain if not running locally
// or if your phone/emulator cannot reach 132.145.65.145
return `http://132.145.65.145:8080${imageUrl}`;
}
// --- Emoji Rating Helpers ---
function averageRatingToEmoji(avg: number) {
if (avg <= 0.5) {
return "Unrated"; // If avg is 0 or less, return "Unrated"
}
if (avg < 1.66) {
return "😞"; // closer to 1 (sad)
} else if (avg < 2.33) {
return "😐"; // closer to 2 (neutral)
} else {
return "😊"; // closer to 3 (happy)
}
}
export function getListItemStyle(rating: number) {
return {
...styles.listItemText,
backgroundColor: getBackgroundColorForRating(rating),
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
marginBottom: 6,
};
}
function getBackgroundColorForRating(rating: number) {
if (typeof rating !== 'number' || rating <= 0.5) {
return '#e0e0e0'; // Darker gray for unrated (more visible)
}
if (rating < 1.66) {
return '#ffcdd2'; // More saturated light red for sad (😞)
} else if (rating < 2.33) {
return '#ffe0b2'; // More saturated light orange for neutral (😐)
} else {
return '#c8e6c9'; // More saturated light green for happy (😊)
}
}
function averageRatingToText(avg: number) {
if (avg <= 0.5) {
return ""
} else {
return avg.toFixed(1);
}
}
interface EmojiRatingSelectorProps {
label: string;
value: number | null;
onSelect: (value: number) => void;
style?: object; // Allow custom styles
}
function getReviewDetailStyle(rating: number) {
return {
...modalStyles.reviewDetail,
backgroundColor: getBackgroundColorForRating(rating),
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
marginBottom: 4,
};
}
const EmojiRatingSelector: React.FC<EmojiRatingSelectorProps> = ({ label, value, onSelect, style }) => {
const emojis = [
{ emoji: "😞", value: 1, label: "Poor" },
{ emoji: "😐", value: 2, label: "Okay" },
{ emoji: "😊", value: 3, label: "Great" }
];
return (
<View style={[styles.ratingContainer, style]}>
<Text style={styles.ratingLabel}>{label}</Text>
<View style={styles.emojiRow}>
{emojis.map((item) => (
<TouchableOpacity
key={item.value}
style={[
styles.emojiButton,
value === item.value && styles.selectedEmoji
]}
onPress={() => onSelect(item.value)}
>
<Text style={styles.emojiText}>{item.emoji}</Text>
<Text style={styles.emojiLabel}>{item.label}</Text>
</TouchableOpacity>
))}
</View>
</View>
);
};
// --- Prayer Space Type Map ---
// --- Prayer Space Type Map ---
// UPDATED: prayerSpaceTypeMap function to include new types for display
function prayerSpaceTypeMap(str: string): 'Mosque' | 'Prayer Room' | 'Community Space' | 'Other' | 'Outdoor Space' | 'Multi-Faith Room' {
switch (str) {
case "mosque":
return "Mosque"
case "outdoor_space": // NEW Type
return "Outdoor Space"
case "multi_faith_room": // NEW Type
return "Multi-Faith Room"
case "other":
return "Other"
default:
return "Other" // Fallback for unexpected values
}
}
interface PrayerSpaceWithDistance extends PrayerSpaceBase {
calculatedDistance: number | null;
}
interface Filters {
showWomensSpace: boolean;
showWudu: boolean;
minRating: number;
spaceType: ('Mosque' | 'Prayer Room' | 'Community Space' | 'All' | 'Other');
cleanRating?: number;
cleanWuduRating?: number;
quietRating?: number;
privatenessRating?: number;
childFriendlyRating?: number;
safeRating?: number;
}
// --- Haversine Distance Calculation ---
function getDistanceInKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371; // Radius of the earth in km
const dLat = deg2rad(lat2 - lat1);
const dLon = deg2rad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in km
}
function deg2rad(deg: number): number {
return deg * (Math.PI / 180);
}
// --- ReviewsModal Component (Inline) ---
interface ReviewsModalProps {
visible: boolean;
onClose: () => void;
reviews: Review[];
spaceName: string;
}
const { width, height } = Dimensions.get('window');
const ReviewsModal: React.FC<ReviewsModalProps> = ({ visible, onClose, reviews, spaceName }) => {
return (
<Modal
animationType="slide"
transparent={true}
visible={visible}
onRequestClose={onClose}
>
<View style={modalStyles.centeredView}>
<View style={modalStyles.modalView}>
<Text style={modalStyles.modalTitle}>Reviews for {spaceName}</Text>
{reviews.length === 0 ? (
<Text style={modalStyles.noReviewsText}>No reviews yet. Be the first to leave one!</Text>
) : (
<ScrollView style={modalStyles.reviewsScrollView}>
{reviews.map((review, index) => (
<View key={review.ID || `review-${index}`} style={modalStyles.reviewItem}>
{/* Assuming Rating field exists, if not, calculate here */}
{review.Rating !== undefined && review.Rating !== null && (
<Text style={modalStyles.reviewRating}>Overall Rating: {review.Rating.toFixed(1)}</Text>
)}
{/* Using new fields in review display */}
<Text style={getReviewDetailStyle(review.Clean)}>
Cleanliness: {typeof review.Clean === 'number' ? averageRatingToEmoji(review.Clean) : '—'}
</Text>
<Text style={getReviewDetailStyle(review.CleanWudu)}>
Wudu Cleanliness: {typeof review.CleanWudu === 'number' ? averageRatingToEmoji(review.CleanWudu) : '—'}
</Text>
<Text style={getReviewDetailStyle(review.Quiet)}>
Quietness: {typeof review.Quiet === 'number' ? averageRatingToEmoji(review.Quiet) : '—'}
</Text>
<Text style={getReviewDetailStyle(review.Private)}>
Privacy: {typeof review.Private === 'number' ? averageRatingToEmoji(review.Private) : '—'}
</Text>
<Text style={getReviewDetailStyle(review.ChildFriendly)}>
Child Friendliness: {typeof review.ChildFriendly === 'number' ? averageRatingToEmoji(review.ChildFriendly) : '—'}
</Text>
<Text style={getReviewDetailStyle(review.Safe)}>
Safety: {typeof review.Safe === 'number' ? averageRatingToEmoji(review.Safe) : '—'}
</Text>
{review.Comment && <Text style={modalStyles.reviewComment}>"{review.Comment}"</Text>}
{review.User && <Text style={modalStyles.reviewUser}> {review.User}</Text>}
</View>
))}
</ScrollView>
)}
<TouchableOpacity
style={modalStyles.closeButton}
onPress={onClose}
>
<Text style={modalStyles.closeButtonText}>Close</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};
const modalStyles = StyleSheet.create({
centeredView: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.6)',
},
modalView: {
margin: 20,
backgroundColor: '#FFEEE7', // modal background
borderRadius: 10,
padding: 25,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
width: width * 0.9,
maxHeight: height * 0.8,
},
modalTitle: {
fontSize: 22,
fontWeight: 'bold',
marginBottom: 20,
color: '#333',
textAlign: 'center',
},
reviewsScrollView: {
width: '100%',
maxHeight: height * 0.6,
marginBottom: 15,
},
reviewItem: {
backgroundColor: '#FFEEE7', // list item background
padding: 15,
borderRadius: 8,
marginBottom: 10,
borderWidth: 1,
borderColor: '#eee',
},
reviewRating: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 5,
color: '#007bff',
},
reviewDetail: {
fontSize: 14,
color: '#555',
marginBottom: 2,
},
reviewComment: {
fontSize: 14,
fontStyle: 'italic',
marginTop: 8,
color: '#666',
borderLeftWidth: 3,
borderLeftColor: '#ccc',
paddingLeft: 10,
},
reviewUser: {
fontSize: 12,
fontStyle: 'italic',
textAlign: 'right',
marginTop: 5,
color: '#888',
},
noReviewsText: {
fontSize: 16,
color: '#777',
textAlign: 'center',
paddingVertical: 20,
},
closeButton: {
backgroundColor: '#007bff',
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 25,
marginTop: 10,
minWidth: 120,
alignItems: 'center',
},
closeButtonText: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
},
});
// --- End ReviewsModal Component ---
// --- ReviewForm Component (Inline - sends strings, matching your working ReviewScreen.tsx) ---
interface ReviewFormProps {
placeName: string; // Now expecting place NAME string
onReviewSubmitted: () => void;
}
const HARDCODED_USERNAME = 'testuser'; // Hardcoded username string, matching your seed user
const ReviewForm: React.FC<ReviewFormProps> = ({ placeName, onReviewSubmitted }) => {
const [quiet, setQuiet] = useState<number | null>(null);
const [clean, setClean] = useState<number | null>(null);
const [privacy, setPrivacy] = useState<number | null>(null);
const [cleanWudu, setCleanWudu] = useState<number | null>(null); // NEW
const [childFriendly, setChildFriendly] = useState<number | null>(null); // NEW
const [safe, setSafe] = useState<number | null>(null); // NEW
const [comment, setComment] = useState(''); // Uncomment if you add comment to DB
// Updated isValidRating for new emoji scale (1-3)
const isValidRating = (val: number | null): boolean => {
return val !== null && val >= 1 && val <= 3;
};
const handleSubmit = async () => {
// Validate all NEW rating fields
if (!quiet || !clean || !privacy || !cleanWudu || !childFriendly || !safe) {
Alert.alert('Missing Info', 'Please fill in all rating fields.');
return;
}
// Validate all NEW rating fields with isValidRating
if (![quiet, clean, privacy, cleanWudu, childFriendly, safe].every(isValidRating)) {
Alert.alert('Invalid Input', 'Please select a rating for each category (1-3).');
return;
}
// Constructing the payload with username and place name strings
const payload = {
user: HARDCODED_USERNAME, // Hardcoded username string
place: placeName, // Place NAME string
quiet: quiet,
clean: clean,
private: privacy,
cleanWudu: cleanWudu, // NEW
childFriendly: childFriendly, // NEW
safe: safe, // NEW
notes: comment, // Uncomment if you add comment to DB
};
console.log("Sending review payload (strings and new ratings):", payload);
try {
const response = await fetch('http://132.145.65.145:8080/reviews/new', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Unknown error' }));
console.error("Backend error response:", errorData);
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
}
const data = await response.json();
Alert.alert('Success', data.message || 'Review submitted!');
// Reset form (including new fields)
setQuiet(null);
setClean(null);
setPrivacy(null);
setCleanWudu(null); // NEW
setChildFriendly(null); // NEW
setSafe(null); // NEW
onReviewSubmitted();
} catch (error: any) {
Alert.alert('Error', `Could not send review: ${error.message || error}`);
console.error("Review submission error:", error);
}
};
return (
<View style={styles.reviewFormContainer}>
<Text style={styles.reviewFormTitle}>Submit a Review for {placeName}</Text>
{/* Using EmojiRatingSelector for all ratings */}
<EmojiRatingSelector
label="Cleanliness Rating"
value={clean}
onSelect={setClean}
/>
<EmojiRatingSelector
label="Wudu Cleanliness Rating"
value={cleanWudu}
onSelect={setCleanWudu}
/>
<EmojiRatingSelector
label="Quietness Rating"
value={quiet}
onSelect={setQuiet}
/>
<EmojiRatingSelector
label="Privacy Rating"
value={privacy}
onSelect={setPrivacy}
/>
<EmojiRatingSelector
label="Child Friendliness Rating"
value={childFriendly}
onSelect={setChildFriendly}
/>
<EmojiRatingSelector
label="Safety Rating"
value={safe}
onSelect={setSafe}
/>
<TextInput
style={styles.input}
placeholder="Your comments (optional)"
value={comment}
onChangeText={setComment}
multiline
numberOfLines={4}
/>
<Button title="Submit Review" onPress={handleSubmit} />
<TouchableOpacity onPress={onReviewSubmitted} style={{ marginTop: 10 }}>
<Text style={{ color: '#007bff', textAlign: 'center' }}>Cancel</Text>
</TouchableOpacity>
</View>
);
};
// --- PrayerSpaceListItem Component ---
interface PrayerSpaceListItemProps {
space: PrayerSpaceWithDistance;
onRefreshPlaces: () => void;
onAddWebsite: (spaceId: string, currentWebsite: string | undefined) => void; // NEW: Pass the handler down
onSaveWebsite: (spaceId: string, newWebsite: string) => Promise<void>; // NEW: Pass the save handler down
}
const PrayerSpaceListItem: React.FC<PrayerSpaceListItemProps> = ({ space, onRefreshPlaces, onAddWebsite, onSaveWebsite }) => { // ADDED onAddWebsite, onSaveWebsite
const router = useRouter();
const [isImageViewerVisible, setIsImageViewerVisible] = useState(false);
const [selectedImageInitialIndex, setSelectedImageInitialIndex] = useState(0);
const [isReviewsModalVisible, setIsReviewsModalVisible] = useState(false);
const [showReviewForm, setShowReviewForm] = useState(false); // New state to control review form visibility
const handleShowReviews = () => {
setIsReviewsModalVisible(true);
};
const handleAddReview = () => {
// Navigate to the new AddReviewScreen
router.push({
pathname: '/add_review', // Path to the new AddReviewScreen
params: {
placeId: space.id, // Pass the place's UUID
placeName: space.name, // Pass the place's name for display
},
});
};
// This function is defined but not explicitly used in the provided code,
// it would typically be called when a review submission is completed
// and you want to close the form and refresh the list.
const handleReviewSubmittedAndClosed = () => {
setShowReviewForm(false);
onRefreshPlaces(); // Refresh the parent list to show new review count/averages
};
const handleShowOnMap = () => {
router.push({
pathname: '/(tabs)/map',
params: {
latitude: space.latitude.toString(),
longitude: space.longitude.toString(),
name: space.name,
},
});
};
// --- NEW: handleAddPhoto ---
const handleAddPhoto = () => {
// Navigate to the UploadTab, passing placeId and placeName as params
router.push({
pathname: '/upload', // Adjust this path if your UploadTab is routed differently
params: {
placeId: space.id, // Pass the actual UUID of the mosque
placeName: space.name // Pass the name for display in UploadTab
},
});
};
// --- END NEW ---
const openImageViewer = (index: number) => {
setSelectedImageInitialIndex(index);
setIsImageViewerVisible(true);
};
return (
<View style={styles.listItem}>
{space.images && space.images.length > 0 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.thumbnailScrollView}
contentContainerStyle={styles.thumbnailScrollViewContent}
>
{space.images.map((imgObj, index) => (
<TouchableOpacity key={imgObj.id || `img-${index}`} onPress={() => openImageViewer(index)}>
<Image source={{ uri: imgObj.url }} style={styles.thumbnailImage} />
</TouchableOpacity>
))}
</ScrollView>
)}
<Text style={styles.listItemName}>{space.name}</Text>
{
(space.website === '' || space.website === undefined) ? (
<TouchableOpacity onPress={() => onAddWebsite(space.id, undefined)}>
<Text style={[styles.listItemText, styles.addWebsiteLink]}>Add a website</Text>
</TouchableOpacity>
) : (
<TouchableOpacity onPress={() => Linking.openURL(space.website)}>
<Text style={[styles.listItemText, styles.websiteLink]}>
{space.website}
</Text>
</TouchableOpacity>
)
}
<Text style={styles.listItemText}>Type: {space.type}</Text>
<Text style={styles.listItemText}>Address: {space.address}</Text>
<Text style={styles.listItemText}>
Distance: {space.calculatedDistance !== null ? `${space.calculatedDistance.toFixed(1)} km` : 'Calculating...'}
</Text>
{/* Conditional rendering for stats or "No reviews" */}
{space.numberOfReviews > 0 ? (
<>
<Text style={getListItemStyle(space.clean)}>
Cleanliness: {typeof space.clean === 'number' ? averageRatingToEmoji(space.clean) : '—'}
</Text>
<Text style={getListItemStyle(space.cleanWudu)}>
Wudu Cleanliness: {typeof space.cleanWudu === 'number' ? averageRatingToEmoji(space.cleanWudu) : '—'}
</Text>
<Text style={getListItemStyle(space.quiet)}>
Quietness: {typeof space.quiet === 'number' ? averageRatingToEmoji(space.quiet) : '—'}
</Text>
<Text style={getListItemStyle(space.privateness)}>
Privacy: {typeof space.privateness === 'number' ? averageRatingToEmoji(space.privateness) : '—'}
</Text>
<Text style={getListItemStyle(space.childFriendly)}>
Child Friendliness: {typeof space.childFriendly === 'number' ? averageRatingToEmoji(space.childFriendly) : '—'}
</Text>
<Text style={getListItemStyle(space.safe)}>
Safety: {typeof space.safe === 'number' ? averageRatingToEmoji(space.safe) : '—'}
</Text>
</>
) : (
<Text style={styles.noReviewsText}>No reviews yet.</Text>
)}
{space.notes && <Text style={styles.listItemNotes}>Note: {space.notes}</Text>}
{/* Button Row for Reviews and Map */}
<View style={styles.buttonRowContainer}>
<TouchableOpacity
style={[styles.splitButtonBase, styles.splitButtonLeft]}
onPress={handleShowReviews}
>
<Text style={styles.splitButtonText}>Show Reviews</Text>
</TouchableOpacity>
<View style={styles.buttonSeparator} />
<TouchableOpacity
style={[styles.splitButtonBase, styles.splitButtonRight]}
onPress={handleShowOnMap}
>
<Text style={styles.splitButtonText}>Show on Map</Text>
</TouchableOpacity>
</View>
{/* New Button to Add Review */}
{!showReviewForm && ( // Only show "Add Review" button if form is not visible
<TouchableOpacity style={styles.addReviewButton} onPress={handleAddReview}>
<Text style={styles.addReviewButtonText}>Add a Review</Text>
</TouchableOpacity>
)}
{/* NEW: Add Photo Button */}
<TouchableOpacity style={styles.addPhotoButton} onPress={handleAddPhoto}>
<Text style={styles.addPhotoButtonText}>Add a Photo</Text>
</TouchableOpacity>
{/* END NEW */}
{/* Render the ReviewForm if showReviewForm is true */}
{showReviewForm && (
<ReviewForm
placeName={space.name} // Pass place NAME
onReviewSubmitted={handleReviewSubmittedAndClosed}
/>
)}
{/* ImageViewerModal */}
{space.images && space.images.length > 0 && (
<ImageViewerModal
visible={isImageViewerVisible}
images={space.images}
initialIndex={selectedImageInitialIndex}
onClose={() => setIsImageViewerVisible(false)}
/>
)}
{/* ReviewsModal */}
<ReviewsModal
visible={isReviewsModalVisible}
onClose={() => setIsReviewsModalVisible(false)}
reviews={space.reviews}
spaceName={space.name}
/>
</View>
);
};
// --- FilterControls Component ---
interface FilterControlsProps {
filters: Filters;
onFilterChange: (newFilters: Partial<Filters>) => void;
}
const FilterControls: React.FC<FilterControlsProps> = ({ filters, onFilterChange }) => {
const [showAdvanced, setShowAdvanced] = useState(false);
return (
<View style={styles.filterControls}>
<Text style={styles.filterTitle}>Filters</Text>
<View style={styles.filterRow}>
<Text style={styles.filterLabelText}>Women's Space Available</Text>
<Switch
value={filters.showWomensSpace}
onValueChange={(value) => onFilterChange({ showWomensSpace: value })}
/>
</View>
<View style={styles.filterRow}>
<Text style={styles.filterLabelText}>Wudu Available</Text>
<Switch
value={filters.showWudu}
onValueChange={(value) => onFilterChange({ showWudu: value })}
/>
</View>
<View style={styles.filterRow}>
<Text style={styles.filterLabelText}>Min Rating: {averageRatingToText(filters.minRating)} {averageRatingToEmoji(filters.minRating)}</Text>
<TouchableOpacity style={{marginLeft: 10, padding: 6, backgroundColor: '#eee', borderRadius: 6}} onPress={() => setShowAdvanced(v => !v)}>
<Text style={{color: '#007bff', fontWeight: 'bold'}}>{showAdvanced ? 'Hide' : 'More'}</Text>
</TouchableOpacity>
</View>
<Slider
style={styles.slider}
minimumValue={0}
maximumValue={3}
step={0.5}
value={filters.minRating}
onSlidingComplete={(value: number) => onFilterChange({ minRating: value })}
/>
{showAdvanced && (
<View style={{marginTop: 10}}>
<Text style={styles.filterLabelText}>Cleanliness:</Text>
<Picker
selectedValue={filters.cleanRating}
onValueChange={(val) => onFilterChange({ cleanRating: val })}
style={styles.picker}
>
<Picker.Item label="Any" value={undefined} />
<Picker.Item label="😞 (Poor)" value={1} />
<Picker.Item label="😐 (Okay)" value={2} />
<Picker.Item label="😊 (Great)" value={3} />
</Picker>
<Text style={styles.filterLabelText}>Wudu Cleanliness:</Text>
<Picker
selectedValue={filters.cleanWuduRating}
onValueChange={(val) => onFilterChange({ cleanWuduRating: val })}
style={styles.picker}
>
<Picker.Item label="Any" value={undefined} />
<Picker.Item label="😞 (Poor)" value={1} />
<Picker.Item label="😐 (Okay)" value={2} />
<Picker.Item label="😊 (Great)" value={3} />
</Picker>
<Text style={styles.filterLabelText}>Quietness:</Text>
<Picker
selectedValue={filters.quietRating}
onValueChange={(val) => onFilterChange({ quietRating: val })}
style={styles.picker}
>
<Picker.Item label="Any" value={undefined} />
<Picker.Item label="😞 (Poor)" value={1} />
<Picker.Item label="😐 (Okay)" value={2} />
<Picker.Item label="😊 (Great)" value={3} />
</Picker>
<Text style={styles.filterLabelText}>Privacy:</Text>
<Picker
selectedValue={filters.privatenessRating}
onValueChange={(val) => onFilterChange({ privatenessRating: val })}
style={styles.picker}
>
<Picker.Item label="Any" value={undefined} />
<Picker.Item label="😞 (Poor)" value={1} />
<Picker.Item label="😐 (Okay)" value={2} />
<Picker.Item label="😊 (Great)" value={3} />
</Picker>
<Text style={styles.filterLabelText}>Child Friendliness:</Text>
<Picker
selectedValue={filters.childFriendlyRating}
onValueChange={(val) => onFilterChange({ childFriendlyRating: val })}
style={styles.picker}
>
<Picker.Item label="Any" value={undefined} />
<Picker.Item label="😞 (Poor)" value={1} />
<Picker.Item label="😐 (Okay)" value={2} />
<Picker.Item label="😊 (Great)" value={3} />
</Picker>
<Text style={styles.filterLabelText}>Safety:</Text>
<Picker
selectedValue={filters.safeRating}
onValueChange={(val) => onFilterChange({ safeRating: val })}
style={styles.picker}
>
<Picker.Item label="Any" value={undefined} />
<Picker.Item label="😞 (Poor)" value={1} />
<Picker.Item label="😐 (Okay)" value={2} />
<Picker.Item label="😊 (Great)" value={3} />
</Picker>
</View>
)}
<Text style={styles.filterLabelText}>Space Type:</Text>
<View style={styles.pickerContainer}>
<Picker
selectedValue={filters.spaceType}
onValueChange={(itemValue: string) => onFilterChange({ spaceType: itemValue as Filters['spaceType'] })}
style={styles.picker}
>
<Picker.Item label="All Types" value="All" />
<Picker.Item label="Mosque" value="Mosque" />
<Picker.Item label="Prayer Room" value="Prayer Room" />
<Picker.Item label="Community Space" value="Community Space" />
<Picker.Item label="Other" value="other" />
</Picker>
</View>
</View>
);
};
// --- Main Page Component (PrayerSpacesListPage) ---
const PrayerSpacesListPage: React.FC = () => {
const [allSpaces, setAllSpaces] = useState<PrayerSpaceBase[]>([]);
const [spacesWithDistances, setSpacesWithDistances] = useState<PrayerSpaceWithDistance[]>([]);
const [userLocation, setUserLocation] = useState<Location.LocationObjectCoords | null>(null);
const [locationErrorMsg, setLocationErrorMsg] = useState<string | null>(null);
const [isLocationLoading, setIsLocationLoading] = useState(true);
const [showWebsiteInputModal, setShowWebsiteInputModal] = useState(false);
const [editingWebsiteId, setEditingWebsiteId] = useState<string | null>(null); // To store the ID of the mosque being edited
const [currentWebsiteInput, setCurrentWebsiteInput] = useState(''); // To hold the text in the website input field
const [filters, setFilters] = useState<Filters>({
showWomensSpace: false,
showWudu: false,
minRating: 1.0, // Start at 1.0 instead of 0
spaceType: 'All',
cleanRating: undefined,
cleanWuduRating: undefined,
quietRating: undefined,
privatenessRating: undefined,
childFriendlyRating: undefined,
safeRating: undefined,
});
const fetchPrayerSpacesData = async () => {
try {
const response = await fetch('http://132.145.65.145:8080/places/query');
if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
const data = await response.json();
console.log('[DEBUG] Fetched raw spaces from backend:', data);
const frontendSpaces = data.map(mapBackendSpaceToPrayerSpace);
console.log('[DEBUG] Converted to frontend spaces:', frontendSpaces);
setAllSpaces(frontendSpaces);
} catch (error) {
console.error('Failed to fetch prayer spaces:', error);
Alert.alert('Error', 'Could not load prayer spaces from the server.');
}
};
// NEW HANDLER: Called when 'Add a website' or existing website is clicked
const handleAddWebsiteClick = (spaceId: string, currentWebsite: string | undefined) => {
setEditingWebsiteId(spaceId);
setCurrentWebsiteInput(currentWebsite || ''); // Pre-fill with existing website or empty string
setShowWebsiteInputModal(true);
};
// NEW HANDLER: Called when 'Save Website' is pressed in the modal
const handleSaveWebsite = async (spaceId: string, newWebsite: string) => {
if (!spaceId) {
Alert.alert('Error', 'Missing mosque ID for website update.');
return;
}
// Basic URL validation (you can make this more robust)
if (newWebsite && !newWebsite.startsWith('http://') && !newWebsite.startsWith('https://')) {
Alert.alert('Invalid URL', 'Please enter a full URL starting with http:// or https://');
return;
}
try {
const response = await fetch(`http://132.145.65.145:8080/places/${spaceId}/website`, { // Assuming new backend endpoint
method: 'PUT', // Using PUT for updating an existing resource
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ website_url: newWebsite }), // Payload must match backend expectation
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Unknown error' }));
console.error("Backend website update error:", errorData);
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
}
Alert.alert('Success', 'Website updated successfully!');
setShowWebsiteInputModal(false); // Close modal
fetchPrayerSpacesData(); // Refresh list to show updated website
} catch (error: any) {
console.error('Update website error:', error);
Alert.alert('Error', `Failed to update website: ${error.message || 'Network error'}`);
}
};
// END NEW HANDLERS
useEffect(() => {
let isMounted = true;
fetchPrayerSpacesData();
return () => { isMounted = false; };
}, []);
useEffect(() => {
let isMounted = true;
(async () => {
if (allSpaces.length === 0) {
setIsLocationLoading(false);
return;
}
setIsLocationLoading(true);
setLocationErrorMsg(null);
let { status } = await Location.requestForegroundPermissionsAsync();
if (!isMounted) return;
if (status !== 'granted') {
setLocationErrorMsg('Permission denied. Distances cannot be calculated.');
setSpacesWithDistances(allSpaces.map(space => ({ ...space, calculatedDistance: null })));
setIsLocationLoading(false);
return;
}
try {
let location: Location.LocationObject | null = await Location.getLastKnownPositionAsync();
if (!isMounted) return;
if (location) {
setUserLocation(location.coords);
}
const currentLocation = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced });
if (isMounted && currentLocation) {
setUserLocation(currentLocation.coords);
} else if (isMounted && !location) {
setLocationErrorMsg('Could not fetch location. Distances cannot be calculated.');
setSpacesWithDistances(allSpaces.map(space => ({ ...space, calculatedDistance: null })));
}
} catch (error) {
if (isMounted) {
setLocationErrorMsg('Error fetching location. Distances cannot be calculated.');
setSpacesWithDistances(allSpaces.map(space => ({ ...space, calculatedDistance: null })));
}
}
if (isMounted) setIsLocationLoading(false);
})();
return () => { isMounted = false; };
}, [allSpaces]);
useEffect(() => {
if (allSpaces.length === 0) return;
if (userLocation) {
const spacesAugmented = allSpaces.map(space => ({
...space,
calculatedDistance: getDistanceInKm(
userLocation.latitude,
userLocation.longitude,
space.latitude,
space.longitude
),
}));
setSpacesWithDistances(spacesAugmented);
} else if (!isLocationLoading) {
setSpacesWithDistances(allSpaces.map(space => ({ ...space, calculatedDistance: null })));
}
}, [userLocation, allSpaces, isLocationLoading]);
const handleFilterChange = (newFilterValues: Partial<Filters>) => {
setFilters((prevFilters) => ({ ...prevFilters, ...newFilterValues }));
};
const filteredAndSortedSpaces = useMemo(() => {
let filtered = spacesWithDistances.filter((space) => {
if (filters.showWomensSpace && !space.womensSpace) return false;
if (filters.showWudu && !space.wudu) return false;
const overallAvgRating = (space.clean + space.cleanWudu + space.quiet + space.privateness + space.childFriendly + space.safe) / 6;
if (filters.minRating === 0.5) {
return true;
}
if (overallAvgRating < filters.minRating) return false;
if (filters.spaceType !== 'All' && space.type !== filters.spaceType) return false;
if (filters.cleanRating && Math.round(space.clean) < filters.cleanRating) return false;
if (filters.cleanWuduRating && Math.round(space.cleanWudu) < filters.cleanWuduRating) return false;
if (filters.quietRating && Math.round(space.quiet) < filters.quietRating) return false;
if (filters.privatenessRating && Math.round(space.privateness) < filters.privatenessRating) return false;
if (filters.childFriendlyRating && Math.round(space.childFriendly) < filters.childFriendlyRating) return false;
if (filters.safeRating && Math.round(space.safe) < filters.safeRating) return false;
return true;
});
return filtered.sort((a, b) => {
if (a.calculatedDistance === null && b.calculatedDistance === null) return 0;
if (a.calculatedDistance === null) return 1;
if (b.calculatedDistance === null) return -1;
return a.calculatedDistance - b.calculatedDistance;
});
}, [spacesWithDistances, filters]);
return (
<ScrollView style={styles.pageContainer} contentContainerStyle={styles.contentContainer}>
<Text style={styles.pageTitle}>Find A Prayer Space</Text>
<FilterControls filters={filters} onFilterChange={handleFilterChange} />
{isLocationLoading && <ActivityIndicator size="large" color="#007bff" style={styles.loader} />}
{locationErrorMsg && <Text style={styles.errorText}>{locationErrorMsg}</Text>}
{!isLocationLoading && filteredAndSortedSpaces.length > 0 ? (
<View style={styles.listContainer}>
{filteredAndSortedSpaces.map((space) => (
<PrayerSpaceListItem
key={space.id}
space={space}
onRefreshPlaces={fetchPrayerSpacesData}
onAddWebsite={handleAddWebsiteClick} // NEW: Pass the handler for adding/editing website
onSaveWebsite={handleSaveWebsite} // NEW: Pass the save handler
/>
))}
</View>
) : !isLocationLoading && (
<Text style={styles.noResultsText}>No prayer spaces match your filters or available.</Text>
)}
<WebsiteInputModal
visible={showWebsiteInputModal}
initialWebsite={currentWebsiteInput}
mosqueId={editingWebsiteId}
onClose={() => setShowWebsiteInputModal(false)}
onSave={handleSaveWebsite} // Pass the save handler
/>
</ScrollView>
);
};
const styles = StyleSheet.create({
pageContainer: {
flex: 1,
backgroundColor: '#FFEEE7', // main background
},
contentContainer: { // Corrected: this style was truncated previously
padding: 15,
paddingBottom: 30,
},
pageTitle: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
color: '#333',
marginBottom: 20,
},
filterControls: {
marginBottom: 20,
padding: 15,
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
backgroundColor: '#FFFFFF', // changed from #f9f9f9 to #E2A593 for filter box
},
filterTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 10,
},
filterRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
filterLabelText: {
fontSize: 16,
flexShrink: 1,
marginRight: 8,
color: '#222', // Ensure dark text for visibility
},
slider: {
width: '100%',
height: 40,
marginBottom: 10,
// Add border for visibility
borderWidth: 1,
borderColor: '#aaa',
borderRadius: 8,
},
pickerContainer: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 4,
marginBottom: 10,
},
picker: {
height: Platform.OS === 'ios' ? 120 : 50,
width: '100%',
color: '#222', // Ensure dark text for picker
},
listContainer: {
marginTop: 10,
},
listItem: {
borderWidth: 1,
borderColor: '#eee',
borderRadius: 8,
padding: 15,
marginBottom: 15,
backgroundColor: '#FFFFFF', // list item background
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
thumbnailScrollView: {
marginBottom: 12,
maxHeight: 100,
},
thumbnailScrollViewContent: {
paddingVertical: 2,
},
thumbnailImage: {
width: 100,
height: 80,
borderRadius: 6,
marginRight: 8,
backgroundColor: '#e0e0e0',
},
listItemName: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 8,
color: '#007bff',
},
listItemText: {
fontSize: 14,
color: '#222', // Ensure dark text for all list items
marginBottom: 4,
lineHeight: 20,
},
listItemNotes: {
fontSize: 13,
color: '#777',
fontStyle: 'italic',
marginTop: 6,
},
facilitiesIconsContainer: {
flexDirection: 'row',
marginVertical: 8,
},
facilityIcon: {
fontSize: 20,
marginRight: 10,
},
noResultsText: {
textAlign: 'center',
color: '#777',
fontSize: 16,
marginTop: 30,
},
buttonRowContainer: {
flexDirection: 'row',
marginTop: 12,
borderWidth: 1,
borderColor: '#007bff',
borderRadius: 5,
overflow: 'hidden',
},
splitButtonBase: {
flex: 1,
paddingVertical: 10,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#007bff',
},
splitButtonLeft: {},
splitButtonRight: {},
buttonSeparator: {
width: 1,
backgroundColor: '#fff',
},
splitButtonText: {
color: 'white',
fontSize: 14,
fontWeight: '600',
},
addReviewButton: {
backgroundColor: '#28a745',
borderRadius: 5,
paddingVertical: 10,
alignItems: 'center',
marginTop: 10,
},
addReviewButtonText: {
color: 'white',
fontSize: 15,
fontWeight: '600',
},
addPhotoButton: { // NEW: Add Photo Button styles
backgroundColor: '#28a745',
borderRadius: 5,
paddingVertical: 10,
alignItems: 'center',
marginTop: 10,
},
addPhotoButtonText: { // NEW: Add Photo Button text styles
color: 'white',
fontSize: 15,
fontWeight: '600',
},
reviewFormContainer: {
marginTop: 20,
padding: 15,
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
backgroundColor: '#fefefe',
},
reviewFormTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 15,
textAlign: 'center',
},
ratingContainer: { // Style for EmojiRatingSelector container
marginBottom: 15,
alignItems: 'center',
},
ratingLabel: { // Style for label in EmojiRatingSelector
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
color: '#333',
},
emojiRow: { // Style for row of emojis in EmojiRatingSelector
flexDirection: 'row',
justifyContent: 'space-around',
width: '100%',
},
emojiButton: { // Style for each emoji button
padding: 10,
borderRadius: 5,
alignItems: 'center',
},
selectedEmoji: { // Style for selected emoji
backgroundColor: '#e3f2fd',
borderColor: '#007AFF',
borderWidth: 1,
},
emojiText: { // Style for the emoji itself
fontSize: 30,
},
emojiLabel: { // Style for text label under emoji
fontSize: 12,
color: '#666',
marginTop: 5,
},
input: { // Reused input style, adjust if needed specifically for form
borderWidth: 1,
borderColor: '#ccc',
paddingHorizontal: 10,
paddingVertical: 8,
borderRadius: 5,
marginBottom: 15,
},
loader: {
marginVertical: 20,
},
errorText: {
textAlign: 'center',
color: 'red',
marginVertical: 10,
fontSize: 14,
},
addWebsiteLink: {
color: 'blue',
textDecorationLine: 'underline',
fontStyle: 'italic',
},
websiteLink: {
color: 'blue',
textDecorationLine: 'underline',
},
infoText: {
fontSize: 15,
color: '#222', // Ensure dark text for info
marginBottom: 4,
},
});
// --- WebsiteInputModal Component (Inline within PrayerSpacesListPage.tsx) ---
interface WebsiteInputModalProps {
visible: boolean;
initialWebsite: string;
mosqueId: string | null;
onClose: () => void;
onSave: (mosqueId: string, newWebsite: string) => Promise<void>; // Promise<void> indicates it's async
}
const WebsiteInputModal: React.FC<WebsiteInputModalProps> = ({ visible, initialWebsite, mosqueId, onClose, onSave }) => {
const [website, setWebsite] = useState(initialWebsite);
const [isSaving, setIsSaving] = useState(false);
// Update local state if initialWebsite prop changes (e.g., editing different mosque)
useEffect(() => {
setWebsite(initialWebsite);
}, [initialWebsite]);
const handleSave = async () => {
if (!mosqueId) {
Alert.alert('Error', 'Mosque ID is missing.');
return;
}
setIsSaving(true);
try {
await onSave(mosqueId, website); // Call parent's save function
onClose(); // Close modal on success
} catch (error) {
// Error handled by parent, just re-throw to stop finally block
throw error;
} finally {
setIsSaving(false);
}
};
return (
<Modal
animationType="fade"
transparent={true}
visible={visible}
onRequestClose={onClose}
>
<View style={websiteModalStyles.centeredView}>
<View style={websiteModalStyles.modalView}>
<Text style={websiteModalStyles.modalTitle}>
{initialWebsite ? 'Edit Website' : 'Add Website'}
</Text>
<TextInput
style={websiteModalStyles.input}
placeholder="Enter website URL (e.g., https://mosque.org)"
value={website}
onChangeText={setWebsite}
keyboardType="url"
autoCapitalize="none"
/>
<Button
title={isSaving ? "Saving..." : "Save Website"}
onPress={handleSave}
disabled={isSaving}
/>
<TouchableOpacity onPress={onClose} style={websiteModalStyles.cancelButton}>
<Text style={websiteModalStyles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};
const websiteModalStyles = StyleSheet.create({
centeredView: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
// backgroundColor: 'rgba(0,0,0,0.6)',
},
modalView: {
margin: 20,
backgroundColor: '#FFFFFF', // modal background
borderRadius: 10,
padding: 25,
width: '80%',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
modalTitle: {
fontSize: 20,
fontWeight: 'bold',
marginBottom: 20,
textAlign: 'center',
},
input: {
width: '100%',
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
paddingHorizontal: 15,
paddingVertical: 10,
marginBottom: 15,
fontSize: 16,
},
cancelButton: {
marginTop: 10,
},
cancelButtonText: {
color: '#007bff',
fontSize: 16,
},
});
// Render the Website Input Modal
export default PrayerSpacesListPage;