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() {
Најавете се
-
+
За да ги видите вашиот профил и податоци.
router.push("/(auth)/login")}>Најави се
@@ -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() {
Треба да бидете најавени за да креирате оглас.
- router.push("/(auth)/login")}>Најави се
+ router.push("/(auth)/login")} accessibilityLabel="Најави се">Најави се
>
);
@@ -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() {
Треба да сте најавени за да креирате побарување.
- router.push("/(auth)/login" as any)}>Најави се
+ router.push("/(auth)/login" as any)} accessibilityLabel="Најави се">Најави се
>
);
@@ -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(),