diff --git a/app.json b/app.json index 1915abe..32008c2 100644 --- a/app.json +++ b/app.json @@ -20,7 +20,18 @@ "monochromeImage": "./assets/android-icon-monochrome.png" }, "package": "mk.mojmajstor.app", - "softwareKeyboardLayoutMode": "resize" + "softwareKeyboardLayoutMode": "resize", + "intentFilters": [ + { + "action": "VIEW", + "data": [ + { + "scheme": "mojmajstor" + } + ], + "category": ["BROWSABLE", "DEFAULT"] + } + ] }, "web": { "favicon": "./assets/favicon.png" diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx index 34b1941..2e75789 100644 --- a/app/(auth)/login.tsx +++ b/app/(auth)/login.tsx @@ -70,6 +70,7 @@ export default function LoginScreen() { onChangeText={setEmail} autoCapitalize="none" keyboardType="email-address" + accessibilityLabel="Е-пошта" /> - @@ -88,6 +90,9 @@ export default function LoginScreen() { Немате профил? router.push("/(auth)/register")} > diff --git a/app/(auth)/register.tsx b/app/(auth)/register.tsx index 157d2a3..5fdf155 100644 --- a/app/(auth)/register.tsx +++ b/app/(auth)/register.tsx @@ -82,6 +82,9 @@ export default function RegisterScreen() { setRole("customer")} style={{ flex: 1, @@ -103,6 +106,9 @@ export default function RegisterScreen() { setRole("handyman")} style={{ flex: 1, @@ -124,31 +130,37 @@ export default function RegisterScreen() { - + - + - Веќе имате профил? - router.back()}> + router.back()}> Најавете се diff --git a/app/(tabs)/(chat)/index.tsx b/app/(tabs)/(chat)/index.tsx index f7ff0b6..3293152 100644 --- a/app/(tabs)/(chat)/index.tsx +++ b/app/(tabs)/(chat)/index.tsx @@ -1,5 +1,6 @@ -import { View, Text, ScrollView } from "react-native"; +import { View, Text, ScrollView, RefreshControl } from "react-native"; import { useRouter } from "expo-router"; +import { useState, useCallback } from "react"; import { useQuery } from "convex/react"; import { api } from "../../../convex/_generated/api"; import { useAuth } from "../../_layout"; @@ -12,16 +13,25 @@ export default function ChatScreen() { const theme = useTheme(); const router = useRouter(); const { token, userId, isAuthenticated } = useAuth(); + const [refreshing, setRefreshing] = useState(false); const chats = useQuery( api.chats.listByUser, isAuthenticated && token ? { token } : "skip" ); + const onRefresh = useCallback(() => { + setRefreshing(true); + setTimeout(() => setRefreshing(false), 800); + }, []); + if (!isAuthenticated) { return ( + } contentContainerStyle={{ padding: theme.spacing.lg }} > + } contentContainerStyle={{ padding: theme.spacing.lg }} > Немате разговори - + Започнете разговор со мајстор или клиент. @@ -113,6 +127,9 @@ export default function ChatScreen() { return ( + } style={{ backgroundColor: theme.colors.background }} > {chats.map((chat) => ( diff --git a/app/(tabs)/(explore)/index.tsx b/app/(tabs)/(explore)/index.tsx index 4945044..6598f51 100644 --- a/app/(tabs)/(explore)/index.tsx +++ b/app/(tabs)/(explore)/index.tsx @@ -1,7 +1,8 @@ -import { View, Text, Pressable, ScrollView } from "react-native"; +import { View, Text, Pressable, ScrollView, RefreshControl } from "react-native"; import { useLocalSearchParams, Link } from "expo-router"; +import { useState, useCallback } from "react"; import { useTheme } from "../../../components/theme"; -import { useQuery } from "convex/react"; +import { usePaginatedQuery, useQuery } from "convex/react"; import { api } from "../../../convex/_generated/api"; import { CategoryGrid } from "../../../components/category-grid"; import { AdCard } from "../../../components/ad-card"; @@ -11,13 +12,20 @@ import { CATEGORIES, CATEGORY_EMOJI, getCategoryName } from "../../../lib/consta export default function ExploreScreen() { const theme = useTheme(); const { category } = useLocalSearchParams<{ category?: string }>(); + const [refreshing, setRefreshing] = useState(false); const categories = useQuery(api.categories.list); - const categoryAds = useQuery( - api.ads.getByCategory, - category ? { category } : "skip" + const categoryAdsResult = usePaginatedQuery( + api.ads.getByCategoryPaginated, + category ? { category } : "skip", + { initialNumItems: 20 } ); - const allAds = useQuery(api.ads.list); + const allAdsResult = usePaginatedQuery(api.ads.listPaginated, {}, { initialNumItems: 20 }); + + const onRefresh = useCallback(() => { + setRefreshing(true); + setTimeout(() => setRefreshing(false), 800); + }, []); const displayCategories = categories ?? CATEGORIES; @@ -28,33 +36,55 @@ export default function ExploreScreen() { return ( + } contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }} > - + ← Сите {catEmoji} - + {catName} - {categoryAds === undefined ? ( + {categoryAdsResult.status === "LoadingFirstPage" ? ( - ) : categoryAds.length === 0 ? ( + ) : categoryAdsResult.results.length === 0 ? ( Нема огласи во оваа категорија ) : ( - {categoryAds.map((ad) => ( + {categoryAdsResult.results.map((ad) => ( ))} )} + + {categoryAdsResult.status === "CanLoadMore" && ( + categoryAdsResult.loadMore(20)} + style={{ + backgroundColor: theme.colors.surface, + borderRadius: theme.radius.md, + paddingVertical: theme.spacing.md, + alignItems: "center", + borderWidth: 1, + borderColor: theme.colors.border, + }} + > + Вчитај повеќе + + )} ); } @@ -62,6 +92,9 @@ export default function ExploreScreen() { return ( + } contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }} > @@ -74,19 +107,38 @@ export default function ExploreScreen() { Сите огласи - {allAds === undefined ? ( + {allAdsResult.status === "LoadingFirstPage" ? ( - ) : allAds.length === 0 ? ( + ) : allAdsResult.results.length === 0 ? ( Нема огласи ) : ( - {allAds.map((ad) => ( + {allAdsResult.results.map((ad) => ( ))} )} + + {allAdsResult.status === "CanLoadMore" && ( + allAdsResult.loadMore(20)} + style={{ + backgroundColor: theme.colors.surface, + borderRadius: theme.radius.md, + paddingVertical: theme.spacing.md, + alignItems: "center", + borderWidth: 1, + borderColor: theme.colors.border, + }} + > + Вчитај повеќе + + )} ); } \ No newline at end of file diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index e46811f..b9d9e6a 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -1,7 +1,7 @@ -import { View, Text, TextInput, ScrollView } from "react-native"; -import { useState } from "react"; +import { View, Text, TextInput, ScrollView, RefreshControl, Pressable } from "react-native"; +import { useState, useCallback } from "react"; import { useTheme } from "../../../components/theme"; -import { useQuery } from "convex/react"; +import { useQuery, usePaginatedQuery } from "convex/react"; import { api } from "../../../convex/_generated/api"; import { CategoryGrid } from "../../../components/category-grid"; import { AdCard } from "../../../components/ad-card"; @@ -11,32 +11,50 @@ import { CATEGORIES } from "../../../lib/constants"; export default function HomeScreen() { const theme = useTheme(); const [search, setSearch] = useState(""); + const [refreshing, setRefreshing] = useState(false); const categories = useQuery(api.categories.list); - const allAds = useQuery(api.ads.list); + const adsResult = usePaginatedQuery(api.ads.listPaginated, {}, { initialNumItems: 20 }); + const ads = adsResult.results; + const loadMoreAds = adsResult.loadMore; + const adsStatus = adsResult.status; + const searchResults = useQuery( api.ads.search, search.trim() ? { query: search.trim() } : "skip" ); - const ads = search.trim() ? searchResults : allAds; + const onRefresh = useCallback(() => { + setRefreshing(true); + setTimeout(() => setRefreshing(false), 800); + }, []); + const displayCategories = categories ?? CATEGORIES; + const displayAds = search.trim() ? (searchResults ?? []) : ads; + + const isLoading = !search.trim() && adsStatus === "LoadingFirstPage"; return ( + } contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 40 }} > Добредојдовте 🏠 - + Најдете мајстор во вашата близина - {ads === undefined ? ( + {(search.trim() ? searchResults === undefined : isLoading) ? ( - ) : ads.length === 0 ? ( + ) : displayAds.length === 0 ? ( {search.trim() ? "Не се пронајдени резултати" : "Нема огласи"} ) : ( - {ads.map((ad) => ( + {displayAds.map((ad) => ( ))} )} + + {!search.trim() && adsStatus === "CanLoadMore" && ( + loadMoreAds(20)} + style={{ + backgroundColor: theme.colors.surface, + borderRadius: theme.radius.md, + paddingVertical: theme.spacing.md, + alignItems: "center", + borderWidth: 1, + borderColor: theme.colors.border, + }} + > + Вчитај повеќе + + )} ); } \ No newline at end of file diff --git a/app/(tabs)/(posts)/index.tsx b/app/(tabs)/(posts)/index.tsx index 5bb6188..8c4912c 100644 --- a/app/(tabs)/(posts)/index.tsx +++ b/app/(tabs)/(posts)/index.tsx @@ -1,7 +1,7 @@ -import { View, Text, ScrollView, Pressable } from "react-native"; +import { View, Text, ScrollView, RefreshControl, Pressable } from "react-native"; import { useRouter } from "expo-router"; -import { useState } from "react"; -import { useQuery } from "convex/react"; +import { useState, useCallback } from "react"; +import { usePaginatedQuery, useQuery } from "convex/react"; import { api } from "../../../convex/_generated/api"; import { useAuth } from "../../_layout"; import { useTheme } from "../../../components/theme"; @@ -24,21 +24,36 @@ export default function PostsScreen() { isAuthenticated && token ? { token } : "skip" ); const [activeTab, setActiveTab] = useState("open"); + const [refreshing, setRefreshing] = useState(false); - const posts = useQuery( - api.posts.list, - activeTab === "open" ? { status: "open" } : {} + const postsResult = usePaginatedQuery( + api.posts.listPaginated, + activeTab === "open" ? { status: "open" as const } : {}, + { initialNumItems: 20 } ); + const onRefresh = useCallback(() => { + setRefreshing(true); + setTimeout(() => setRefreshing(false), 800); + }, []); + const isCustomer = currentUser?.role !== "handyman"; return ( + } contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }} > {isAuthenticated && isCustomer && ( - router.push("/post/create" as any)}> + router.push("/post/create" as any)} + > ( setActiveTab(tab.key)} style={{ flex: 1, @@ -84,9 +103,9 @@ export default function PostsScreen() { ))} - {posts === undefined ? ( + {postsResult.status === "LoadingFirstPage" ? ( - ) : posts.length === 0 ? ( + ) : postsResult.results.length === 0 ? ( 📋 @@ -100,11 +119,30 @@ export default function PostsScreen() { ) : ( - {posts.map((post) => ( + {postsResult.results.map((post) => ( ))} )} + + {postsResult.status === "CanLoadMore" && ( + postsResult.loadMore(20)} + style={{ + backgroundColor: theme.colors.surface, + borderRadius: theme.radius.md, + paddingVertical: theme.spacing.md, + alignItems: "center", + borderWidth: 1, + borderColor: theme.colors.border, + }} + > + Вчитај повеќе + + )} ); } \ No newline at end of file diff --git a/app/(tabs)/(profile)/index.tsx b/app/(tabs)/(profile)/index.tsx index 34f9796..a37c597 100644 --- a/app/(tabs)/(profile)/index.tsx +++ b/app/(tabs)/(profile)/index.tsx @@ -1,6 +1,7 @@ -import { View, Text, Pressable, ScrollView, Alert } from "react-native"; +import { View, Text, Pressable, ScrollView, RefreshControl, Alert } from "react-native"; import { useRouter } from "expo-router"; -import { useQuery, useMutation } from "convex/react"; +import { useState, useCallback } from "react"; +import { useQuery, useMutation, usePaginatedQuery } from "convex/react"; import { api } from "../../../convex/_generated/api"; import { useAuth } from "../../_layout"; import { useTheme } from "../../../components/theme"; @@ -11,20 +12,34 @@ import { Loading } from "../../../components/ui/loading"; import { Card } from "../../../components/ui/card"; import { Avatar } from "../../../components/ui/avatar"; import { AdCard } from "../../../components/ad-card"; +import { PostCard } from "../../../components/post-card"; import { getCategoryName } from "../../../lib/constants"; export default function ProfileScreen() { const router = useRouter(); const theme = useTheme(); const { token, userId, isAuthenticated, logout } = useAuth(); + const [refreshing, setRefreshing] = useState(false); + const user = useQuery(api.users.getCurrentUser, isAuthenticated ? { token: token! } : "skip"); - const myAds = useQuery( - api.ads.getByHandyman, - isAuthenticated && userId ? { handymanId: userId as any } : "skip" + const myAdsResult = usePaginatedQuery( + api.ads.getByHandymanPaginated, + isAuthenticated && userId ? { handymanId: userId as any } : "skip", + { initialNumItems: 20 } + ); + const myPostsResult = usePaginatedQuery( + api.posts.getByCustomerPaginated, + isAuthenticated && userId ? { customerId: userId as any } : "skip", + { initialNumItems: 20 } ); const deleteAd = useMutation(api.ads.remove); const logoutMutation = useMutation(api.auth.logout); + const onRefresh = useCallback(() => { + setRefreshing(true); + setTimeout(() => setRefreshing(false), 800); + }, []); + async function handleLogout() { if (token) { try { @@ -63,7 +78,7 @@ export default function ProfileScreen() { Најавете се - + За да ги видите вашиот профил и податоци. @@ -80,53 +95,61 @@ export default function ProfileScreen() { } const isHandyman = user.role === "handyman"; + const myAds = myAdsResult.results; + const myPosts = myPostsResult.results; return ( + } contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }} > - {/* Profile header */} - + {user.name || "Корисник"} {user.email && ( - + {user.email} )} {user.phone && ( - + {user.phone} )} - {/* Handyman sections */} {isHandyman && ( <> Мои огласи - router.push("/ad/create" as any)}> + router.push("/ad/create" as any)} + > + Нов оглас - {myAds === undefined ? ( + {myAdsResult.status === "LoadingFirstPage" ? ( ) : myAds.length === 0 ? ( 📋 - - Сеуште немате огласи. Креирајте нов оглас за да се прикажете на мајсторите. + + Сеуште немате огласи. Креирајте нов оглас за да се прикажете на клиентите. @@ -137,6 +160,9 @@ export default function ProfileScreen() { router.push(`/ad/create?editId=${ad._id}` as any)} style={{ flex: 1, @@ -151,6 +177,9 @@ export default function ProfileScreen() { Уреди handleDeleteAd(ad._id)} style={{ flex: 1, @@ -167,40 +196,91 @@ export default function ProfileScreen() { ))} )} + + {myAdsResult.status === "CanLoadMore" && ( + myAdsResult.loadMore(20)} + style={{ + backgroundColor: theme.colors.surface, + borderRadius: theme.radius.md, + paddingVertical: theme.spacing.md, + alignItems: "center", + borderWidth: 1, + borderColor: theme.colors.border, + }} + > + Вчитај повеќе + + )} )} - {/* Customer sections */} {!isHandyman && ( <> Мои побарувања - + router.push("/post/create" as any)} + > + Ново побарување - - - 📝 - - Сеуште немате побарувања. Креирајте ново побарување за да најдете мајстор. - + {myPostsResult.status === "LoadingFirstPage" ? ( + + ) : myPosts.length === 0 ? ( + + + 📝 + + Сеуште немате побарувања. Креирајте ново побарување за да најдете мајстор. + + + + ) : ( + + {myPosts.map((post) => ( + + ))} - + )} + + {myPostsResult.status === "CanLoadMore" && ( + myPostsResult.loadMore(20)} + style={{ + backgroundColor: theme.colors.surface, + borderRadius: theme.radius.md, + paddingVertical: theme.spacing.md, + alignItems: "center", + borderWidth: 1, + borderColor: theme.colors.border, + }} + > + Вчитај повеќе + + )} )} - {/* Menu items */} - + Оцени - + Поставки diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 0e6f30e..5ce21ea 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -21,35 +21,35 @@ export default function TabLayout() { name="(home)" options={{ title: "Почетна", - tabBarIcon: ({ color, size }) => undefined, + tabBarAccessibilityLabel: "Почетна", }} /> undefined, + tabBarAccessibilityLabel: "Пребарување", }} /> undefined, + tabBarAccessibilityLabel: "Побарувања", }} /> undefined, + tabBarAccessibilityLabel: "Чат", }} /> undefined, + tabBarAccessibilityLabel: "Профил", }} /> diff --git a/app/+not-found.tsx b/app/+not-found.tsx index 2bd5910..14c95dd 100644 --- a/app/+not-found.tsx +++ b/app/+not-found.tsx @@ -1,21 +1,28 @@ -import { View, Text } from "react-native"; -import { Link } from "expo-router"; +import { View, Text, Pressable } from "react-native"; +import { useRouter } from "expo-router"; import { useTheme } from "../components/theme"; export default function NotFound() { const theme = useTheme(); + const router = useRouter(); return ( - + Страницата не е пронајдена - + Страницата што ја барате не постои. - - Назад кон почетна - + router.replace("/(tabs)/(home)")} + style={{ backgroundColor: theme.colors.primary, borderRadius: theme.radius.md, paddingVertical: 12, paddingHorizontal: 24 }} + > + Начална страница + ); } \ No newline at end of file diff --git a/app/_layout.tsx b/app/_layout.tsx index b509846..0783958 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,7 +1,8 @@ import { Stack } from "expo-router/stack"; import { ConvexProvider, ConvexReactClient } from "convex/react"; -import { useState, createContext, useContext, useEffect, useCallback } from "react"; -import { ThemeProvider } from "../components/theme"; +import { useState, createContext, useContext, useEffect, useCallback, Component, type ReactNode, type ErrorInfo } from "react"; +import { View, Text, Pressable, ScrollView } from "react-native"; +import { ThemeProvider, useTheme } from "../components/theme"; const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!); @@ -28,6 +29,56 @@ export function useAuth() { const TOKEN_KEY = "mojmajstor_auth_token"; const USER_ID_KEY = "mojmajstor_auth_userId"; +class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean; error: Error | null }> { + state: { hasError: boolean; error: Error | null } = { hasError: false, error: null }; + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + + reset = () => { + this.setState({ hasError: false, error: null }); + }; + + render() { + if (this.state.hasError) { + return ; + } + return this.props.children; + } +} + +function ErrorFallback({ error, onReset }: { error: Error; onReset: () => void }) { + const theme = useTheme(); + return ( + + ⚠️ + + Настана грешка + + + Приложението наиде на неочекувана грешка. + + {__DEV__ && error?.message ? ( + + + {error.message} + + + ) : null} + + Обиди се повторно + + + ); +} + export default function RootLayout() { const [token, setToken] = useState(null); const [userId, setUserId] = useState(null); @@ -66,16 +117,18 @@ export default function RootLayout() { - - - - - - - - - - + + + + + + + + + + + + diff --git a/app/ad/[id].tsx b/app/ad/[id].tsx index e50fdd5..30f1a3f 100644 --- a/app/ad/[id].tsx +++ b/app/ad/[id].tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { View, Text, ScrollView, Pressable, Alert } from "react-native"; +import { useState, useCallback } from "react"; +import { View, Text, ScrollView, Pressable, Alert, RefreshControl } from "react-native"; import { useLocalSearchParams, Stack, useRouter } from "expo-router"; import { useQuery, useMutation } from "convex/react"; import { api } from "../../convex/_generated/api"; @@ -63,6 +63,12 @@ export default function AdDetailScreen() { const deleteAd = useMutation(api.ads.remove); const startChat = useMutation(api.chats.getOrCreate); const [startingChat, setStartingChat] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + const onRefresh = useCallback(() => { + setRefreshing(true); + setTimeout(() => setRefreshing(false), 800); + }, []); async function handleStartChat() { if (!token || !ad) return; @@ -128,9 +134,14 @@ export default function AdDetailScreen() { /> + } contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 100 }} > {ad.title} {ad.priceRange && ( - + {ad.priceRange} )} @@ -159,7 +170,7 @@ export default function AdDetailScreen() { - 📍 {ad.location} + 📍 {ad.location} {ad.ratingAvg != null && ad.ratingAvg > 0 && ( @@ -171,7 +182,7 @@ export default function AdDetailScreen() { Расположивост - {ad.availability} + {ad.availability} )} @@ -184,6 +195,9 @@ export default function AdDetailScreen() { { if (handyman) router.push(`/(tabs)/(profile)`); }} diff --git a/app/ad/create.tsx b/app/ad/create.tsx index 1a91b94..859baa1 100644 --- a/app/ad/create.tsx +++ b/app/ad/create.tsx @@ -61,7 +61,7 @@ export default function CreateAdScreen() { Треба да бидете најавени за да креирате оглас. - + ); @@ -147,6 +147,9 @@ export default function CreateAdScreen() { {["Категорија", "Детали", "Локација", "Цена"].map((label, i) => ( setStep(i)} style={{ flex: 1, @@ -170,9 +173,12 @@ export default function CreateAdScreen() { Изберете категорија {CATEGORIES.map((cat) => ( - setCategory(cat.slug)} + setCategory(cat.slug)} style={{ flexDirection: "row", alignItems: "center", @@ -210,6 +216,7 @@ export default function CreateAdScreen() { value={title} onChangeText={setTitle} maxLength={100} + accessibilityLabel="Наслов на оглас" /> {title.length > 0 && title.length < 3 && ( Насловот треба да има најмалку 3 знаци @@ -227,6 +234,7 @@ export default function CreateAdScreen() { numberOfLines={5} style={{ minHeight: 120, textAlignVertical: "top" }} maxLength={2000} + accessibilityLabel="Опис на оглас" /> {description.length > 0 && description.length < 10 && ( Описот треба да има најмалку 10 знаци @@ -248,6 +256,7 @@ export default function CreateAdScreen() { placeholder="Пр: Скопје, Чаир" value={location} onChangeText={setLocation} + accessibilityLabel="Локација" /> @@ -291,6 +301,7 @@ export default function CreateAdScreen() { placeholder="Пр: Достапен Петоци - Недели" value={availability} onChangeText={setAvailability} + accessibilityLabel="Расположивост" /> diff --git a/app/chat/[id].tsx b/app/chat/[id].tsx index 8c93ee9..d6e0c16 100644 --- a/app/chat/[id].tsx +++ b/app/chat/[id].tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useCallback } from "react"; import { View, Text, @@ -7,6 +7,7 @@ import { Pressable, KeyboardAvoidingView, Platform, + RefreshControl, type ListRenderItemInfo, } from "react-native"; import { useLocalSearchParams, Stack, useRouter } from "expo-router"; @@ -42,6 +43,12 @@ export default function ChatDetailScreen() { const sendMessage = useMutation(api.messages.send); const [text, setText] = useState(""); const [sending, setSending] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + const onRefresh = useCallback(() => { + setRefreshing(true); + setTimeout(() => setRefreshing(false), 800); + }, []); async function handleSend() { const content = text.trim(); @@ -170,6 +177,9 @@ export default function ChatDetailScreen() { data={messages} keyExtractor={(item) => item._id} inverted + refreshControl={ + + } contentContainerStyle={{ paddingVertical: 16, paddingHorizontal: 16, @@ -236,6 +246,9 @@ export default function ChatDetailScreen() { }} > { + setRefreshing(true); + setTimeout(() => setRefreshing(false), 800); + }, []); if (!post) { return ( @@ -78,7 +85,12 @@ export default function PostDetailScreen() { return ( <> - + + } + contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 100 }}> @@ -99,13 +111,13 @@ export default function PostDetailScreen() { {post.location && ( 📍 Локација: - {post.location} + {post.location} )} {post.budget && ( 💰 Буџет: - {post.budget} + {post.budget} )} diff --git a/app/post/create.tsx b/app/post/create.tsx index b79433d..a6ec62c 100644 --- a/app/post/create.tsx +++ b/app/post/create.tsx @@ -36,7 +36,7 @@ export default function CreatePostScreen() { Треба да сте најавени за да креирате побарување. - + ); @@ -81,6 +81,7 @@ export default function CreatePostScreen() { value={title} onChangeText={setTitle} maxLength={100} + accessibilityLabel="Наслов на побарување" /> @@ -94,6 +95,7 @@ export default function CreatePostScreen() { numberOfLines={4} style={{ minHeight: 100, textAlignVertical: "top" }} maxLength={2000} + accessibilityLabel="Опис на побарување" /> @@ -134,6 +136,7 @@ export default function CreatePostScreen() { value={location} onChangeText={setLocation} maxLength={100} + accessibilityLabel="Локација" /> @@ -144,10 +147,11 @@ export default function CreatePostScreen() { value={budget} onChangeText={setBudget} maxLength={100} + accessibilityLabel="Буџет" /> - diff --git a/app/review/create.tsx b/app/review/create.tsx index 1dce224..c768479 100644 --- a/app/review/create.tsx +++ b/app/review/create.tsx @@ -98,6 +98,9 @@ export default function CreateReviewScreen() { {[1, 2, 3, 4, 5].map((star) => ( setRating(star)} style={{ padding: 4 }} > @@ -129,6 +132,7 @@ export default function CreateReviewScreen() { multiline numberOfLines={4} style={{ minHeight: 100, textAlignVertical: "top" }} + accessibilityLabel="Коментар" /> @@ -136,6 +140,7 @@ export default function CreateReviewScreen() { loading={submitting} disabled={rating === 0} onPress={handleSubmit} + accessibilityLabel="Испрати оценка" > Испрати оценка diff --git a/components/ad-card.tsx b/components/ad-card.tsx index f041d41..2708eb7 100644 --- a/components/ad-card.tsx +++ b/components/ad-card.tsx @@ -24,6 +24,9 @@ export function AdCard({ ad }: AdCardProps) { return ( {ad.priceRange && ( - + {ad.priceRange} )} diff --git a/components/category-grid.tsx b/components/category-grid.tsx index 04f8e13..40a6b96 100644 --- a/components/category-grid.tsx +++ b/components/category-grid.tsx @@ -25,6 +25,9 @@ export function CategoryGrid({ categories, columns = 3 }: CategoryGridProps) { return ( 📍 {post.location} )} {post.budget && ( - + 💰 {post.budget} )} diff --git a/components/review-card.tsx b/components/review-card.tsx index 0bf48e9..51497d1 100644 --- a/components/review-card.tsx +++ b/components/review-card.tsx @@ -38,6 +38,8 @@ export function ReviewCard({ review, customerName, customerAvatarId }: ReviewCar return ( - + {customerName} @@ -61,7 +63,7 @@ export function ReviewCard({ review, customerName, customerAvatarId }: ReviewCar {review.comment ? ( - + {review.comment} ) : null} diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx index f2471ca..736a339 100644 --- a/components/ui/avatar.tsx +++ b/components/ui/avatar.tsx @@ -1,5 +1,5 @@ import { View, Text } from "react-native"; -import { Image, type ImageContentFit } from "expo-image"; +import { Image } from "expo-image"; import { useTheme } from "../theme"; interface AvatarProps { @@ -20,6 +20,8 @@ export function Avatar({ uri, name, size = 44 }: AvatarProps) { if (uri) { return ( + {label} ); diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 646f6c4..5bbf942 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -11,6 +11,7 @@ interface ButtonProps { onPress?: () => void; children: React.ReactNode; style?: ViewStyle; + accessibilityLabel?: string; } export function Button({ @@ -21,6 +22,7 @@ export function Button({ children, style, onPress, + accessibilityLabel, }: ButtonProps) { const theme = useTheme(); @@ -44,8 +46,14 @@ export function Button({ destructive: "#FFFFFF", }; + const label = typeof children === "string" ? children : accessibilityLabel; + return ( + ); diff --git a/components/ui/rating.tsx b/components/ui/rating.tsx index 300c1c8..90ed1dc 100644 --- a/components/ui/rating.tsx +++ b/components/ui/rating.tsx @@ -11,9 +11,13 @@ export function Rating({ value, count, size = 16 }: RatingProps) { const theme = useTheme(); return ( - + - + {value.toFixed(1)} {count !== undefined && ( diff --git a/convex/ads.ts b/convex/ads.ts index e7a1c4e..944971f 100644 --- a/convex/ads.ts +++ b/convex/ads.ts @@ -1,5 +1,6 @@ import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; +import { paginationOptsValidator } from "convex/server"; export const list = query({ args: {}, @@ -8,6 +9,13 @@ export const list = query({ }, }); +export const listPaginated = query({ + args: { paginationOpts: paginationOptsValidator }, + handler: async (ctx, args) => { + return await ctx.db.query("ads").order("desc").paginate(args.paginationOpts); + }, +}); + export const getByCategory = query({ args: { category: v.string() }, handler: async (ctx, args) => { @@ -19,6 +27,17 @@ export const getByCategory = query({ }, }); +export const getByCategoryPaginated = query({ + args: { category: v.string(), paginationOpts: paginationOptsValidator }, + handler: async (ctx, args) => { + return await ctx.db + .query("ads") + .withIndex("by_category", (q) => q.eq("category", args.category)) + .order("desc") + .paginate(args.paginationOpts); + }, +}); + export const getById = query({ args: { id: v.id("ads") }, handler: async (ctx, args) => { @@ -61,6 +80,17 @@ export const getByHandyman = query({ }, }); +export const getByHandymanPaginated = query({ + args: { handymanId: v.id("users"), paginationOpts: paginationOptsValidator }, + handler: async (ctx, args) => { + return await ctx.db + .query("ads") + .withIndex("by_handyman", (q) => q.eq("handymanId", args.handymanId)) + .order("desc") + .paginate(args.paginationOpts); + }, +}); + export const create = mutation({ args: { token: v.string(), diff --git a/convex/auth.ts b/convex/auth.ts index 2302dde..db51360 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -26,12 +26,12 @@ export const login = mutation({ ) .first(); - if (!account) throw new Error("Invalid credentials"); + if (!account) throw new Error("Погрешна е-пошта или лозинка"); const user = await ctx.db.get(account.userId); - if (!user) throw new Error("User not found"); + if (!user) throw new Error("Корисникот не е пронајден"); - if (account.secret !== args.passwordHash) throw new Error("Invalid credentials"); + if (account.secret !== args.passwordHash) throw new Error("Погрешна е-пошта или лозинка"); const token = crypto.randomUUID(); await ctx.db.insert("sessions", { @@ -61,7 +61,7 @@ export const register = mutation({ ) .first(); - if (existing) throw new Error("Email already registered"); + if (existing) throw new Error("Е-поштата е веќе регистрирана"); const userId = await ctx.db.insert("users", { name: args.name, diff --git a/convex/chats.ts b/convex/chats.ts index dcb16c3..c27c703 100644 --- a/convex/chats.ts +++ b/convex/chats.ts @@ -1,5 +1,6 @@ import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; +import { paginationOptsValidator } from "convex/server"; export const listByUser = query({ args: { token: v.string() }, @@ -52,6 +53,67 @@ export const listByUser = query({ }, }); +export const listByUserPaginated = query({ + args: { token: v.string(), paginationOpts: paginationOptsValidator }, + handler: async (ctx, args) => { + const session = await ctx.db + .query("sessions") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .first(); + if (!session) { + return { page: [], continuationCursor: null, isDone: true }; + } + + const result = await ctx.db + .query("chats") + .order("desc") + .paginate(args.paginationOpts); + + const filtered = result.page.filter((chat) => + chat.participantIds.includes(session.userId) + ); + + const enriched = await Promise.all( + filtered.map(async (chat) => { + const otherId = chat.participantIds.find( + (id) => id !== session.userId + )!; + const otherUser = await ctx.db.get(otherId); + const lastMessage = await ctx.db + .query("messages") + .withIndex("by_chat", (q) => q.eq("chatId", chat._id)) + .order("desc") + .first(); + + return { + _id: chat._id, + otherUser: otherUser + ? { + _id: otherUser._id, + name: otherUser.name, + avatarId: otherUser.avatarId ?? null, + } + : null, + lastMessage: lastMessage + ? { + content: lastMessage.content, + createdAt: lastMessage.createdAt, + senderId: lastMessage.senderId, + } + : null, + lastMessageAt: chat.lastMessageAt, + }; + }) + ); + + return { + page: enriched.sort((a, b) => b.lastMessageAt - a.lastMessageAt), + continueCursor: result.continueCursor, + isDone: result.isDone, + }; + }, +}); + export const getOrCreate = mutation({ args: { token: v.string(), diff --git a/convex/messages.ts b/convex/messages.ts index 35f30cf..46fba47 100644 --- a/convex/messages.ts +++ b/convex/messages.ts @@ -1,5 +1,6 @@ import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; +import { paginationOptsValidator } from "convex/server"; export const listByChat = query({ args: { token: v.string(), chatId: v.id("chats") }, @@ -24,6 +25,29 @@ export const listByChat = query({ }, }); +export const listByChatPaginated = query({ + args: { token: v.string(), chatId: v.id("chats"), paginationOpts: paginationOptsValidator }, + handler: async (ctx, args) => { + const session = await ctx.db + .query("sessions") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .first(); + if (!session) throw new Error("Неавторизиран"); + + const chat = await ctx.db.get(args.chatId); + if (!chat) throw new Error("Разговорот не е пронајден"); + if (!chat.participantIds.includes(session.userId)) { + throw new Error("Немате пристап до овој разговор"); + } + + return await ctx.db + .query("messages") + .withIndex("by_chat", (q) => q.eq("chatId", args.chatId)) + .order("desc") + .paginate(args.paginationOpts); + }, +}); + export const send = mutation({ args: { token: v.string(), diff --git a/convex/posts.ts b/convex/posts.ts index c64ce9e..f964120 100644 --- a/convex/posts.ts +++ b/convex/posts.ts @@ -1,5 +1,6 @@ import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; +import { paginationOptsValidator } from "convex/server"; export const list = query({ args: { status: v.optional(v.union(v.literal("open"), v.literal("closed"))) }, @@ -15,6 +16,23 @@ export const list = query({ }, }); +export const listPaginated = query({ + args: { + status: v.optional(v.union(v.literal("open"), v.literal("closed"))), + paginationOpts: paginationOptsValidator, + }, + handler: async (ctx, args) => { + if (args.status) { + return await ctx.db + .query("posts") + .withIndex("by_status", (q) => q.eq("status", args.status!)) + .order("desc") + .paginate(args.paginationOpts); + } + return await ctx.db.query("posts").order("desc").paginate(args.paginationOpts); + }, +}); + export const getByCustomer = query({ args: { customerId: v.id("users") }, handler: async (ctx, args) => { @@ -26,6 +44,17 @@ export const getByCustomer = query({ }, }); +export const getByCustomerPaginated = query({ + args: { customerId: v.id("users"), paginationOpts: paginationOptsValidator }, + handler: async (ctx, args) => { + return await ctx.db + .query("posts") + .withIndex("by_customer", (q) => q.eq("customerId", args.customerId)) + .order("desc") + .paginate(args.paginationOpts); + }, +}); + export const getById = query({ args: { id: v.id("posts") }, handler: async (ctx, args) => { diff --git a/convex/reviews.ts b/convex/reviews.ts index fc8b1f6..6f45b3e 100644 --- a/convex/reviews.ts +++ b/convex/reviews.ts @@ -1,5 +1,6 @@ import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; +import { paginationOptsValidator } from "convex/server"; export const getByAd = query({ args: { adId: v.id("ads") }, @@ -12,6 +13,17 @@ export const getByAd = query({ }, }); +export const getByAdPaginated = query({ + args: { adId: v.id("ads"), paginationOpts: paginationOptsValidator }, + handler: async (ctx, args) => { + return await ctx.db + .query("reviews") + .withIndex("by_ad", (q) => q.eq("adId", args.adId)) + .order("desc") + .paginate(args.paginationOpts); + }, +}); + export const getByHandyman = query({ args: { handymanId: v.id("users") }, handler: async (ctx, args) => { @@ -23,6 +35,17 @@ export const getByHandyman = query({ }, }); +export const getByHandymanPaginated = query({ + args: { handymanId: v.id("users"), paginationOpts: paginationOptsValidator }, + handler: async (ctx, args) => { + return await ctx.db + .query("reviews") + .withIndex("by_handyman", (q) => q.eq("handymanId", args.handymanId)) + .order("desc") + .paginate(args.paginationOpts); + }, +}); + export const create = mutation({ args: { token: v.string(),