diff --git a/Store/shopstore.tsx b/Store/shopstore.tsx index 88126d0..68fab90 100644 --- a/Store/shopstore.tsx +++ b/Store/shopstore.tsx @@ -25,6 +25,7 @@ export interface Shop { timeout: string; isOpen?: boolean; categoryId?: number; + category?: { id: number; name: string }; services?: ShopService[]; } @@ -60,7 +61,7 @@ export const useShopStore = create((set, get) => ({ set({ shops, loading: false }); return shops; } catch (error: any) { - set({ error: error.message || "Failed to fetch shops", loading: false }); + set({ error: error.message || "Failed to fetch shop", loading: false }); return []; } }, diff --git a/app/(authadmin)/_layout.tsx b/app/(authadmin)/_layout.tsx index 1695364..80e4256 100644 --- a/app/(authadmin)/_layout.tsx +++ b/app/(authadmin)/_layout.tsx @@ -50,7 +50,7 @@ export default function AuthLayout() { {/* Card */} - + @@ -123,7 +123,6 @@ const styles = StyleSheet.create({ letterSpacing: 0.2, }, card: { - backgroundColor: 'white', borderTopLeftRadius: 28, borderTopRightRadius: 28, marginTop: -24, diff --git a/app/(authadmin)/adminsign-in.tsx b/app/(authadmin)/adminsign-in.tsx index ca3fb8d..50d6beb 100644 --- a/app/(authadmin)/adminsign-in.tsx +++ b/app/(authadmin)/adminsign-in.tsx @@ -11,6 +11,8 @@ import * as WebBrowser from 'expo-web-browser'; import * as Google from 'expo-auth-session/providers/google'; import * as AppleAuthentication from 'expo-apple-authentication'; import { makeRedirectUri } from 'expo-auth-session'; +import { registerAdminPushToken } from '@/constants/adminApi'; +import { registerForPushNotificationsAsync } from '@/app/utils/pushNotifications'; const Signin = () => { const { colors } = useThemeStore(); @@ -47,6 +49,10 @@ const Signin = () => { const res = await apiClient.post('/api/v1/admin/google', { idToken }); await SecureStore.deleteItemAsync('token'); await SecureStore.setItemAsync('admintoken', res.data.token); + try { + const pushToken = await registerForPushNotificationsAsync(); + if (pushToken) await registerAdminPushToken(pushToken); + } catch (ignored) {} router.replace('/adminfolder' as any); } catch (error: any) { Alert.alert('Google Sign-In Failed', error.message || 'Something went wrong'); @@ -70,6 +76,10 @@ const Signin = () => { const res = await apiClient.post('/api/v1/admin/apple', { idToken: credential.identityToken, name }); await SecureStore.deleteItemAsync('token'); await SecureStore.setItemAsync('admintoken', res.data.token); + try { + const pushToken = await registerForPushNotificationsAsync(); + if (pushToken) await registerAdminPushToken(pushToken); + } catch (ignored) {} router.replace('/adminfolder' as any); } catch (e: any) { if (e.code !== 'ERR_REQUEST_CANCELED') { @@ -108,9 +118,12 @@ const Signin = () => { try { const response = await apiClient.post('/api/v1/admin/signin', form); const { token } = response.data; - // Clear any legacy 'token' key that may still contain a stale admin token await SecureStore.deleteItemAsync('token'); await SecureStore.setItemAsync('admintoken', token); + try { + const pushToken = await registerForPushNotificationsAsync(); + if (pushToken) await registerAdminPushToken(pushToken); + } catch (ignored) {} router.replace('/adminfolder' as any); } catch (error: any) { const message = error.response?.data?.error || error.message || 'Something went wrong'; diff --git a/app/(authuser)/_layout.tsx b/app/(authuser)/_layout.tsx index 890dac6..ae566a7 100644 --- a/app/(authuser)/_layout.tsx +++ b/app/(authuser)/_layout.tsx @@ -48,7 +48,7 @@ export default function AuthLayout() { {/* Card */} - + @@ -110,7 +110,6 @@ const styles = StyleSheet.create({ letterSpacing: 0.2, }, card: { - backgroundColor: 'white', borderTopLeftRadius: 28, borderTopRightRadius: 28, marginTop: -24, diff --git a/app/(authuser)/sign-in.tsx b/app/(authuser)/sign-in.tsx index 7a50098..08936a4 100644 --- a/app/(authuser)/sign-in.tsx +++ b/app/(authuser)/sign-in.tsx @@ -10,7 +10,8 @@ import * as WebBrowser from 'expo-web-browser'; import * as Google from 'expo-auth-session/providers/google'; import * as AppleAuthentication from 'expo-apple-authentication'; import { makeRedirectUri } from 'expo-auth-session'; -import { userAppleSignin, userGoogleSignin } from '@/constants/userApi'; +import { userAppleSignin, userGoogleSignin, registerUserPushToken } from '@/constants/userApi'; +import { registerForPushNotificationsAsync } from '@/app/utils/pushNotifications'; import { useEffect } from 'react'; WebBrowser.maybeCompleteAuthSession(); @@ -48,6 +49,10 @@ const UserSignInScreen = () => { setSubmitting(true); try { await userGoogleSignin(idToken); + try { + const pushToken = await registerForPushNotificationsAsync(); + if (pushToken) await registerUserPushToken(pushToken); + } catch (ignored) {} router.replace('/userflow/setting'); } catch (error: any) { Alert.alert('Google Sign-In Failed', error.message || 'Something went wrong'); @@ -69,6 +74,10 @@ const UserSignInScreen = () => { ? `${credential.fullName.givenName} ${credential.fullName.familyName || ''}`.trim() : undefined; await userAppleSignin(credential.identityToken!, name); + try { + const pushToken = await registerForPushNotificationsAsync(); + if (pushToken) await registerUserPushToken(pushToken); + } catch (ignored) {} router.replace('/userflow/setting'); } catch (e: any) { if (e.code !== 'ERR_REQUEST_CANCELED') { @@ -93,6 +102,10 @@ const UserSignInScreen = () => { setSubmitting(true); try { await userSignin(form.email, form.password); + try { + const pushToken = await registerForPushNotificationsAsync(); + if (pushToken) await registerUserPushToken(pushToken); + } catch (ignored) {} router.replace('/userflow/setting'); } catch (error: any) { Alert.alert('Sign in Failed', error.message); diff --git a/app/(tabs)/cart.tsx b/app/(tabs)/cart.tsx index 6b49cb4..a76381a 100644 --- a/app/(tabs)/cart.tsx +++ b/app/(tabs)/cart.tsx @@ -58,15 +58,8 @@ export default function Cart() { const categorizeBooking = (booking: any) => { if (!booking.booked || booking.status === 'cancelled') return 'Cancelled'; - - try { - const endTimeObj = new Date(booking.endTime); - const now = new Date(); - - return now <= endTimeObj ? 'Active' : 'Completed'; - } catch(e) { - return 'Completed'; - } + if (booking.status === 'completed') return 'Completed'; + return 'Active'; // 'upcoming' from backend }; const handleCancel = (bookingId: number) => { @@ -316,6 +309,17 @@ export default function Cart() { + {activeTab === 'Active' && ( + router.push(`/shops/${order.service?.shop?.id}` as any)} + > + + Reschedule + + + )} + activeTab === 'Active' ? setSelectedBooking(order) : router.push(`/shops/${order.service?.shop?.id}` as any)} diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 339bda6..27761aa 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,5 +1,5 @@ import { SafeAreaView } from "react-native-safe-area-context"; -import { FlatList, Image, Pressable, Text, TouchableOpacity, View, StyleSheet } from "react-native"; +import { FlatList, Image, Pressable, Text, TouchableOpacity, View, StyleSheet, ScrollView } from "react-native"; import { Fragment, useEffect, useState } from "react"; import { router } from "expo-router"; import * as Location from 'expo-location'; @@ -123,16 +123,49 @@ export default function Index() { Book the services you need - router.replace('/(authadmin)/adminsign-in')} - activeOpacity={0.8} - > - - Become a Seller - + + router.replace('/(authadmin)/adminsign-in')} + activeOpacity={0.8} + > + + Become a Seller + + + router.push('/userflow/map')} + activeOpacity={0.8} + > + + Map View + + - Browse Categories + Popular Shops + + + {shops.slice(0, 5).map(shop => ( + router.push(`/shops/${shop.id}`)} + > + + + {shop.name || `Best ${shop.occupation}`} + {shop.category?.name || shop.occupation} + + + 4.8 + + + + ))} + + + Explore Offers )} /> @@ -166,6 +199,45 @@ const styles = StyleSheet.create({ }, sellerBtnText: { fontSize: 13, fontWeight: '700' }, sectionTitle: { fontSize: 18, fontWeight: '800', marginBottom: 4 }, + shopCard: { + width: 160, + borderRadius: 16, + borderWidth: 1, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.05, + shadowRadius: 8, + elevation: 3, + }, + shopImage: { + width: '100%', + height: 100, + backgroundColor: '#e5e7eb', + }, + shopInfo: { + padding: 12, + }, + shopTitle: { + fontSize: 14, + fontWeight: '700', + marginBottom: 4, + }, + shopCategory: { + fontSize: 12, + color: '#6366f1', + fontWeight: '500', + marginBottom: 6, + }, + shopRatingRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + shopRating: { + fontSize: 12, + fontWeight: '600', + }, card: { width: '100%', height: 160, diff --git a/app/_layout.tsx b/app/_layout.tsx index 00e0fd0..804375e 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -3,6 +3,7 @@ import { Stack } from 'expo-router'; import { useFonts } from 'expo-font'; import { useEffect } from 'react'; import { useThemeStore } from '@/Store/themeStore'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; import './globals.css'; export default function RootLayout() { @@ -22,11 +23,13 @@ export default function RootLayout() { }, [hydrateTheme]); return ( - + + + ); } diff --git a/app/adminfolder/[id]/calendar.tsx b/app/adminfolder/[id]/calendar.tsx new file mode 100644 index 0000000..1dca195 --- /dev/null +++ b/app/adminfolder/[id]/calendar.tsx @@ -0,0 +1,281 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { View, Text, StyleSheet, ActivityIndicator, ScrollView, TouchableOpacity } from 'react-native'; +import { useLocalSearchParams, router } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Calendar, DateData } from 'react-native-calendars'; +import { Ionicons } from '@expo/vector-icons'; +import { getAdminShopBookings } from '@/constants/adminApi'; +import { useThemeStore } from '@/Store/themeStore'; + +type Booking = { + id: number; + startTime: string; + endTime: string; + duration: number; + price: number; + status: string; + service: { name: string }; + user: { name: string; email: string }; +}; + +export default function AdminShopCalendar() { + const { id } = useLocalSearchParams<{ id: string }>(); + const { colors } = useThemeStore(); + const [bookings, setBookings] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]); + + useEffect(() => { + const fetchBookings = async () => { + if (!id) return; + try { + const data = await getAdminShopBookings(Number(id)); + setBookings(data); + } catch (error) { + console.error("Failed to load shop bookings", error); + } finally { + setLoading(false); + } + }; + fetchBookings(); + }, [id]); + + // Format dates for react-native-calendars + const markedDates = useMemo(() => { + const marks: any = {}; + + bookings.forEach(b => { + if (b.status === 'cancelled') return; + const dateStr = b.startTime.split('T')[0]; + marks[dateStr] = { + marked: true, + dotColor: colors.primary, + }; + }); + + // Always highlight the selected date + marks[selectedDate] = { + ...marks[selectedDate], + selected: true, + selectedColor: colors.primary, + }; + + return marks; + }, [bookings, selectedDate, colors.primary]); + + const formatTime = (isoString: string) => { + return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + + // Filter bookings for the selected date + const dayBookings = useMemo(() => { + return bookings.filter(b => + b.status !== 'cancelled' && + b.startTime.startsWith(selectedDate) + ).sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); + }, [bookings, selectedDate]); + + if (loading) { + return ( + + + + ); + } + + return ( + + + {/* Header */} + + router.back()} style={styles.backBtn}> + + + Booking Calendar + {/* spacer */} + + + setSelectedDate(day.dateString)} + markedDates={markedDates} + theme={{ + backgroundColor: colors.background, + calendarBackground: colors.surface, + textSectionTitleColor: colors.textMuted, + selectedDayBackgroundColor: colors.primary, + selectedDayTextColor: '#ffffff', + todayTextColor: colors.primary, + dayTextColor: colors.text, + textDisabledColor: colors.textMuted + '50', + dotColor: colors.primary, + selectedDotColor: '#ffffff', + arrowColor: colors.primary, + monthTextColor: colors.text, + indicatorColor: colors.primary, + textDayFontWeight: '500', + textMonthFontWeight: 'bold', + textDayHeaderFontWeight: '600', + }} + style={styles.calendar} + /> + + + + Schedule for {new Date(selectedDate).toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' })} + + + + {dayBookings.length === 0 ? ( + + + No bookings for this date. + + ) : ( + dayBookings.map((booking) => ( + + + {formatTime(booking.startTime)} + + {booking.duration}m + + + + + {booking.service.name} + + + {booking.user.name} + + + + + + {booking.status.toUpperCase()} + + + ₹{booking.price} + + + + )) + )} + + + + + ); +} + +const styles = StyleSheet.create({ + center: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12 + }, + backBtn: { + width: 40, height: 40, + justifyContent: 'center', alignItems: 'center' + }, + headerTitle: { fontSize: 18, fontWeight: '700' }, + calendar: { + borderBottomWidth: 1, + borderBottomColor: 'rgba(0,0,0,0.05)', + paddingBottom: 10 + }, + agendaContainer: { + flex: 1, + paddingHorizontal: 20, + paddingTop: 20, + }, + agendaTitle: { + fontSize: 16, + fontWeight: '700', + marginBottom: 16 + }, + emptyContainer: { + alignItems: 'center', + paddingTop: 40 + }, + emptyText: { + fontSize: 15, + fontWeight: '500' + }, + bookingCard: { + flexDirection: 'row', + padding: 16, + borderRadius: 16, + borderWidth: 1, + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.05, + shadowRadius: 8, + elevation: 2 + }, + timeSection: { + width: 80, + borderRightWidth: 1, + borderRightColor: 'rgba(0,0,0,0.05)', + marginRight: 16, + alignItems: 'flex-start' + }, + timeText: { + fontSize: 15, + fontWeight: '700', + marginBottom: 6 + }, + durationBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 6 + }, + durationText: { + fontSize: 11, + fontWeight: '600' + }, + detailsSection: { + flex: 1 + }, + serviceName: { + fontSize: 16, + fontWeight: '600', + marginBottom: 6 + }, + userRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginBottom: 12 + }, + userName: { + fontSize: 14, + fontWeight: '500' + }, + statusRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center' + }, + statusBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 6 + }, + statusText: { + fontSize: 10, + fontWeight: '700', + letterSpacing: 0.5 + }, + priceText: { + fontSize: 15, + fontWeight: '700' + } +}); diff --git a/app/adminfolder/dashboard.tsx b/app/adminfolder/dashboard.tsx new file mode 100644 index 0000000..f9c4dda --- /dev/null +++ b/app/adminfolder/dashboard.tsx @@ -0,0 +1,187 @@ +import React, { useEffect, useState } from 'react'; +import { View, Text, ScrollView, ActivityIndicator, StyleSheet } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { getDashboardAnalytics, getServiceAnalytics } from '@/constants/analyticsApi'; +import { useThemeStore } from '@/Store/themeStore'; + +type DashboardStats = { + totalBookings: number; + totalRevenue: number; + totalCustomers: number; + averageRating: number; + recentBookings: { date: string; count: number }[]; +}; + +type ServiceStat = { + serviceId: number; + serviceName: string; + bookingCount: number; + totalRevenue: number; +}; + +export default function AdminDashboardScreen() { + const { colors } = useThemeStore(); + const [stats, setStats] = useState(null); + const [serviceStats, setServiceStats] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchAnalytics = async () => { + try { + const [dashboardData, servicesData] = await Promise.all([ + getDashboardAnalytics(), + getServiceAnalytics() + ]); + setStats(dashboardData); + setServiceStats(servicesData); + } catch (error) { + console.error("Failed to fetch analytics", error); + } finally { + setLoading(false); + } + }; + + fetchAnalytics(); + }, []); + + if (loading) { + return ( + + + + ); + } + + const maxBookingsInTrend = Math.max(...(stats?.recentBookings.map(b => b.count) || [1])); + + return ( + + + Dashboard + Your business at a glance + + + + + {/* ─── Highlights Grid ─── */} + + + + + + Total Revenue + ₹{stats?.totalRevenue.toLocaleString()} + + + + + + + Total Bookings + {stats?.totalBookings} + + + + + + + Unique Customers + {stats?.totalCustomers} + + + + + + + Average Rating + {stats?.averageRating} / 5 + + + + {/* ─── Recent Bookings Trend (7 Days) ─── */} + + Booking Trend (Last 7 Days) + + + {stats?.recentBookings.map((day, idx) => { + const barHeight = Math.max(10, (day.count / (maxBookingsInTrend || 1)) * 120); + const dayName = new Date(day.date).toLocaleDateString(undefined, { weekday: 'short' }); + + return ( + + {day.count} + + {dayName} + + ); + })} + + + + {/* ─── Top Services ─── */} + + Top Performing Services + + {serviceStats.length === 0 ? ( + No service data available yet. + ) : ( + serviceStats.map((service, index) => ( + + + #{index + 1} + + {service.serviceName} + {service.bookingCount} bookings + + + ₹{service.totalRevenue.toLocaleString()} + + )) + )} + + + + + ); +} + +const styles = StyleSheet.create({ + center: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + header: { paddingHorizontal: 20, paddingTop: 10, paddingBottom: 20 }, + headerTitle: { fontSize: 32, fontWeight: '800', letterSpacing: -1 }, + container: { paddingHorizontal: 20, paddingBottom: 40 }, + + grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 16, marginBottom: 24 }, + card: { + width: '47.5%', + padding: 16, + borderRadius: 20, + borderWidth: 1, + shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.05, shadowRadius: 10, elevation: 3 + }, + iconWrap: { width: 44, height: 44, borderRadius: 22, justifyContent: 'center', alignItems: 'center', marginBottom: 12 }, + cardLabel: { fontSize: 13, fontWeight: '600', marginBottom: 4 }, + cardValue: { fontSize: 22, fontWeight: '800' }, + + section: { + padding: 20, + borderRadius: 24, + borderWidth: 1, + marginBottom: 24, + shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.05, shadowRadius: 10, elevation: 3 + }, + sectionTitle: { fontSize: 18, fontWeight: '700', marginBottom: 20 }, + + chartArea: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end', height: 160, paddingTop: 20 }, + barWrap: { alignItems: 'center', flex: 1 }, + barValue: { fontSize: 11, fontWeight: '600', marginBottom: 6 }, + bar: { width: 14, borderRadius: 7 }, + dayLabel: { fontSize: 11, fontWeight: '500', marginTop: 8 }, + + serviceRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: 'rgba(0,0,0,0.05)' }, + serviceLeft: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + serviceRank: { fontSize: 16, fontWeight: '800', width: 24 }, + serviceName: { fontSize: 15, fontWeight: '600', marginBottom: 2 }, + serviceRevenue: { fontSize: 16, fontWeight: '700' }, +}); diff --git a/app/adminfolder/index.tsx b/app/adminfolder/index.tsx index 7761317..3d95d81 100644 --- a/app/adminfolder/index.tsx +++ b/app/adminfolder/index.tsx @@ -24,6 +24,8 @@ type AdminShop = { isOpen: boolean; slotDuration: number; id: number; + verificationStatus: 'pending' | 'approved' | 'rejected'; + rejectionReason?: string; }; export default function AdminShopsScreen() { @@ -85,6 +87,13 @@ export default function AdminShopsScreen() { {shops.length} active {shops.length === 1 ? 'shop' : 'shops'} + router.push('/adminfolder/dashboard')} + activeOpacity={0.8} + > + + router.push('/adminprofile')} @@ -141,6 +150,33 @@ export default function AdminShopsScreen() { {shop.occupation} {shop.speclization} + {/* Verification Status Badge */} + + + + {shop.verificationStatus === 'pending' ? 'Under Review' : shop.verificationStatus === 'approved' ? 'Verified' : 'Rejected'} + + + {shop.verificationStatus === 'rejected' && shop.rejectionReason && ( + + Reason: {shop.rejectionReason} + + )} + @@ -167,22 +203,33 @@ export default function AdminShopsScreen() { - - + + + {shop.isOpen ? 'Accepting Bookings' : 'Closed'} + + toggleShopStatus(shop.id, shop.isOpen)} + trackColor={{ false: '#FECACA', true: '#D1FAE5' }} + thumbColor={shop.isOpen ? '#10B981' : '#EF4444'} + /> + + + router.push(`/adminfolder/${shop.id}/calendar`)} + activeOpacity={0.8} > - {shop.isOpen ? 'Accepting Bookings' : 'Closed'} - - toggleShopStatus(shop.id, shop.isOpen)} - trackColor={{ false: '#FECACA', true: '#D1FAE5' }} - thumbColor={shop.isOpen ? '#10B981' : '#EF4444'} - /> + + View Schedule + @@ -240,4 +287,40 @@ const styles = StyleSheet.create({ createBtn: { borderRadius: 16, overflow: 'hidden', shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.35, shadowRadius: 12, elevation: 8 }, createBtnGradient: { paddingVertical: 16, paddingHorizontal: 32 }, createBtnText: { color: 'white', fontSize: 16, fontWeight: '700' }, + verificationBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + alignSelf: 'flex-start', + marginBottom: 10, + }, + verificationText: { + fontSize: 12, + fontWeight: '700', + }, + actionRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingTop: 12, + marginTop: 12, + borderTopWidth: 1, + borderTopColor: 'rgba(0,0,0,0.05)' + }, + calendarBtn: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 8, + gap: 6 + }, + calendarBtnText: { + color: 'white', + fontSize: 13, + fontWeight: '600' + } }); \ No newline at end of file diff --git a/app/adminprofile.tsx b/app/adminprofile.tsx index 925cdf0..a92ca83 100644 --- a/app/adminprofile.tsx +++ b/app/adminprofile.tsx @@ -130,6 +130,7 @@ export default function AdminShopForm() { timeout: `${form.timeoutHour} ${form.timeoutPeriod}`, isOpen: true, categoryName: form.occupation, + images: form.image ? [form.image] : [], services: services.map(s => ({ name: s.name, price: Number(s.price), diff --git a/app/shops/[id].tsx b/app/shops/[id].tsx index e020bcd..cc17698 100644 --- a/app/shops/[id].tsx +++ b/app/shops/[id].tsx @@ -1,4 +1,4 @@ -import { View, Text, Image, ScrollView, ActivityIndicator, Pressable, Alert, TouchableOpacity, StyleSheet } from 'react-native'; +import { View, Text, Image, ScrollView, ActivityIndicator, Pressable, Alert, TouchableOpacity, StyleSheet, Modal, TextInput } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useLocalSearchParams, useRouter, useFocusEffect } from 'expo-router'; import { useShopStore, ShopService } from '@/Store/shopstore'; @@ -10,6 +10,7 @@ import { LinearGradient } from 'expo-linear-gradient'; import { getShopSlots, createBooking } from '@/constants/bookingApi'; import { useThemeStore } from '@/Store/themeStore'; import BackButton from '@/components/BackButton'; +import apiClient from '@/constants/axiosInstance'; const GOOGLE_MAPS_APIKEY = "AIzaSyA97WCu7Ld0sSnNWbgAfEouBfRqXSB8dnw"; @@ -40,6 +41,81 @@ export default function ShopDetails() { const [selectedService, setSelectedService] = useState(null); const [isLoadingSlots, setIsLoadingSlots] = useState(false); + const [isFavorite, setIsFavorite] = useState(false); + const [reviews, setReviews] = useState([]); + const [averageRating, setAverageRating] = useState(0); + const [isEligibleToReview, setIsEligibleToReview] = useState(false); + + const [isReviewModalVisible, setIsReviewModalVisible] = useState(false); + const [reviewRating, setReviewRating] = useState(0); + const [reviewComment, setReviewComment] = useState(""); + const [isSubmittingReview, setIsSubmittingReview] = useState(false); + + useEffect(() => { + if (!shop) return; + const fetchShopData = async () => { + try { + const reviewsRes = await apiClient.get(`/api/v1/user/reviews/${shop.id}`); + setReviews(reviewsRes.data.reviews || []); + setAverageRating(reviewsRes.data.averageRating || 0); + + const favsRes = await apiClient.get('/api/v1/user/favorites'); + const favs = favsRes.data || []; + setIsFavorite(favs.some((f: any) => f.shopId === shop.id)); + + try { + const eligRes = await apiClient.get(`/api/v1/user/reviews/eligibility/${shop.id}`); + setIsEligibleToReview(eligRes.data.eligible); + } catch (e) { + console.log("Eligibility check failed", e); + } + } catch (err) { + console.log("Failed to fetch shop data:", err); + } + }; + fetchShopData(); + }, [shop?.id]); + + const handleToggleFavorite = async () => { + setIsFavorite(!isFavorite); + try { + await apiClient.post('/api/v1/user/favorites', { shopId: shop.id }); + } catch (error) { + setIsFavorite(!isFavorite); + console.error("Failed to toggle favorite", error); + } + }; + + const submitReview = async () => { + if (reviewRating === 0) { + Alert.alert("Rating Required", "Please select a star rating."); + return; + } + if (!reviewComment.trim()) { + Alert.alert("Comment Required", "Please write a short review."); + return; + } + setIsSubmittingReview(true); + try { + await apiClient.post('/api/v1/user/reviews', { + shopId: shop.id, + rating: reviewRating, + comment: reviewComment + }); + Alert.alert("Success", "Your review has been submitted!"); + setIsReviewModalVisible(false); + setReviewRating(0); + setReviewComment(""); + const reviewsRes = await apiClient.get(`/api/v1/user/reviews/${shop.id}`); + setReviews(reviewsRes.data.reviews || []); + setAverageRating(reviewsRes.data.averageRating || 0); + } catch (error: any) { + Alert.alert("Error", error?.response?.data?.error || "Failed to submit review."); + } finally { + setIsSubmittingReview(false); + } + }; + // Auto-select first service if available useEffect(() => { if (shop?.services && shop.services.length > 0 && !selectedService) { @@ -119,6 +195,9 @@ export default function ShopDetails() { {shop.address ? shop.address.split(" ").slice(0, 4).join(" ") : "Location"} + + + @@ -165,38 +244,49 @@ export default function ShopDetails() { {/* Map */} - - 📍 Location & Route - - - - - - - - - - + {(shop.latitude && shop.longitude) ? ( + + 📍 Location & Route + + + + + + + + + + + + {shop.address} - {shop.address} - + ) : ( + + 📍 Location + {shop.address || "No address provided."} + + + Map not available for this shop + + + )} {/* Services Menu */} {shop.services && shop.services.length > 0 && ( @@ -301,6 +391,40 @@ export default function ShopDetails() { + {/* Reviews Section */} + + + ⭐ Reviews ({reviews.length}) + + + + {averageRating.toFixed(1)} + + {isEligibleToReview && ( + setIsReviewModalVisible(true)} style={[styles.reviewBtn, { backgroundColor: colors.primary + '15' }]}> + Write Review + + )} + + + {reviews.slice(0, 3).map((rev, i) => ( + + + {rev.user?.name || 'User'} + + {[...Array(5)].map((_, idx) => ( + + ))} + + + {rev.comment} + + ))} + {reviews.length === 0 && ( + No reviews yet. Be the first to book and review! + )} + + {/* Spacer for sticky button */} @@ -319,6 +443,53 @@ export default function ShopDetails() { + + {/* Review Modal */} + setIsReviewModalVisible(false)} + > + + + Rate & Review + + + {[1, 2, 3, 4, 5].map((star) => ( + setReviewRating(star)}> + + + ))} + + Tap a star to rate + + + + + setIsReviewModalVisible(false)} style={[styles.modalCancelBtn, { borderColor: colors.border }]} disabled={isSubmittingReview}> + Cancel + + + {isSubmittingReview ? ( + + ) : ( + Submit Review + )} + + + + + ); } @@ -338,6 +509,10 @@ const styles = StyleSheet.create({ width: 38, height: 38, borderRadius: 19, alignItems: 'center', justifyContent: 'center', marginRight: 12, }, + favoriteBtn: { + width: 38, height: 38, borderRadius: 19, + alignItems: 'center', justifyContent: 'center', marginLeft: 12, + }, locationInfo: { flexDirection: 'row', alignItems: 'center', gap: 4, flex: 1 }, locationText: { fontSize: 14, color: '#374151', fontWeight: '600', flex: 1 }, scrollContent: { paddingBottom: 40 }, @@ -428,5 +603,18 @@ const styles = StyleSheet.create({ slotChipText: { fontSize: 13, fontWeight: '700', color: '#0369A1' }, slotChipTextBooked: { color: '#EF4444', textDecorationLine: 'line-through' }, slotChipTextSelected: { color: 'white' }, + + // Review Modal Styles + reviewBtn: { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 12 }, + modalOverlay: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 }, + modalContent: { width: '100%', borderRadius: 24, padding: 24, elevation: 10, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.1, shadowRadius: 10 }, + modalTitle: { fontSize: 20, fontWeight: '800', textAlign: 'center', marginBottom: 16 }, + starContainer: { flexDirection: 'row', justifyContent: 'center', gap: 8, marginBottom: 8 }, + modalInput: { borderWidth: 1, borderRadius: 12, padding: 16, fontSize: 15, height: 120, marginBottom: 24 }, + modalActions: { flexDirection: 'row', gap: 12 }, + modalCancelBtn: { flex: 1, borderWidth: 1, paddingVertical: 14, borderRadius: 12, alignItems: 'center' }, + modalCancelText: { fontWeight: '700', fontSize: 15 }, + modalSubmitBtn: { flex: 1, paddingVertical: 14, borderRadius: 12, alignItems: 'center' }, + modalSubmitText: { color: 'white', fontWeight: '700', fontSize: 15 }, }); diff --git a/app/userflow/map.tsx b/app/userflow/map.tsx new file mode 100644 index 0000000..0af9114 --- /dev/null +++ b/app/userflow/map.tsx @@ -0,0 +1,239 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { StyleSheet, View, Text, TouchableOpacity, Dimensions, ActivityIndicator, Image } from 'react-native'; +import MapView, { Marker, Callout, PROVIDER_GOOGLE } from 'react-native-maps'; +import * as Location from 'expo-location'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useShopStore } from '@/Store/shopstore'; +import { useThemeStore } from '@/Store/themeStore'; +import BackButton from '@/components/BackButton'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +const { width, height } = Dimensions.get('window'); + +export default function InteractiveMapScreen() { + const router = useRouter(); + const { colors } = useThemeStore(); + const { shops, fetchShops } = useShopStore(); + + const [location, setLocation] = useState(null); + const [loading, setLoading] = useState(true); + const mapRef = useRef(null); + + useEffect(() => { + (async () => { + let { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== 'granted') { + setLoading(false); + return; + } + + let loc = await Location.getCurrentPositionAsync({}); + setLocation(loc); + + if (shops.length === 0) { + await fetchShops(); + } + + setLoading(false); + })(); + }, []); + + const initialRegion = location + ? { + latitude: location.coords.latitude, + longitude: location.coords.longitude, + latitudeDelta: 0.05, + longitudeDelta: 0.05, + } + : { + latitude: 37.78825, // default fallback + longitude: -122.4324, + latitudeDelta: 0.0922, + longitudeDelta: 0.0421, + }; + + const handleCenterMap = () => { + if (location && mapRef.current) { + mapRef.current.animateToRegion({ + latitude: location.coords.latitude, + longitude: location.coords.longitude, + latitudeDelta: 0.05, + longitudeDelta: 0.05, + }, 1000); + } + }; + + if (loading) { + return ( + + + + ); + } + + return ( + + {/* Header */} + + + Discover Shops + + + + {/* Map */} + + {shops.filter(s => s.latitude && s.longitude).map((shop) => ( + router.push(`/Shopbyname/${shop.id}` as any)} + > + + + + + + {shop.image && ( + + )} + + {shop.occupation} + {shop.category?.name && ( + {shop.category.name} + )} + {shop.address} + + + + + ))} + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + header: { + minHeight: 60, + paddingVertical: 10, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + borderBottomWidth: 1, + elevation: 4, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + zIndex: 10, + }, + backButton: { + width: 40, + height: 40, + justifyContent: 'center', + alignItems: 'flex-start', + }, + headerTitle: { + fontSize: 18, + fontWeight: '700', + }, + headerRight: { + width: 40, + }, + map: { + width: width, + height: height - 60, + }, + markerBadge: { + padding: 8, + borderRadius: 20, + borderWidth: 2, + borderColor: 'white', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + elevation: 5, + }, + calloutContainer: { + width: 200, + borderRadius: 12, + overflow: 'hidden', + padding: 0, + elevation: 4, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + }, + calloutImage: { + width: '100%', + height: 80, + }, + calloutTextContainer: { + padding: 10, + }, + calloutTitle: { + fontWeight: 'bold', + fontSize: 14, + marginBottom: 2, + }, + calloutCategory: { + fontSize: 12, + color: '#6366f1', + fontWeight: '600', + marginBottom: 4, + }, + calloutAddress: { + fontSize: 11, + color: '#6b7280', + }, + floatingButton: { + position: 'absolute', + bottom: 30, + right: 20, + width: 50, + height: 50, + borderRadius: 25, + justifyContent: 'center', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 4, + elevation: 6, + }, +}); diff --git a/app/userflow/onboarding.tsx b/app/userflow/onboarding.tsx new file mode 100644 index 0000000..86442c4 --- /dev/null +++ b/app/userflow/onboarding.tsx @@ -0,0 +1,149 @@ +import React, { useState } from 'react'; +import { View, Text, StyleSheet, SafeAreaView, Dimensions, TouchableOpacity } from 'react-native'; +import { router } from 'expo-router'; +import { useThemeStore } from '@/Store/themeStore'; +import { Ionicons } from '@expo/vector-icons'; +import Animated, { useSharedValue, useAnimatedStyle, withSpring, interpolate } from 'react-native-reanimated'; + +const { width } = Dimensions.get('window'); + +const SLIDES = [ + { + id: '1', + title: 'Discover Services', + description: 'Find the best professionals near you quickly and easily.', + icon: 'search-outline', + }, + { + id: '2', + title: 'Book Instantly', + description: 'Check real-time availability and secure your appointment.', + icon: 'calendar-outline', + }, + { + id: '3', + title: 'Manage Time', + description: 'Never miss an appointment with automated reminders.', + icon: 'time-outline', + } +]; + +export default function OnboardingScreen() { + const { colors } = useThemeStore(); + const [currentIndex, setCurrentIndex] = useState(0); + + const handleNext = () => { + if (currentIndex < SLIDES.length - 1) { + setCurrentIndex(prev => prev + 1); + } else { + // End of onboarding, go to Main App (Auth or Home) + router.replace('/(authuser)/sign-in'); // Or your root destination + } + }; + + return ( + + + + + + + {SLIDES[currentIndex].title} + + + {SLIDES[currentIndex].description} + + + + + + {SLIDES.map((_, index) => ( + + ))} + + + + + {currentIndex === SLIDES.length - 1 ? "Get Started" : "Next"} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1 }, + slideContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 40, + }, + iconContainer: { + width: 160, + height: 160, + borderRadius: 80, + backgroundColor: '#EFF6FF', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 40, + }, + title: { + fontSize: 28, + fontWeight: '800', + marginBottom: 16, + textAlign: 'center', + }, + description: { + fontSize: 16, + textAlign: 'center', + lineHeight: 24, + }, + footer: { + padding: 24, + paddingBottom: 40, + }, + pagination: { + flexDirection: 'row', + justifyContent: 'center', + marginBottom: 32, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + marginHorizontal: 4, + }, + button: { + height: 56, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#3B82F6', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 4, + }, + buttonText: { + color: 'white', + fontSize: 16, + fontWeight: '700', + } +}); diff --git a/app/utils/pushNotifications.ts b/app/utils/pushNotifications.ts new file mode 100644 index 0000000..86e4453 --- /dev/null +++ b/app/utils/pushNotifications.ts @@ -0,0 +1,48 @@ +import * as Device from 'expo-device'; +import * as Notifications from 'expo-notifications'; +import Constants from 'expo-constants'; +import { Platform } from 'react-native'; + +export async function registerForPushNotificationsAsync() { + let token; + + if (Platform.OS === 'android') { + await Notifications.setNotificationChannelAsync('default', { + name: 'default', + importance: Notifications.AndroidImportance.MAX, + vibrationPattern: [0, 250, 250, 250], + lightColor: '#FF231F7C', + }); + } + + if (Device.isDevice) { + const { status: existingStatus } = await Notifications.getPermissionsAsync(); + let finalStatus = existingStatus; + if (existingStatus !== 'granted') { + const { status } = await Notifications.requestPermissionsAsync(); + finalStatus = status; + } + if (finalStatus !== 'granted') { + console.log('Failed to get push token for push notification!'); + return undefined; + } + + const projectId = Constants.expoConfig?.extra?.eas?.projectId || Constants.easConfig?.projectId; + if (!projectId) { + console.warn('Project ID not found. Ensure app.json has expo.extra.eas.projectId defined.'); + } + + try { + const pushTokenString = (await Notifications.getExpoPushTokenAsync({ + projectId, + })).data; + token = pushTokenString; + } catch (e: unknown) { + console.warn('Error fetching Expo Push Token: ', e); + } + } else { + console.log('Must use physical device for Push Notifications'); + } + + return token; +} diff --git a/backend-01/package-lock.json b/backend-01/package-lock.json index 223ced4..7be7f64 100644 --- a/backend-01/package-lock.json +++ b/backend-01/package-lock.json @@ -4,16 +4,21 @@ "requires": true, "packages": { "": { + "hasInstallScript": true, "dependencies": { "@prisma/client": "^6.13.0", "@sendgrid/mail": "^8.1.5", "apple-signin-auth": "^2.0.0", "bcryptjs": "^3.0.2", - "cors": "^2.8.5", + "cors": "^2.8.6", "dotenv": "^17.3.1", + "expo-server-sdk": "^6.1.0", "express": "^5.1.0", + "express-rate-limit": "^8.3.1", "google-auth-library": "^10.6.1", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", + "morgan": "^1.10.1", "nodemailer": "^7.0.5", "zod": "^4.0.14" }, @@ -22,6 +27,7 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/jsonwebtoken": "^9.0.10", + "@types/morgan": "^1.9.10", "@types/node": "^24.2.0", "@types/nodemailer": "^6.4.17", "prisma": "^6.13.0", @@ -368,6 +374,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/morgan": { + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", + "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -584,6 +600,24 @@ ], "license": "MIT" }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/bcryptjs": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", @@ -830,9 +864,9 @@ } }, "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -840,6 +874,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/create-require": { @@ -1023,6 +1061,12 @@ "node": ">= 0.8" } }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1083,11 +1127,26 @@ "node": ">= 0.6" } }, + "node_modules/expo-server-sdk": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/expo-server-sdk/-/expo-server-sdk-6.1.0.tgz", + "integrity": "sha512-ISuax1AQ7cpM5RAqcu8gVcoLL0ZKskJ5OLoMWmdITBe9nYjTucjdGyBq817YkIvTcj1pAUwx+9toUT7l/V7thA==", + "license": "MIT", + "dependencies": { + "promise-limit": "^2.7.0", + "promise-retry": "^2.0.1", + "undici": "^7.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -1125,6 +1184,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/exsolve": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", @@ -1508,6 +1585,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -1590,6 +1676,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1834,6 +1929,49 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1987,6 +2125,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2123,6 +2270,25 @@ } } }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2261,6 +2427,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rimraf": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", @@ -2721,6 +2896,15 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", + "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", diff --git a/backend-01/package.json b/backend-01/package.json index 4e245c2..2484847 100644 --- a/backend-01/package.json +++ b/backend-01/package.json @@ -10,6 +10,7 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/jsonwebtoken": "^9.0.10", + "@types/morgan": "^1.9.10", "@types/node": "^24.2.0", "@types/nodemailer": "^6.4.17", "prisma": "^6.13.0", @@ -21,11 +22,15 @@ "@sendgrid/mail": "^8.1.5", "apple-signin-auth": "^2.0.0", "bcryptjs": "^3.0.2", - "cors": "^2.8.5", + "cors": "^2.8.6", "dotenv": "^17.3.1", + "expo-server-sdk": "^6.1.0", "express": "^5.1.0", + "express-rate-limit": "^8.3.1", "google-auth-library": "^10.6.1", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", + "morgan": "^1.10.1", "nodemailer": "^7.0.5", "zod": "^4.0.14" } diff --git a/backend-01/prisma/schema.prisma b/backend-01/prisma/schema.prisma index 0b4e12a..c58de49 100644 --- a/backend-01/prisma/schema.prisma +++ b/backend-01/prisma/schema.prisma @@ -23,6 +23,7 @@ model Admin { googleId String? @unique appleId String? @unique isVerified Boolean @default(false) + pushToken String? adminprofile AdminShop[] } @@ -44,6 +45,8 @@ model AdminShop { timein String timeout String isOpen Boolean @default(true) + verificationStatus String @default("pending") // pending, approved, rejected + rejectionReason String? // reason when rejected by SuperAdmin AdminId Int Admin Admin @relation(fields: [AdminId], references: [id]) @@ -51,6 +54,9 @@ model AdminShop { category Category? @relation(fields: [categoryId], references: [id]) services ShopService[] + reviews Review[] + favorites Favorite[] + images ShopImage[] } model ShopService { @@ -72,10 +78,13 @@ model User { googleId String? @unique appleId String? @unique isVerified Boolean @default(false) + pushToken String? createdAt DateTime @default(now()) bookings Booking[] profile userprofile? @relation(name: "UserToUserProfile") addresses UserAddress[] + reviews Review[] + favorites Favorite[] } model UserAddress { @@ -117,8 +126,59 @@ model Booking { startTime DateTime endTime DateTime status String @default("upcoming") // upcoming, active, completed, cancelled + paymentIntentId String? // Added for Stripe createdAt DateTime @default(now()) service ShopService @relation(fields: [shopServiceId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id]) } + +model Review { + id Int @id @default(autoincrement()) + rating Int // 1-5 + comment String? + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + shopId Int + shop AdminShop @relation(fields: [shopId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) +} + +model Favorite { + id Int @id @default(autoincrement()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + shopId Int + shop AdminShop @relation(fields: [shopId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@unique([userId, shopId]) +} + +model PromoCode { + id Int @id @default(autoincrement()) + code String @unique + discountType String // "percentage" or "fixed" + discountValue Float + maxUses Int? // null means unlimited + usesCount Int @default(0) + expiryDate DateTime? + isActive Boolean @default(true) + createdAt DateTime @default(now()) +} + +model SuperAdmin { + id Int @id @default(autoincrement()) + name String + email String @unique + password String + createdAt DateTime @default(now()) +} + +model ShopImage { + id Int @id @default(autoincrement()) + url String + shopId Int + shop AdminShop @relation(fields: [shopId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) +} diff --git a/backend-01/src/index.ts b/backend-01/src/index.ts index eb2e6e2..bc10ee8 100644 --- a/backend-01/src/index.ts +++ b/backend-01/src/index.ts @@ -3,20 +3,42 @@ import express from 'express'; import adminroutes from './routes/admin' import userroutes from './routes/user' import bookroutes from './routes/booking' +import superadminroutes from './routes/superadmin' +import analyticsroutes from './routes/analytics' +import notificationsroutes from './routes/notifications' import cors from 'cors'; - - - +import helmet from 'helmet'; +import rateLimit from 'express-rate-limit'; +import morgan from 'morgan'; const app = express(); const PORT = Number(process.env.PORT) || 3000; app.use(express.json()); -app.use(cors()); +// Security headers +app.use(helmet()); +// HTTP request logging +app.use(morgan('combined')); +// CORS allow all domains for mobile apps and specific origins for web admin +app.use(cors({ origin: '*' })); + +// Rate limiting (100 requests per 15 minutes per IP) +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests from this IP, please try again after 15 minutes' } +}); +// Apply limiter to all routes +app.use(limiter); app.use('/api/v1/admin', adminroutes) app.use('/api/v1/user', userroutes) app.use('/api/v1/booking', bookroutes) +app.use('/api/v1/superadmin', superadminroutes) +app.use('/api/v1/analytics', analyticsroutes) +app.use('/api/v1/notifications', notificationsroutes) app.get('/', (req, res) => { res.send('Hello from Express + TypeScript!'); diff --git a/backend-01/src/middleware/superadminmiddleware.ts b/backend-01/src/middleware/superadminmiddleware.ts new file mode 100644 index 0000000..5bf582e --- /dev/null +++ b/backend-01/src/middleware/superadminmiddleware.ts @@ -0,0 +1,34 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; + +const JWT_SECRET = process.env.JWT_SECRET || 'your_jwt_secret'; + +interface JwtPayload { + superAdminId: string; +} + +declare module 'express-serve-static-core' { + interface Request { + superAdmin?: { + id: string; + }; + } +} + +export const verifySuperAdminToken = (req: Request, res: Response, next: NextFunction) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Unauthorized: No token provided' }); + } + + const token = authHeader.split(' ')[1]; + + try { + const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload; + req.superAdmin = { id: decoded.superAdminId }; + next(); + } catch (error) { + return res.status(403).json({ error: 'Forbidden: Invalid or expired token' }); + } +}; diff --git a/backend-01/src/routes/admin.ts b/backend-01/src/routes/admin.ts index 82c236c..d5cf507 100644 --- a/backend-01/src/routes/admin.ts +++ b/backend-01/src/routes/admin.ts @@ -301,8 +301,9 @@ router.post('/adminshop', verifyAdminToken, async (req, res) => { timein, timeout, isOpen, - categoryName, // newly added - services // newly added, array of { name, price, durationMins, description } + categoryName, + images, // Array of strings for gallery + services } = req.body; try { @@ -336,6 +337,7 @@ router.post('/adminshop', verifyAdminToken, async (req, res) => { timein, timeout, isOpen: isOpen ?? true, + verificationStatus: 'pending', // Requires SuperAdmin approval Admin: { connect: { id: adminId } }, ...(categoryId && { category: { connect: { id: categoryId } } }), // Create nested services @@ -348,15 +350,25 @@ router.post('/adminshop', verifyAdminToken, async (req, res) => { description: s.description || null })) } + }), + // Create nested gallery images + ...(images && Array.isArray(images) && images.length > 0 && { + images: { + create: images.map((url: string) => ({ url })) + } }) }, include: { services: true, - category: true + category: true, + images: true } }); - return res.status(201).json({ shop: newShop }); + return res.status(201).json({ + shop: newShop, + message: 'Shop created successfully! It will be visible to users after verification by our team.' + }); } catch (error) { console.error(error); return res.status(500).json({ error: 'Something went wrong' }); @@ -372,7 +384,8 @@ router.get('/adminshops', verifyAdminToken, async (req, res) => { }, include: { services: true, - category: true + category: true, + images: true } }); @@ -417,4 +430,34 @@ router.patch('/adminshop/:id/settings', verifyAdminToken, async (req, res) => { } }); +// Get bookings for a specific admin shop +router.get('/adminshop/:id/bookings', verifyAdminToken, async (req, res) => { + try { + const shopId = Number(req.params.id); + const adminId = Number(req.user?.id); + + // Verify the shop belongs to this admin + const shop = await prisma.adminShop.findUnique({ where: { id: shopId } }); + if (!shop) return res.status(404).json({ error: "Shop not found" }); + if (shop.AdminId !== adminId) return res.status(403).json({ error: "Unauthorized" }); + + // Get all bookings across all services for this shop + const bookings = await prisma.booking.findMany({ + where: { + service: { shopId: shopId }, + }, + include: { + user: { select: { id: true, name: true, email: true } }, + service: { select: { name: true } } + }, + orderBy: { startTime: 'asc' } // chronological order for the calendar + }); + + res.status(200).json(bookings); + } catch (error) { + console.error("Error fetching shop bookings:", error); + res.status(500).json({ error: "Internal server error" }); + } +}); + export default router; \ No newline at end of file diff --git a/backend-01/src/routes/analytics.ts b/backend-01/src/routes/analytics.ts new file mode 100644 index 0000000..7405ffd --- /dev/null +++ b/backend-01/src/routes/analytics.ts @@ -0,0 +1,166 @@ +import express from 'express'; +import { PrismaClient } from '@prisma/client'; +import { verifyAdminToken } from '../middleware/authmiddleware'; // Admin auth middleware + +const router = express.Router(); +const prisma = new PrismaClient(); + +// ─── GET /api/v1/analytics/dashboard ────────────────────────────────────────── +// Returns summary statistics for the logged-in admin's shops +router.get('/dashboard', verifyAdminToken, async (req, res) => { + try { + const adminId = Number(req.user?.id); + + if (!adminId) { + return res.status(401).json({ message: "Unauthorized" }); + } + + // 1. Get all shops owned by this admin + const shops = await prisma.adminShop.findMany({ + where: { AdminId: adminId }, + select: { id: true } + }); + const shopIds = shops.map(shop => shop.id); + + if (shopIds.length === 0) { + return res.status(200).json({ + totalBookings: 0, + totalRevenue: 0, + totalCustomers: 0, + averageRating: 0, + recentBookings: [] + }); + } + + // 2. Fetch all bookings for these shops + const bookings = await prisma.booking.findMany({ + where: { + service: { shopId: { in: shopIds } }, + status: { not: 'cancelled' } // Only count active/completed revenue + }, + include: { + user: { select: { id: true } } + } + }); + + // 3. Compute stats + const totalBookings = bookings.length; + const totalRevenue = bookings.reduce((sum, b) => sum + (b.price || 0), 0); + + // Distinct customers + const uniqueCustomers = new Set(bookings.map(b => b.userId)); + const totalCustomers = uniqueCustomers.size; + + // 4. Calculate Average Rating (across all shops) + const reviews = await prisma.review.findMany({ + where: { shopId: { in: shopIds } }, + select: { rating: true } + }); + const averageRating = reviews.length > 0 + ? reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length + : 0; + + // 5. Booking trend (last 7 days) + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + sevenDaysAgo.setHours(0, 0, 0, 0); + + const recentBookingsRaw = await prisma.booking.groupBy({ + by: ['createdAt'], + where: { + service: { shopId: { in: shopIds } }, + createdAt: { gte: sevenDaysAgo } + }, + _count: { id: true }, + orderBy: { createdAt: 'asc' } + }); + + // Group by day string (YYYY-MM-DD) + const trendMap: Record = {}; + for (let i = 0; i < 7; i++) { + const d = new Date(sevenDaysAgo); + d.setDate(d.getDate() + i); + trendMap[d.toISOString().split('T')[0]] = 0; + } + + recentBookingsRaw.forEach(b => { + const dayStr = b.createdAt.toISOString().split('T')[0]; + if (trendMap[dayStr] !== undefined) { + trendMap[dayStr] += b._count.id; + } + }); + + const recentBookings = Object.keys(trendMap).map(date => ({ + date, + count: trendMap[date] + })); + + res.status(200).json({ + totalBookings, + totalRevenue, + totalCustomers, + averageRating: Number(averageRating.toFixed(1)), + recentBookings + }); + + } catch (error: any) { + console.error("Dashboard Analytics Error:", error); + res.status(500).json({ message: "Failed to fetch analytics dashboard", error: error.message }); + } +}); + +// ─── GET /api/v1/analytics/services ─────────────────────────────────────────── +// Returns a breakdown of bookings per service +router.get('/services', verifyAdminToken, async (req, res) => { + try { + const adminId = Number(req.user?.id); + + const shops = await prisma.adminShop.findMany({ + where: { AdminId: adminId }, + select: { id: true } + }); + const shopIds = shops.map(shop => shop.id); + + if (shopIds.length === 0) return res.status(200).json([]); + + // Get count of bookings grouped by service + const serviceBookings = await prisma.booking.groupBy({ + by: ['shopServiceId'], + where: { + service: { shopId: { in: shopIds } }, + status: { not: 'cancelled' } + }, + _count: { id: true }, + _sum: { price: true }, + orderBy: { + _count: { id: 'desc' } + }, + take: 5 // Top 5 services + }); + + // Fetch service names + const serviceIds = serviceBookings.map(sb => sb.shopServiceId); + const services = await prisma.shopService.findMany({ + where: { id: { in: serviceIds } }, + select: { id: true, name: true } + }); + + const result = serviceBookings.map(sb => { + const service = services.find(s => s.id === sb.shopServiceId); + return { + serviceId: sb.shopServiceId, + serviceName: service?.name || 'Unknown Service', + bookingCount: sb._count.id, + totalRevenue: sb._sum.price || 0 + }; + }); + + res.status(200).json(result); + + } catch (error: any) { + console.error("Service Analytics Error:", error); + res.status(500).json({ message: "Failed to fetch service analytics", error: error.message }); + } +}); + +export default router; diff --git a/backend-01/src/routes/booking.ts b/backend-01/src/routes/booking.ts index 0c7bf56..24a1381 100644 --- a/backend-01/src/routes/booking.ts +++ b/backend-01/src/routes/booking.ts @@ -1,6 +1,7 @@ import express from 'express'; import { PrismaClient } from '@prisma/client'; import { verifyToken } from '../middleware/usermiddleware'; +import { sendPushNotification } from './notifications'; const router = express.Router(); const prisma = new PrismaClient(); @@ -184,7 +185,7 @@ router.post('/', verifyToken, async (req, res) => { } // 3️⃣ Create booking - const booking = await prisma.booking.create({ + const booking: any = await prisma.booking.create({ data: { shopServiceId, duration, @@ -201,7 +202,7 @@ router.post('/', verifyToken, async (req, res) => { shop: { select: { occupation: true, - Admin: { select: { name: true, email: true } } + Admin: { select: { name: true, email: true, pushToken: true } } } } } @@ -214,7 +215,18 @@ router.post('/', verifyToken, async (req, res) => { // 4️⃣ Optional: Notify admin const adminEmail = booking.service.shop.Admin.email; + const pushToken = booking.service.shop.Admin.pushToken; console.log(`📢 Notify admin ${adminEmail}: ${booking.user.name} booked from ${bookingStart} to ${bookingEnd}`); + + if (pushToken) { + // Background notification + sendPushNotification( + pushToken, + "New Booking Received! 🎉", + `${booking.user.name} just booked a ${booking.duration}m session.`, + { bookingId: booking.id } + ); + } res.status(201).json({ message: "Booking created successfully", @@ -264,7 +276,31 @@ router.get('/', verifyToken, async (req, res) => { } }); - res.status(200).json(bookings); + // Auto-transition upcoming bookings that are in the past to 'completed' + const now = new Date(); + let needsUpdate = false; + + const currentBookings = bookings.map(booking => { + if (booking.status === 'upcoming' && new Date(booking.endTime) < now) { + needsUpdate = true; + return { ...booking, status: 'completed' }; + } + return booking; + }); + + if (needsUpdate) { + // Update db async, no need to block the response + prisma.booking.updateMany({ + where: { + userId, + status: 'upcoming', + endTime: { lt: now } + }, + data: { status: 'completed' } + }).catch(console.error); + } + + res.status(200).json(currentBookings); } catch (error: any) { console.error("Error fetching bookings:", error); res.status(500).json({ @@ -280,9 +316,18 @@ router.delete('/:id', verifyToken, async (req, res) => { const bookingId = Number(req.params.id); const userId = Number(req.user?.id); - const booking = await prisma.booking.findUnique({ - where: { id: bookingId } - }); + const booking = (await prisma.booking.findUnique({ + where: { id: bookingId }, + include: { + user: { select: { name: true } }, + service: { + include: { shop: { select: { Admin: { select: { + // @ts-ignore + pushToken: true + } } } } } + } + } + })) as any; if (!booking) { return res.status(404).json({ message: "Booking not found" }); @@ -294,9 +339,23 @@ router.delete('/:id', verifyToken, async (req, res) => { const updatedBooking = await prisma.booking.update({ where: { id: bookingId }, - data: { booked: false } + data: { + booked: false, + status: 'cancelled' + } }); + const adminPushToken = booking.service.shop.Admin.pushToken; + if (adminPushToken) { + // Background notification + sendPushNotification( + adminPushToken, + "Booking Cancelled ❌", + `${booking.user.name} has cancelled their booking.`, + { bookingId } + ); + } + res.status(200).json({ message: "Booking cancelled successfully", booking: updatedBooking }); } catch (error: any) { console.error("Error cancelling booking:", error); @@ -304,4 +363,95 @@ router.delete('/:id', verifyToken, async (req, res) => { } }); +// PATCH (Reschedule) a booking +router.patch('/:id/reschedule', verifyToken, async (req, res) => { + try { + const bookingId = Number(req.params.id); + const userId = Number(req.user?.id); + const { bookingStart, bookingEnd } = req.body; + + if (!bookingStart || !bookingEnd) { + return res.status(400).json({ message: "bookingStart and bookingEnd are required" }); + } + + const startDt = new Date(bookingStart); + const endDt = new Date(bookingEnd); + const now = new Date(); + + if (startDt < now) { + return res.status(400).json({ message: "Cannot reschedule to a past time slot." }); + } + if (endDt <= startDt) { + return res.status(400).json({ message: "End time must be after start time." }); + } + + const booking = (await prisma.booking.findUnique({ + where: { id: bookingId }, + include: { + service: { + include: { shop: { select: { Admin: { select: { + // @ts-ignore + pushToken: true + } } } } } + }, + user: { select: { name: true } } + } + })) as any; + + if (!booking) { + return res.status(404).json({ message: "Booking not found" }); + } + if (booking.userId !== userId) { + return res.status(403).json({ message: "Unauthorized to reschedule this booking" }); + } + if (booking.status === 'cancelled' || booking.status === 'completed') { + return res.status(400).json({ message: "Cannot reschedule cancelled or completed bookings" }); + } + + // Check for overlaps (ignoring the current booking itself) + const conflictingBooking = await prisma.booking.findFirst({ + where: { + id: { not: bookingId }, + service: { shopId: booking.service.shopId }, + status: { not: "cancelled" }, + AND: [ + { startTime: { lt: endDt } }, + { endTime: { gt: startDt } } + ] + } + }); + + if (conflictingBooking) { + return res.status(400).json({ + message: "This exact time slot is already booked. Please select an available slot.", + code: "SLOT_UNAVAILABLE" + }); + } + + const updatedBooking = await prisma.booking.update({ + where: { id: bookingId }, + data: { + startTime: startDt, + endTime: endDt + } + }); + + const adminPushToken = booking.service.shop.Admin.pushToken; + if (adminPushToken) { + // Background notification + sendPushNotification( + adminPushToken, + "Booking Rescheduled 📅", + `${booking.user.name} rescheduled their booking.`, + { bookingId } + ); + } + + res.status(200).json({ message: "Booking rescheduled successfully", booking: updatedBooking }); + } catch (error: any) { + console.error("Error rescheduling booking:", error); + res.status(500).json({ message: "Failed to reschedule booking", error: error.message }); + } +}); + export default router; \ No newline at end of file diff --git a/backend-01/src/routes/notifications.ts b/backend-01/src/routes/notifications.ts new file mode 100644 index 0000000..c2e63b0 --- /dev/null +++ b/backend-01/src/routes/notifications.ts @@ -0,0 +1,97 @@ +import express from 'express'; +import { PrismaClient } from '@prisma/client'; +import { verifyToken } from '../middleware/usermiddleware'; +import { verifyAdminToken } from '../middleware/authmiddleware'; +import { Expo } from 'expo-server-sdk'; + +const router = express.Router(); +const prisma = new PrismaClient(); +const expo = new Expo(); + +// Register push token for User +router.post('/register-user-token', verifyToken, async (req, res) => { + try { + const userId = Number(req.user?.id); + const { pushToken } = req.body; + + if (!pushToken) { + return res.status(400).json({ error: "Push token is required" }); + } + + if (!Expo.isExpoPushToken(pushToken)) { + console.error(`Push token ${pushToken} is not a valid Expo push token`); + return res.status(400).json({ error: "Invalid push token format" }); + } + + await prisma.user.update({ + where: { id: userId }, + // @ts-ignore - Prisma types might not be updated in TS server yet + data: { pushToken }, + }); + + res.status(200).json({ message: "User push token registered successfully" }); + } catch (error) { + console.error("Error registering user push token:", error); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// Register push token for Admin +router.post('/register-admin-token', verifyAdminToken, async (req, res) => { + try { + const adminId = Number(req.user?.id); + const { pushToken } = req.body; + + if (!pushToken) { + return res.status(400).json({ error: "Push token is required" }); + } + + if (!Expo.isExpoPushToken(pushToken)) { + console.error(`Push token ${pushToken} is not a valid Expo push token`); + return res.status(400).json({ error: "Invalid push token format" }); + } + + await prisma.admin.update({ + where: { id: adminId }, + // @ts-ignore - Prisma types might not be updated in TS server yet + data: { pushToken }, + }); + + res.status(200).json({ message: "Admin push token registered successfully" }); + } catch (error) { + console.error("Error registering admin push token:", error); + res.status(500).json({ error: "Internal server error" }); + } +}); + +/** + * Helper function to send push notifications + */ +export const sendPushNotification = async (pushToken: string | null, title: string, body: string, data: Record = {}) => { + if (!pushToken || !Expo.isExpoPushToken(pushToken)) { + console.log(`Cannot send notification. Invalid or missing token: ${pushToken}`); + return; + } + + const messages = [{ + to: pushToken, + sound: 'default' as const, + title, + body, + data, + }]; + + const chunks = expo.chunkPushNotifications(messages); + const tickets = []; + + for (let chunk of chunks) { + try { + const ticketChunk = await expo.sendPushNotificationsAsync(chunk); + tickets.push(...ticketChunk); + } catch (error) { + console.error("Error sending push notification chunk:", error); + } + } +}; + +export default router; diff --git a/backend-01/src/routes/superadmin.ts b/backend-01/src/routes/superadmin.ts new file mode 100644 index 0000000..75fd829 --- /dev/null +++ b/backend-01/src/routes/superadmin.ts @@ -0,0 +1,224 @@ +import express from 'express'; +import { z } from 'zod'; +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcryptjs'; +import { PrismaClient } from '@prisma/client'; +import { verifySuperAdminToken } from '../middleware/superadminmiddleware'; + +const prisma = new PrismaClient(); +const router = express.Router(); +const JWT_SECRET = process.env.JWT_SECRET || 'your_jwt_secret'; + +// ─── Sign In ────────────────────────────────────────────────────────────────── +const signinSchema = z.object({ + email: z.string().email(), + password: z.string(), +}); + +router.post('/signin', async (req, res) => { + try { + const result = signinSchema.safeParse(req.body); + if (!result.success) { + return res.status(400).json({ error: result.error.flatten().fieldErrors }); + } + + const { email, password } = result.data; + + const superAdmin = await prisma.superAdmin.findUnique({ where: { email } }); + if (!superAdmin) return res.status(400).json({ error: 'Invalid credentials' }); + + const isValid = await bcrypt.compare(password, superAdmin.password); + if (!isValid) return res.status(400).json({ error: 'Invalid credentials' }); + + const token = jwt.sign({ superAdminId: String(superAdmin.id) }, JWT_SECRET); + + return res.json({ + token, + superAdmin: { id: superAdmin.id, name: superAdmin.name, email: superAdmin.email } + }); + } catch (error) { + console.error('SuperAdmin signin error:', error); + return res.status(500).json({ error: 'Server error during signin' }); + } +}); + +// ─── Get Current SuperAdmin ─────────────────────────────────────────────────── +router.get('/me', verifySuperAdminToken, async (req, res) => { + try { + const id = Number(req.superAdmin?.id); + const superAdmin = await prisma.superAdmin.findUnique({ where: { id } }); + if (!superAdmin) return res.status(404).json({ error: 'SuperAdmin not found' }); + return res.json({ id: superAdmin.id, name: superAdmin.name, email: superAdmin.email }); + } catch { + return res.status(500).json({ error: 'Server error' }); + } +}); + +// ─── List Pending Shops ─────────────────────────────────────────────────────── +router.get('/shops/pending', verifySuperAdminToken, async (req, res) => { + try { + const shops = await prisma.adminShop.findMany({ + where: { verificationStatus: 'pending' }, + include: { + Admin: { select: { id: true, name: true, email: true } }, + category: true, + services: true, + // @ts-ignore + images: true + }, + orderBy: { id: 'desc' }, + }); + return res.status(200).json({ shops, count: shops.length }); + } catch (error) { + console.error('Error fetching pending shops:', error); + return res.status(500).json({ error: 'Failed to fetch pending shops' }); + } +}); + +// ─── List All Shops (with status) ───────────────────────────────────────────── +router.get('/shops/all', verifySuperAdminToken, async (req, res) => { + try { + const { status } = req.query; + + const whereClause: any = {}; + if (status && typeof status === 'string') { + whereClause.verificationStatus = status; + } + + const shops = await prisma.adminShop.findMany({ + where: whereClause, + include: { + Admin: { select: { id: true, name: true, email: true } }, + category: true, + services: true, + // @ts-ignore + images: true + }, + orderBy: { id: 'desc' }, + }); + return res.status(200).json({ shops, count: shops.length }); + } catch (error) { + console.error('Error fetching shops:', error); + return res.status(500).json({ error: 'Failed to fetch shops' }); + } +}); + +// ─── Approve a Shop ────────────────────────────────────────────────────────── +router.patch('/shops/:id/approve', verifySuperAdminToken, async (req, res) => { + try { + const shopId = Number(req.params.id); + + const shop = await prisma.adminShop.findUnique({ where: { id: shopId } }); + if (!shop) return res.status(404).json({ error: 'Shop not found' }); + + const updatedShop = await prisma.adminShop.update({ + where: { id: shopId }, + data: { + verificationStatus: 'approved', + rejectionReason: null, + }, + include: { + Admin: { select: { id: true, name: true, email: true } }, + category: true, + services: true, + // @ts-ignore + images: true + }, + }); + + console.log(`✅ Shop #${shopId} approved by SuperAdmin`); + return res.status(200).json({ message: 'Shop approved successfully', shop: updatedShop }); + } catch (error) { + console.error('Error approving shop:', error); + return res.status(500).json({ error: 'Failed to approve shop' }); + } +}); + +// ─── Reject a Shop ─────────────────────────────────────────────────────────── +router.patch('/shops/:id/reject', verifySuperAdminToken, async (req, res) => { + try { + const shopId = Number(req.params.id); + const { reason } = req.body; + + const shop = await prisma.adminShop.findUnique({ where: { id: shopId } }); + if (!shop) return res.status(404).json({ error: 'Shop not found' }); + + const updatedShop = await prisma.adminShop.update({ + where: { id: shopId }, + data: { + verificationStatus: 'rejected', + rejectionReason: reason || 'Your shop listing did not meet our guidelines.', + }, + include: { + Admin: { select: { id: true, name: true, email: true } }, + category: true, + services: true, + // @ts-ignore + images: true + }, + }); + + console.log(`❌ Shop #${shopId} rejected by SuperAdmin. Reason: ${reason || 'N/A'}`); + return res.status(200).json({ message: 'Shop rejected', shop: updatedShop }); + } catch (error) { + console.error('Error rejecting shop:', error); + return res.status(500).json({ error: 'Failed to reject shop' }); + } +}); + +// ─── Dashboard Stats ────────────────────────────────────────────────────────── +router.get('/stats', verifySuperAdminToken, async (req, res) => { + try { + const [totalShops, pendingShops, approvedShops, rejectedShops, totalUsers, totalBookings] = await Promise.all([ + prisma.adminShop.count(), + prisma.adminShop.count({ where: { verificationStatus: 'pending' } }), + prisma.adminShop.count({ where: { verificationStatus: 'approved' } }), + prisma.adminShop.count({ where: { verificationStatus: 'rejected' } }), + prisma.user.count(), + prisma.booking.count(), + ]); + + return res.status(200).json({ + totalShops, + pendingShops, + approvedShops, + rejectedShops, + totalUsers, + totalBookings, + }); + } catch (error) { + console.error('Error fetching stats:', error); + return res.status(500).json({ error: 'Failed to fetch stats' }); + } +}); + +// ─── Seed SuperAdmin (one-time setup) ───────────────────────────────────────── +router.post('/seed', async (req, res) => { + try { + const existing = await prisma.superAdmin.findFirst(); + if (existing) { + return res.status(400).json({ error: 'SuperAdmin already exists' }); + } + + const hashedPassword = await bcrypt.hash('SuperAdmin@123', 10); + + const superAdmin = await prisma.superAdmin.create({ + data: { + name: 'Super Admin', + email: 'admin@timewatcher.com', + password: hashedPassword, + }, + }); + + return res.status(201).json({ + message: 'SuperAdmin created successfully', + email: superAdmin.email, + defaultPassword: 'SuperAdmin@123 (change this immediately!)', + }); + } catch (error) { + console.error('Error seeding SuperAdmin:', error); + return res.status(500).json({ error: 'Failed to seed SuperAdmin' }); + } +}); + +export default router; diff --git a/backend-01/src/routes/user.ts b/backend-01/src/routes/user.ts index 98715d9..19152e6 100644 --- a/backend-01/src/routes/user.ts +++ b/backend-01/src/routes/user.ts @@ -336,11 +336,14 @@ router.get("/", verifyToken, async (req, res) => { router.get('/adminshops', async (req, res) => { try { const shops = await prisma.adminShop.findMany({ + where: { verificationStatus: 'approved' }, // Only show approved shops to users include: { services: true, - category: true + category: true, + // @ts-ignore + images: true } - }); // no filter -> all shops + }); return res.status(200).json({ shops }); } catch (error) { console.error('Error fetching shops:', error); @@ -507,4 +510,240 @@ router.put('/addresses/:id/default', verifyToken, async (req, res) => { } }); +// ---------------------------------------------------------------------- +// REVIEWS APIS +// ---------------------------------------------------------------------- + +// 1. Create a review +router.post('/reviews', verifyToken, async (req, res) => { + try { + const userId = Number(req.user?.id); + const { shopId, rating, comment } = req.body; + + if (!shopId || !rating || rating < 1 || rating > 5) { + return res.status(400).json({ error: "Valid shopId and rating (1-5) are required" }); + } + + // Verify eligibility before creating + const shopServices = await prisma.shopService.findMany({ + where: { shopId: Number(shopId) } + }); + const serviceIds = shopServices.map((s: any) => s.id); + + const completedBooking = await prisma.booking.findFirst({ + where: { + userId, + shopServiceId: { in: serviceIds }, + status: 'completed', // Only if booking is completed + } + }); + + if (!completedBooking) { + return res.status(403).json({ error: "You can only review shops you have completed a booking with." }); + } + + const review = await prisma.review.create({ + data: { + userId, + shopId: Number(shopId), + rating: Number(rating), + comment: comment || null, + } + }); + + res.status(201).json(review); + } catch (error) { + console.error("Error creating review:", error); + res.status(500).json({ error: "Failed to create review" }); + } +}); + +// 2. Check review eligibility +router.get('/reviews/eligibility/:shopId', verifyToken, async (req, res) => { + try { + const userId = Number(req.user?.id); + const shopId = Number(req.params.shopId); + + if (!shopId) return res.status(400).json({ error: "shopId is required" }); + + // Check if user has ANY completed booking for this shop + const shopServices = await prisma.shopService.findMany({ + where: { shopId: Number(shopId) } + }); + const serviceIds = shopServices.map((s: any) => s.id); + + const completedBooking = await prisma.booking.findFirst({ + where: { + userId, + shopServiceId: { in: serviceIds }, + status: 'completed', + } + }); + + res.status(200).json({ eligible: !!completedBooking }); + } catch (error) { + console.error("Error checking eligibility:", error); + res.status(500).json({ error: "Failed to check eligibility" }); + } +}); + +// 3. Get reviews for a shop +router.get('/reviews/:shopId', async (req, res) => { + try { + const shopId = Number(req.params.shopId); + const reviews = await prisma.review.findMany({ + where: { shopId }, + include: { user: { select: { id: true, name: true, profile: { select: { image: true } } } } }, + orderBy: { createdAt: 'desc' } + }); + + // Calculate average rating + const avg = reviews.length > 0 ? reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length : 0; + + res.status(200).json({ reviews, averageRating: avg, totalCount: reviews.length }); + } catch (error) { + console.error("Error fetching reviews:", error); + res.status(500).json({ error: "Failed to fetch reviews" }); + } +}); + +// ---------------------------------------------------------------------- +// FAVORITES APIS +// ---------------------------------------------------------------------- + +// 1. Toggle favorite +router.post('/favorites', verifyToken, async (req, res) => { + try { + const userId = Number(req.user?.id); + const { shopId } = req.body; + + if (!shopId) return res.status(400).json({ error: "shopId is required" }); + + const existing = await prisma.favorite.findUnique({ + where: { userId_shopId: { userId, shopId: Number(shopId) } } + }); + + if (existing) { + // Remove from favorites + await prisma.favorite.delete({ where: { id: existing.id } }); + return res.status(200).json({ message: "Removed from favorites", isFavorite: false }); + } else { + // Add to favorites + const favorite = await prisma.favorite.create({ + data: { userId, shopId: Number(shopId) } + }); + return res.status(201).json({ message: "Added to favorites", isFavorite: true, favorite }); + } + } catch (error) { + console.error("Error toggling favorite:", error); + res.status(500).json({ error: "Failed to toggle favorite" }); + } +}); + +// 2. Get user favorites +router.get('/favorites', verifyToken, async (req, res) => { + try { + const userId = Number(req.user?.id); + const favorites = await prisma.favorite.findMany({ + where: { userId }, + include: { shop: { include: { services: true, category: true, reviews: true } } }, + orderBy: { createdAt: 'desc' } + }); + res.status(200).json(favorites); + } catch (error) { + console.error("Error fetching favorites:", error); + res.status(500).json({ error: "Failed to fetch favorites" }); + } +}); + +// ---------------------------------------------------------------------- +// DISCOVERY & FEED APIS +// ---------------------------------------------------------------------- + +// 1. Home Feed Data (Recommended and Top Rated) +router.get('/feed/home', async (req, res) => { + try { + // In a real prod environment, 'recommended' would use ML or user vectors. + // For now, return the most recently added shops as 'new' and highest reviews as 'topRated' + const latestShops = (await prisma.adminShop.findMany({ + where: { isOpen: true, verificationStatus: 'approved' }, + take: 10, + orderBy: { id: 'desc' }, + include: { category: true, reviews: true, services: true, + // @ts-ignore + images: true + } + })) as any; + + // Get top rated based on reviews or just generic shops if no reviews exist yet + const popularShops = (await prisma.adminShop.findMany({ + where: { isOpen: true, verificationStatus: 'approved' }, + take: 10, + include: { category: true, reviews: true, services: true, + // @ts-ignore + images: true + } + })) as any; + + // Sort popularShops by average review dynamically + popularShops.sort((a: any, b: any) => { + const avgA = a.reviews.length > 0 ? a.reviews.reduce((acc: number, crr: any) => acc + crr.rating, 0) / a.reviews.length : 0; + const avgB = b.reviews.length > 0 ? b.reviews.reduce((acc: number, crr: any) => acc + crr.rating, 0) / b.reviews.length : 0; + return avgB - avgA; + }); + + res.status(200).json({ recommended: latestShops, popular: popularShops }); + } catch (error) { + console.error("Error fetching home feed:", error); + res.status(500).json({ error: "Failed to fetch home feed" }); + } +}); + +// 2. Search & Advanced Filtering +router.get('/search/shops', async (req, res) => { + try { + const { query, categoryId, minPrice, maxPrice } = req.query; + + // Base where clause + let whereClause: any = { isOpen: true, verificationStatus: 'approved' }; + + if (query) { + whereClause.OR = [ + { occupation: { contains: String(query), mode: "insensitive" } }, + { address: { contains: String(query), mode: "insensitive" } }, + { services: { some: { name: { contains: String(query), mode: "insensitive" } } } } + ]; + } + + if (categoryId) { + whereClause.categoryId = Number(categoryId); + } + + // Apply price filters if specified (price is on the Service level) + if (minPrice || maxPrice) { + whereClause.services = { + some: { + price: { + ...(minPrice && { gte: Number(minPrice) }), + ...(maxPrice && { lte: Number(maxPrice) }) + } + } + }; + } + + const shops = await prisma.adminShop.findMany({ + where: whereClause, + include: { category: true, services: true, reviews: true, + // @ts-ignore + images: true + } + }); + + res.status(200).json({ results: shops }); + } catch (error) { + console.error("Error searching shops:", error); + res.status(500).json({ error: "Failed to search shops" }); + } +}); + export default router; \ No newline at end of file diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx new file mode 100644 index 0000000..ffbee96 --- /dev/null +++ b/components/ErrorBoundary.tsx @@ -0,0 +1,100 @@ +import React, { Component, ErrorInfo, ReactNode } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, SafeAreaView } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Uncaught component crash:', error, errorInfo); + } + + private handleReset = () => { + this.setState({ hasError: false, error: null }); + }; + + public render() { + if (this.state.hasError) { + return ( + + + + + + Oops! Something went wrong. + + We've encountered an unexpected error. Please try again or restart the application. + + + + {this.state.error?.message} + + + + Reload Application + + + + ); + } + + return this.props.children; + } +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#F9FAFB' }, + content: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32 }, + iconWrapper: { + width: 96, + height: 96, + borderRadius: 48, + backgroundColor: '#FEF2F2', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 24, + }, + title: { fontSize: 22, fontWeight: '800', marginBottom: 12, color: '#111827', textAlign: 'center' }, + message: { fontSize: 15, color: '#6B7280', textAlign: 'center', marginBottom: 32, lineHeight: 22 }, + errorBox: { + backgroundColor: '#F3F4F6', + padding: 16, + borderRadius: 12, + width: '100%', + marginBottom: 40, + borderWidth: 1, + borderColor: '#E5E7EB' + }, + errorText: { fontSize: 13, color: '#4B5563', fontFamily: 'monospace' }, + button: { + backgroundColor: '#111827', + paddingHorizontal: 24, + height: 52, + width: '100%', + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 12, + elevation: 4 + }, + buttonText: { color: 'white', fontWeight: '700', fontSize: 16 } +}); diff --git a/constants/adminApi.ts b/constants/adminApi.ts index b05453b..c814381 100644 --- a/constants/adminApi.ts +++ b/constants/adminApi.ts @@ -12,10 +12,16 @@ export const adminSignin = async (email: string, password: string) => { return res.data; // { token } }; +export const registerAdminPushToken = async (pushToken: string) => { + const res = await apiClient.post('/api/v1/notifications/register-admin-token', { pushToken }); + return res.data; +}; + // ─── Admin Shop ──────────────────────────────────────────────────────────────── export interface AdminShopPayload { image: string; + images?: string[]; latitude: number | null; longitude: number | null; address: string; @@ -42,3 +48,8 @@ export const updateAdminShopSettings = async (shopId: number, settings: { isOpen const res = await apiClient.patch(`/api/v1/admin/adminshop/${shopId}/settings`, settings); return res.data; }; + +export const getAdminShopBookings = async (shopId: number) => { + const res = await apiClient.get(`/api/v1/admin/adminshop/${shopId}/bookings`); + return res.data; +}; diff --git a/constants/analyticsApi.ts b/constants/analyticsApi.ts new file mode 100644 index 0000000..e66ef1e --- /dev/null +++ b/constants/analyticsApi.ts @@ -0,0 +1,11 @@ +import apiClient from './axiosInstance'; + +export const getDashboardAnalytics = async () => { + const res = await apiClient.get('/api/v1/analytics/dashboard'); + return res.data; +}; + +export const getServiceAnalytics = async () => { + const res = await apiClient.get('/api/v1/analytics/services'); + return res.data; +}; diff --git a/constants/api.ts b/constants/api.ts index 7a3575c..a0f6ea6 100644 --- a/constants/api.ts +++ b/constants/api.ts @@ -7,6 +7,7 @@ const LOCAL_IP = '192.168.29.237'; // <-- Replace with YOUR Mac's LAN IP const LOCAL_URL = `http://${LOCAL_IP}:3000`; const PROD_URL = 'https://timewatcher.onrender.com'; -// export const BASE_URL = __DEV__ ? LOCAL_URL : PROD_URL; -export const BASE_URL = PROD_URL; -console.log(PROD_URL) \ No newline at end of file +export const BASE_URL = __DEV__ ? LOCAL_URL : PROD_URL; +// export const BASE_URL = PROD_URL; +// export const BASE_URL = PROD_URL; +console.log(BASE_URL) \ No newline at end of file diff --git a/constants/bookingApi.ts b/constants/bookingApi.ts index e2343e6..268a575 100644 --- a/constants/bookingApi.ts +++ b/constants/bookingApi.ts @@ -31,3 +31,8 @@ export const cancelBooking = async (bookingId: number) => { const res = await apiClient.delete(`/api/v1/booking/${bookingId}`); return res.data; }; + +export const rescheduleBooking = async (bookingId: number, data: { bookingStart: string; bookingEnd: string }) => { + const res = await apiClient.patch(`/api/v1/booking/${bookingId}/reschedule`, data); + return res.data; +}; diff --git a/constants/userApi.ts b/constants/userApi.ts index 51f163b..f2f17e7 100644 --- a/constants/userApi.ts +++ b/constants/userApi.ts @@ -33,6 +33,11 @@ export const userSignout = async () => { await SecureStore.deleteItemAsync('usertoken'); }; +export const registerUserPushToken = async (pushToken: string) => { + const res = await apiClient.post('/api/v1/notifications/register-user-token', { pushToken }); + return res.data; +}; + // ─── User Profile ────────────────────────────────────────────────────────────── export const getUserProfile = async () => { diff --git a/package-lock.json b/package-lock.json index adb8b28..033d54f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "expo-blur": "~15.0.8", "expo-constants": "~18.0.13", "expo-crypto": "~15.0.8", + "expo-device": "~8.0.10", "expo-font": "~14.0.11", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", @@ -33,6 +34,7 @@ "expo-linear-gradient": "~15.0.8", "expo-linking": "~8.0.11", "expo-location": "~19.0.8", + "expo-notifications": "~0.32.16", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", @@ -45,6 +47,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", + "react-native-calendars": "^1.1314.0", "react-native-dotenv": "^3.4.11", "react-native-gesture-handler": "~2.28.0", "react-native-keyboard-aware-scroll-view": "^0.9.5", @@ -2364,6 +2367,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ide/backoff": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz", + "integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==", + "license": "MIT" + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -4451,6 +4460,19 @@ "version": "2.0.6", "license": "MIT" }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, "node_modules/async-function": { "version": "1.0.0", "dev": true, @@ -4471,7 +4493,6 @@ }, "node_modules/available-typed-arrays": { "version": "1.0.7", - "dev": true, "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -4691,6 +4712,12 @@ "@babel/core": "^7.0.0" } }, + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "license": "MIT" @@ -4885,7 +4912,6 @@ }, "node_modules/call-bind": { "version": "1.0.8", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -4913,7 +4939,6 @@ }, "node_modules/call-bound": { "version": "1.0.4", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5470,7 +5495,6 @@ }, "node_modules/define-data-property": { "version": "1.1.4", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -5495,7 +5519,6 @@ }, "node_modules/define-properties": { "version": "1.2.1", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -6354,6 +6377,44 @@ "expo": "*" } }, + "node_modules/expo-device": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz", + "integrity": "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==", + "license": "MIT", + "dependencies": { + "ua-parser-js": "^0.7.33" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-device/node_modules/ua-parser-js": { + "version": "0.7.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", + "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/expo-file-system": { "version": "19.0.21", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", @@ -6498,6 +6559,26 @@ "react-native": "*" } }, + "node_modules/expo-notifications": { + "version": "0.32.16", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.16.tgz", + "integrity": "sha512-QQD/UA6v7LgvwIJ+tS7tSvqJZkdp0nCSj9MxsDk/jU1GttYdK49/5L2LvE/4U0H7sNBz1NZAyhDZozg8xgBLXw==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.8.8", + "@ide/backoff": "^1.0.0", + "abort-controller": "^3.0.0", + "assert": "^2.0.0", + "badgin": "^1.1.5", + "expo-application": "~7.0.8", + "expo-constants": "~18.0.13" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-router": { "version": "6.0.23", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz", @@ -6899,7 +6980,6 @@ }, "node_modules/for-each": { "version": "0.3.5", - "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.2.7" @@ -7216,7 +7296,6 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -7479,6 +7558,22 @@ "loose-envify": "^1.0.0" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "dev": true, @@ -7573,7 +7668,6 @@ }, "node_modules/is-callable": { "version": "1.2.7", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7673,7 +7767,6 @@ }, "node_modules/is-generator-function": { "version": "1.1.0", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -7709,6 +7802,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-negative-zero": { "version": "2.0.3", "dev": true, @@ -7751,7 +7860,6 @@ }, "node_modules/is-regex": { "version": "1.2.1", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7824,7 +7932,6 @@ }, "node_modules/is-typed-array": { "version": "1.1.15", - "dev": true, "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -8525,6 +8632,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -9112,6 +9225,16 @@ "node": ">=10" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "license": "MIT" @@ -9300,9 +9423,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9310,7 +9448,6 @@ }, "node_modules/object.assign": { "version": "4.1.7", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -9727,7 +9864,6 @@ }, "node_modules/possible-typed-array-names": { "version": "1.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10171,6 +10307,27 @@ } } }, + "node_modules/react-native-calendars": { + "version": "1.1314.0", + "resolved": "https://registry.npmjs.org/react-native-calendars/-/react-native-calendars-1.1314.0.tgz", + "integrity": "sha512-4DLAVto8Qo9L3ggL2vsY9Gk8FFpJWtne8F/3wN8yUb7Xha9/SKS4B+vs7xlhWjKeqZUHws/Vi/q/6IZ8s60kcQ==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.1", + "lodash": "^4.17.15", + "memoize-one": "^5.2.1", + "prop-types": "^15.5.10", + "react-native-swipe-gestures": "^1.0.5", + "recyclerlistview": "^4.0.0", + "xdate": "^0.8.0" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "moment": "^2.29.4" + } + }, "node_modules/react-native-css-interop": { "version": "0.1.22", "license": "MIT", @@ -10357,6 +10514,12 @@ "react-native-linear-gradient": "^2.5.6" } }, + "node_modules/react-native-swipe-gestures": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/react-native-swipe-gestures/-/react-native-swipe-gestures-1.0.5.tgz", + "integrity": "sha512-Ns7Bn9H/Tyw278+5SQx9oAblDZ7JixyzeOczcBK8dipQk2pD7Djkcfnf1nB/8RErAmMLL9iXgW0QHqiII8AhKw==", + "license": "MIT" + }, "node_modules/react-native-web": { "version": "0.21.2", "license": "MIT", @@ -10598,6 +10761,21 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/recyclerlistview": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/recyclerlistview/-/recyclerlistview-4.2.3.tgz", + "integrity": "sha512-STR/wj/FyT8EMsBzzhZ1l2goYirMkIgfV3gYEPxI3Kf3lOnu6f7Dryhyw7/IkQrgX5xtTcDrZMqytvteH9rL3g==", + "license": "Apache-2.0", + "dependencies": { + "lodash.debounce": "4.0.8", + "prop-types": "15.8.1", + "ts-object-utils": "0.0.5" + }, + "peerDependencies": { + "react": ">= 15.2.1", + "react-native": ">= 0.30.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "dev": true, @@ -10922,7 +11100,6 @@ }, "node_modules/safe-regex-test": { "version": "1.1.0", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -11064,7 +11241,6 @@ }, "node_modules/set-function-length": { "version": "1.2.2", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -11831,6 +12007,12 @@ "version": "0.1.13", "license": "Apache-2.0" }, + "node_modules/ts-object-utils": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/ts-object-utils/-/ts-object-utils-0.0.5.tgz", + "integrity": "sha512-iV0GvHqOmilbIKJsfyfJY9/dNHCs969z3so90dQWsO1eMMozvTpnB1MEaUbb3FYtZTGjv5sIy/xmslEz0Rg2TA==", + "license": "ISC" + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "dev": true, @@ -12191,6 +12373,19 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "license": "MIT" @@ -12379,7 +12574,6 @@ }, "node_modules/which-typed-array": { "version": "1.1.19", - "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -12485,6 +12679,12 @@ "node": ">=10.0.0" } }, + "node_modules/xdate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/xdate/-/xdate-0.8.3.tgz", + "integrity": "sha512-1NhJWPJwN+VjbkACT9XHbQK4o6exeSVtS2CxhMPwUE7xQakoEFTlwra9YcqV/uHQVyeEUYoYC46VGDJ+etnIiw==", + "license": "(MIT OR GPL-2.0)" + }, "node_modules/xml2js": { "version": "0.6.0", "license": "MIT", diff --git a/package.json b/package.json index 4151ec8..90866f0 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "expo-blur": "~15.0.8", "expo-constants": "~18.0.13", "expo-crypto": "~15.0.8", + "expo-device": "~8.0.10", "expo-font": "~14.0.11", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", @@ -36,6 +37,7 @@ "expo-linear-gradient": "~15.0.8", "expo-linking": "~8.0.11", "expo-location": "~19.0.8", + "expo-notifications": "~0.32.16", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", @@ -48,6 +50,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", + "react-native-calendars": "^1.1314.0", "react-native-dotenv": "^3.4.11", "react-native-gesture-handler": "~2.28.0", "react-native-keyboard-aware-scroll-view": "^0.9.5",