1490 lines
48 KiB
TypeScript
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;
|