feat: Phase 7 - polish, error handling, pagination, accessibility

- Error boundary with Macedonian fallback screen and retry button
  wrapping all navigation
- Pull-to-refresh on all list/detail screens (home, explore, posts,
  chat, profile, ad detail, post detail)
- Accessibility: labels on all interactive elements, roles on
  Pressable/Button, selectable text for user data
- Deep linking: mojmajstor:// scheme configured in app.json,
  ad/[id] and chat/[id] routes work via Expo Router auto-routing
- Pagination: cursor-based paginated queries for ads, posts,
  messages, chats, reviews with Load More buttons on frontend
- Profile: shows customer posts with PostCard, loading/error states,
  create-post navigation
- Auth error messages in Macedonian (Невалидни акредитиви, итн.)

All UI text in Macedonian. TypeScript clean, Convex functions deployed.
This commit is contained in:
echo 2026-05-29 18:53:26 +02:00
parent c700d139f7
commit 3e5b85a08e
34 changed files with 705 additions and 121 deletions

View File

@ -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"

View File

@ -70,6 +70,7 @@ export default function LoginScreen() {
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
accessibilityLabel="Е-пошта"
/>
<Input
@ -77,10 +78,11 @@ export default function LoginScreen() {
value={password}
onChangeText={setPassword}
secureTextEntry
accessibilityLabel="Лозинка"
/>
<View style={{ marginTop: theme.spacing.sm }}>
<Button loading={loading} onPress={handleLogin}>
<Button loading={loading} onPress={handleLogin} accessibilityLabel="Најави се">
Најави се
</Button>
</View>
@ -88,6 +90,9 @@ export default function LoginScreen() {
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.md }}>
<Text style={{ color: theme.colors.textSecondary }}>Немате профил? </Text>
<Text
accessible
accessibilityLabel="Регистрирајте се"
accessibilityRole="link"
style={{ color: theme.colors.primary, fontWeight: "600" }}
onPress={() => router.push("/(auth)/register")}
>

View File

@ -82,6 +82,9 @@ export default function RegisterScreen() {
<View style={{ flexDirection: "row", gap: theme.spacing.md }}>
<Pressable
accessible
accessibilityLabel="Изберете улога: Клиент"
accessibilityRole="button"
onPress={() => setRole("customer")}
style={{
flex: 1,
@ -103,6 +106,9 @@ export default function RegisterScreen() {
</Pressable>
<Pressable
accessible
accessibilityLabel="Изберете улога: Мајстор"
accessibilityRole="button"
onPress={() => setRole("handyman")}
style={{
flex: 1,
@ -124,31 +130,37 @@ export default function RegisterScreen() {
</Pressable>
</View>
<Input placeholder="Име и презиме" value={name} onChangeText={setName} />
<Input placeholder="Име и презиме" value={name} onChangeText={setName} accessibilityLabel="Име и презиме" />
<Input
placeholder="Е-пошта"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
accessibilityLabel="Е-пошта"
/>
<Input
placeholder="Телефонски број (незадолжително)"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
accessibilityLabel="Телефонски број"
/>
<Input placeholder="Лозинка (мин. 8 карактери)" value={password} onChangeText={setPassword} secureTextEntry />
<Input placeholder="Лозинка (мин. 8 карактери)" value={password} onChangeText={setPassword} secureTextEntry accessibilityLabel="Лозинка" />
<View style={{ marginTop: theme.spacing.sm }}>
<Button loading={loading} onPress={handleRegister}>
<Button loading={loading} onPress={handleRegister} accessibilityLabel="Регистрирај се">
Регистрирај се
</Button>
</View>
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.md }}>
<Text style={{ color: theme.colors.textSecondary }}>Веќе имате профил? </Text>
<Text style={{ color: theme.colors.primary, fontWeight: "600" }} onPress={() => router.back()}>
<Text
accessible
accessibilityLabel="Најавете се"
accessibilityRole="link"
style={{ color: theme.colors.primary, fontWeight: "600" }} onPress={() => router.back()}>
Најавете се
</Text>
</View>

View File

@ -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 (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
}
contentContainerStyle={{ padding: theme.spacing.lg }}
>
<View
@ -45,6 +55,7 @@ export default function ChatScreen() {
Чатови
</Text>
<Text
selectable
style={{
color: theme.colors.textSecondary,
textAlign: "center",
@ -80,6 +91,9 @@ export default function ChatScreen() {
return (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
}
contentContainerStyle={{ padding: theme.spacing.lg }}
>
<View
@ -102,7 +116,7 @@ export default function ChatScreen() {
>
Немате разговори
</Text>
<Text style={{ color: theme.colors.textSecondary, textAlign: "center" }}>
<Text selectable style={{ color: theme.colors.textSecondary, textAlign: "center" }}>
Започнете разговор со мајстор или клиент.
</Text>
</View>
@ -113,6 +127,9 @@ export default function ChatScreen() {
return (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
}
style={{ backgroundColor: theme.colors.background }}
>
{chats.map((chat) => (

View File

@ -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 (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
}
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm }}>
<Link href="/(tabs)/(explore)" asChild>
<Pressable style={{ marginRight: theme.spacing.xs }}>
<Pressable accessible accessibilityLabel="Назад кон сите категории" accessibilityRole="link" style={{ marginRight: theme.spacing.xs }}>
<Text style={{ fontSize: 20, color: theme.colors.primary }}> Сите</Text>
</Pressable>
</Link>
<Text style={{ fontSize: 28 }}>{catEmoji}</Text>
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>
<Text selectable style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>
{catName}
</Text>
</View>
{categoryAds === undefined ? (
{categoryAdsResult.status === "LoadingFirstPage" ? (
<Loading />
) : categoryAds.length === 0 ? (
) : categoryAdsResult.results.length === 0 ? (
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
Нема огласи во оваа категорија
</Text>
) : (
<View style={{ gap: theme.spacing.sm }}>
{categoryAds.map((ad) => (
{categoryAdsResult.results.map((ad) => (
<AdCard key={ad._id} ad={ad} />
))}
</View>
)}
{categoryAdsResult.status === "CanLoadMore" && (
<Pressable
accessible
accessibilityLabel="Вчитај повеќе огласи"
accessibilityRole="button"
onPress={() => categoryAdsResult.loadMore(20)}
style={{
backgroundColor: theme.colors.surface,
borderRadius: theme.radius.md,
paddingVertical: theme.spacing.md,
alignItems: "center",
borderWidth: 1,
borderColor: theme.colors.border,
}}
>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
</Pressable>
)}
</ScrollView>
);
}
@ -62,6 +92,9 @@ export default function ExploreScreen() {
return (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
}
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
>
<Text style={{ fontSize: 20, fontWeight: "700", color: theme.colors.text }}>
@ -74,19 +107,38 @@ export default function ExploreScreen() {
Сите огласи
</Text>
{allAds === undefined ? (
{allAdsResult.status === "LoadingFirstPage" ? (
<Loading />
) : allAds.length === 0 ? (
) : allAdsResult.results.length === 0 ? (
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
Нема огласи
</Text>
) : (
<View style={{ gap: theme.spacing.sm }}>
{allAds.map((ad) => (
{allAdsResult.results.map((ad) => (
<AdCard key={ad._id} ad={ad} />
))}
</View>
)}
{allAdsResult.status === "CanLoadMore" && (
<Pressable
accessible
accessibilityLabel="Вчитај повеќе огласи"
accessibilityRole="button"
onPress={() => allAdsResult.loadMore(20)}
style={{
backgroundColor: theme.colors.surface,
borderRadius: theme.radius.md,
paddingVertical: theme.spacing.md,
alignItems: "center",
borderWidth: 1,
borderColor: theme.colors.border,
}}
>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
</Pressable>
)}
</ScrollView>
);
}

View File

@ -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 (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
}
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 40 }}
>
<View style={{ marginTop: theme.spacing.md }}>
<Text style={{ fontSize: 24, fontWeight: "700", color: theme.colors.text }}>
Добредојдовте 🏠
</Text>
<Text style={{ fontSize: 16, color: theme.colors.textSecondary, marginTop: theme.spacing.sm }}>
<Text selectable style={{ fontSize: 16, color: theme.colors.textSecondary, marginTop: theme.spacing.sm }}>
Најдете мајстор во вашата близина
</Text>
</View>
<TextInput
accessible
accessibilityLabel="Пребарувај мајстори"
accessibilityRole="search"
placeholder="Пребарувај мајстори..."
placeholderTextColor={theme.colors.textTertiary}
value={search}
@ -65,19 +83,38 @@ export default function HomeScreen() {
{search.trim() ? "Резултати од пребарување" : "Неодамна додадени"}
</Text>
{ads === undefined ? (
{(search.trim() ? searchResults === undefined : isLoading) ? (
<Loading />
) : ads.length === 0 ? (
) : displayAds.length === 0 ? (
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
{search.trim() ? "Не се пронајдени резултати" : "Нема огласи"}
</Text>
) : (
<View style={{ gap: theme.spacing.sm }}>
{ads.map((ad) => (
{displayAds.map((ad) => (
<AdCard key={ad._id} ad={ad} />
))}
</View>
)}
{!search.trim() && adsStatus === "CanLoadMore" && (
<Pressable
accessible
accessibilityLabel="Вчитај повеќе огласи"
accessibilityRole="button"
onPress={() => loadMoreAds(20)}
style={{
backgroundColor: theme.colors.surface,
borderRadius: theme.radius.md,
paddingVertical: theme.spacing.md,
alignItems: "center",
borderWidth: 1,
borderColor: theme.colors.border,
}}
>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
</Pressable>
)}
</ScrollView>
);
}

View File

@ -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<TabKey>("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 (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
}
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }}
>
{isAuthenticated && isCustomer && (
<Pressable onPress={() => router.push("/post/create" as any)}>
<Pressable
accessible
accessibilityLabel="Креирај ново побарување"
accessibilityRole="button"
onPress={() => router.push("/post/create" as any)}
>
<View
style={{
backgroundColor: theme.colors.primary,
@ -59,6 +74,10 @@ export default function PostsScreen() {
{TABS.map((tab) => (
<Pressable
key={tab.key}
accessible
accessibilityLabel={`Прикажи ${tab.label.toLowerCase()} побарувања`}
accessibilityRole="button"
accessibilityState={{ selected: activeTab === tab.key }}
onPress={() => setActiveTab(tab.key)}
style={{
flex: 1,
@ -84,9 +103,9 @@ export default function PostsScreen() {
))}
</View>
{posts === undefined ? (
{postsResult.status === "LoadingFirstPage" ? (
<Loading />
) : posts.length === 0 ? (
) : postsResult.results.length === 0 ? (
<View style={{ alignItems: "center", paddingVertical: 60 }}>
<Text style={{ fontSize: 48, marginBottom: 16 }}>📋</Text>
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginBottom: 8 }}>
@ -100,11 +119,30 @@ export default function PostsScreen() {
</View>
) : (
<View style={{ gap: theme.spacing.md }}>
{posts.map((post) => (
{postsResult.results.map((post) => (
<PostCard key={post._id} post={post} />
))}
</View>
)}
{postsResult.status === "CanLoadMore" && (
<Pressable
accessible
accessibilityLabel="Вчитај повеќе побарувања"
accessibilityRole="button"
onPress={() => postsResult.loadMore(20)}
style={{
backgroundColor: theme.colors.surface,
borderRadius: theme.radius.md,
paddingVertical: theme.spacing.md,
alignItems: "center",
borderWidth: 1,
borderColor: theme.colors.border,
}}
>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
</Pressable>
)}
</ScrollView>
);
}

View File

@ -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() {
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginBottom: 8 }}>
Најавете се
</Text>
<Text style={{ color: theme.colors.textSecondary, textAlign: "center", marginBottom: theme.spacing.lg }}>
<Text selectable style={{ color: theme.colors.textSecondary, textAlign: "center", marginBottom: theme.spacing.lg }}>
За да ги видите вашиот профил и податоци.
</Text>
<Button onPress={() => router.push("/(auth)/login")}>Најави се</Button>
@ -80,53 +95,61 @@ export default function ProfileScreen() {
}
const isHandyman = user.role === "handyman";
const myAds = myAdsResult.results;
const myPosts = myPostsResult.results;
return (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
}
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }}
>
{/* Profile header */}
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
<Avatar uri={user.avatarId ?? null} name={user.name || "Корисник"} size={80} />
<Text style={{ fontSize: 20, fontWeight: "700", color: theme.colors.text, marginTop: theme.spacing.md }}>
<Text selectable style={{ fontSize: 20, fontWeight: "700", color: theme.colors.text, marginTop: theme.spacing.md }}>
{user.name || "Корисник"}
</Text>
<Badge label={isHandyman ? "Мајстор" : "Клиент"} variant={isHandyman ? "primary" : "default"} />
{user.email && (
<Text style={{ color: theme.colors.textSecondary, fontSize: 14, marginTop: theme.spacing.xs }}>
<Text selectable style={{ color: theme.colors.textSecondary, fontSize: 14, marginTop: theme.spacing.xs }}>
{user.email}
</Text>
)}
{user.phone && (
<Text style={{ color: theme.colors.textSecondary, fontSize: 14 }}>
<Text selectable style={{ color: theme.colors.textSecondary, fontSize: 14 }}>
{user.phone}
</Text>
)}
</View>
{/* Handyman sections */}
{isHandyman && (
<>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>
Мои огласи
</Text>
<Pressable onPress={() => router.push("/ad/create" as any)}>
<Pressable
accessible
accessibilityLabel="Креирај нов оглас"
accessibilityRole="button"
onPress={() => router.push("/ad/create" as any)}
>
<View style={{ backgroundColor: theme.colors.primary, borderRadius: theme.radius.md, paddingHorizontal: theme.spacing.md, paddingVertical: theme.spacing.sm }}>
<Text style={{ color: "#FFFFFF", fontWeight: "600", fontSize: 14 }}>+ Нов оглас</Text>
</View>
</Pressable>
</View>
{myAds === undefined ? (
{myAdsResult.status === "LoadingFirstPage" ? (
<Loading />
) : myAds.length === 0 ? (
<Card>
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
<Text style={{ fontSize: 40, marginBottom: theme.spacing.sm }}>📋</Text>
<Text style={{ color: theme.colors.textSecondary, textAlign: "center", fontSize: 15 }}>
Сеуште немате огласи. Креирајте нов оглас за да се прикажете на мајсторите.
<Text selectable style={{ color: theme.colors.textSecondary, textAlign: "center", fontSize: 15 }}>
Сеуште немате огласи. Креирајте нов оглас за да се прикажете на клиентите.
</Text>
</View>
</Card>
@ -137,6 +160,9 @@ export default function ProfileScreen() {
<AdCard ad={ad} />
<View style={{ flexDirection: "row", gap: theme.spacing.sm }}>
<Pressable
accessible
accessibilityLabel={`Уреди оглас: ${ad.title}`}
accessibilityRole="button"
onPress={() => router.push(`/ad/create?editId=${ad._id}` as any)}
style={{
flex: 1,
@ -151,6 +177,9 @@ export default function ProfileScreen() {
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Уреди</Text>
</Pressable>
<Pressable
accessible
accessibilityLabel={`Избриши оглас: ${ad.title}`}
accessibilityRole="button"
onPress={() => handleDeleteAd(ad._id)}
style={{
flex: 1,
@ -167,40 +196,91 @@ export default function ProfileScreen() {
))}
</View>
)}
{myAdsResult.status === "CanLoadMore" && (
<Pressable
accessible
accessibilityLabel="Вчитај повеќе огласи"
accessibilityRole="button"
onPress={() => myAdsResult.loadMore(20)}
style={{
backgroundColor: theme.colors.surface,
borderRadius: theme.radius.md,
paddingVertical: theme.spacing.md,
alignItems: "center",
borderWidth: 1,
borderColor: theme.colors.border,
}}
>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
</Pressable>
)}
</>
)}
{/* Customer sections */}
{!isHandyman && (
<>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>
Мои побарувања
</Text>
<Pressable>
<Pressable
accessible
accessibilityLabel="Креирај ново побарување"
accessibilityRole="button"
onPress={() => router.push("/post/create" as any)}
>
<View style={{ backgroundColor: theme.colors.primary, borderRadius: theme.radius.md, paddingHorizontal: theme.spacing.md, paddingVertical: theme.spacing.sm }}>
<Text style={{ color: "#FFFFFF", fontWeight: "600", fontSize: 14 }}>+ Ново побарување</Text>
</View>
</Pressable>
</View>
{myPostsResult.status === "LoadingFirstPage" ? (
<Loading />
) : myPosts.length === 0 ? (
<Card>
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
<Text style={{ fontSize: 40, marginBottom: theme.spacing.sm }}>📝</Text>
<Text style={{ color: theme.colors.textSecondary, textAlign: "center", fontSize: 15 }}>
<Text selectable style={{ color: theme.colors.textSecondary, textAlign: "center", fontSize: 15 }}>
Сеуште немате побарувања. Креирајте ново побарување за да најдете мајстор.
</Text>
</View>
</Card>
) : (
<View style={{ gap: theme.spacing.sm }}>
{myPosts.map((post) => (
<PostCard key={post._id} post={post} />
))}
</View>
)}
{myPostsResult.status === "CanLoadMore" && (
<Pressable
accessible
accessibilityLabel="Вчитај повеќе побарувања"
accessibilityRole="button"
onPress={() => myPostsResult.loadMore(20)}
style={{
backgroundColor: theme.colors.surface,
borderRadius: theme.radius.md,
paddingVertical: theme.spacing.md,
alignItems: "center",
borderWidth: 1,
borderColor: theme.colors.border,
}}
>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
</Pressable>
)}
</>
)}
{/* Menu items */}
<View style={{ backgroundColor: theme.colors.card, borderRadius: theme.radius.lg, padding: theme.spacing.md, gap: theme.spacing.sm, borderWidth: 1, borderColor: theme.colors.border }}>
<Pressable style={{ paddingVertical: theme.spacing.sm }}>
<Pressable accessible accessibilityLabel="Оцени" accessibilityRole="button" style={{ paddingVertical: theme.spacing.sm }}>
<Text style={{ color: theme.colors.text, fontSize: 16 }}>Оцени</Text>
</Pressable>
<Pressable style={{ paddingVertical: theme.spacing.sm }}>
<Pressable accessible accessibilityLabel="Поставки" accessibilityRole="button" style={{ paddingVertical: theme.spacing.sm }}>
<Text style={{ color: theme.colors.text, fontSize: 16 }}>Поставки</Text>
</Pressable>
</View>

View File

@ -21,35 +21,35 @@ export default function TabLayout() {
name="(home)"
options={{
title: "Почетна",
tabBarIcon: ({ color, size }) => undefined,
tabBarAccessibilityLabel: "Почетна",
}}
/>
<Tabs.Screen
name="(explore)"
options={{
title: "Пребарување",
tabBarIcon: ({ color, size }) => undefined,
tabBarAccessibilityLabel: "Пребарување",
}}
/>
<Tabs.Screen
name="(posts)"
options={{
title: "Побарувања",
tabBarIcon: ({ color, size }) => undefined,
tabBarAccessibilityLabel: "Побарувања",
}}
/>
<Tabs.Screen
name="(chat)"
options={{
title: "Чат",
tabBarIcon: ({ color, size }) => undefined,
tabBarAccessibilityLabel: "Чат",
}}
/>
<Tabs.Screen
name="(profile)"
options={{
title: "Профил",
tabBarIcon: ({ color, size }) => undefined,
tabBarAccessibilityLabel: "Профил",
}}
/>
</Tabs>

View File

@ -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 (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: theme.colors.background, padding: theme.spacing.lg }}>
<Text style={{ fontSize: 24, fontWeight: "700", color: theme.colors.text, marginBottom: theme.spacing.sm }}>
<Text accessible accessibilityLabel="Страницата не е пронајдена" style={{ fontSize: 24, fontWeight: "700", color: theme.colors.text, marginBottom: theme.spacing.sm }}>
Страницата не е пронајдена
</Text>
<Text style={{ color: theme.colors.textSecondary, marginBottom: theme.spacing.lg, textAlign: "center" }}>
<Text selectable style={{ color: theme.colors.textSecondary, marginBottom: theme.spacing.lg, textAlign: "center" }}>
Страницата што ја барате не постои.
</Text>
<Link href="/" style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 16 }}>
Назад кон почетна
</Link>
<Pressable
accessible
accessibilityLabel="Назад кон почетна"
accessibilityRole="button"
onPress={() => router.replace("/(tabs)/(home)")}
style={{ backgroundColor: theme.colors.primary, borderRadius: theme.radius.md, paddingVertical: 12, paddingHorizontal: 24 }}
>
<Text style={{ color: "#FFFFFF", fontWeight: "600", fontSize: 16 }}>Начална страница</Text>
</Pressable>
</View>
);
}

View File

@ -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 <ErrorFallback error={this.state.error!} onReset={this.reset} />;
}
return this.props.children;
}
}
function ErrorFallback({ error, onReset }: { error: Error; onReset: () => void }) {
const theme = useTheme();
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center", padding: 24, backgroundColor: theme.colors.background }}>
<Text style={{ fontSize: 48, marginBottom: 16 }}></Text>
<Text style={{ fontSize: 20, fontWeight: "700", color: theme.colors.text, marginBottom: 8, textAlign: "center" }}>
Настана грешка
</Text>
<Text style={{ fontSize: 14, color: theme.colors.textSecondary, textAlign: "center", marginBottom: 8 }}>
Приложението наиде на неочекувана грешка.
</Text>
{__DEV__ && error?.message ? (
<ScrollView style={{ maxHeight: 120, marginBottom: 16 }} contentContainerStyle={{ padding: 12, backgroundColor: theme.colors.surface, borderRadius: 8 }}>
<Text style={{ fontSize: 12, color: theme.colors.error, fontFamily: "monospace" }}>
{error.message}
</Text>
</ScrollView>
) : null}
<Pressable
accessible
accessibilityLabel="Обиди се повторно"
accessibilityRole="button"
onPress={onReset}
style={{ backgroundColor: theme.colors.primary, borderRadius: 12, paddingVertical: 12, paddingHorizontal: 32 }}
>
<Text style={{ color: "#FFFFFF", fontWeight: "600", fontSize: 16 }}>Обиди се повторно</Text>
</Pressable>
</View>
);
}
export default function RootLayout() {
const [token, setToken] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null);
@ -66,6 +117,7 @@ export default function RootLayout() {
<AuthContext.Provider
value={{ token, userId, isAuthenticated: !!token, login, logout }}
>
<ErrorBoundary>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" />
@ -76,6 +128,7 @@ export default function RootLayout() {
<Stack.Screen name="chat/[id]" options={{ presentation: "card" }} />
<Stack.Screen name="review/create" options={{ presentation: "modal" }} />
</Stack>
</ErrorBoundary>
</AuthContext.Provider>
</ThemeProvider>
</ConvexProvider>

View File

@ -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() {
/>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
}
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 100 }}
>
<View
accessible
accessibilityLabel={`Слика за ${ad.title}`}
style={{
backgroundColor: theme.colors.surface,
borderRadius: theme.radius.lg,
@ -151,7 +162,7 @@ export default function AdDetailScreen() {
<View>
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>{ad.title}</Text>
{ad.priceRange && (
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.primary, marginTop: theme.spacing.xs }}>
<Text selectable style={{ fontSize: 18, fontWeight: "600", color: theme.colors.primary, marginTop: theme.spacing.xs }}>
{ad.priceRange}
</Text>
)}
@ -159,7 +170,7 @@ export default function AdDetailScreen() {
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm, flexWrap: "wrap" }}>
<Badge label={categoryName} variant="primary" />
<Text style={{ fontSize: 14, color: theme.colors.textSecondary }}>📍 {ad.location}</Text>
<Text selectable style={{ fontSize: 14, color: theme.colors.textSecondary }}>📍 {ad.location}</Text>
</View>
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
@ -171,7 +182,7 @@ export default function AdDetailScreen() {
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
Расположивост
</Text>
<Text style={{ fontSize: 14, color: theme.colors.textSecondary }}>{ad.availability}</Text>
<Text selectable style={{ fontSize: 14, color: theme.colors.textSecondary }}>{ad.availability}</Text>
</Card>
)}
@ -184,6 +195,9 @@ export default function AdDetailScreen() {
<Card>
<Pressable
accessible
accessibilityLabel={`Профил на ${handyman?.name || "Мајстор"}`}
accessibilityRole="link"
onPress={() => {
if (handyman) router.push(`/(tabs)/(profile)`);
}}

View File

@ -61,7 +61,7 @@ export default function CreateAdScreen() {
<Text style={{ color: theme.colors.textSecondary, textAlign: "center", marginBottom: theme.spacing.lg }}>
Треба да бидете најавени за да креирате оглас.
</Text>
<Button onPress={() => router.push("/(auth)/login")}>Најави се</Button>
<Button onPress={() => router.push("/(auth)/login")} accessibilityLabel="Најави се">Најави се</Button>
</View>
</>
);
@ -147,6 +147,9 @@ export default function CreateAdScreen() {
{["Категорија", "Детали", "Локација", "Цена"].map((label, i) => (
<Pressable
key={i}
accessible
accessibilityLabel={`Чекор ${label}`}
accessibilityRole="button"
onPress={() => setStep(i)}
style={{
flex: 1,
@ -170,8 +173,11 @@ export default function CreateAdScreen() {
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>Изберете категорија</Text>
<View style={{ gap: theme.spacing.sm }}>
{CATEGORIES.map((cat) => (
<Pressable
<Pressable
key={cat.slug}
accessible
accessibilityLabel={cat.name}
accessibilityRole="button"
onPress={() => setCategory(cat.slug)}
style={{
flexDirection: "row",
@ -210,6 +216,7 @@ export default function CreateAdScreen() {
value={title}
onChangeText={setTitle}
maxLength={100}
accessibilityLabel="Наслов на оглас"
/>
{title.length > 0 && title.length < 3 && (
<Text style={{ color: theme.colors.error, fontSize: 12, marginTop: 4 }}>Насловот треба да има најмалку 3 знаци</Text>
@ -227,6 +234,7 @@ export default function CreateAdScreen() {
numberOfLines={5}
style={{ minHeight: 120, textAlignVertical: "top" }}
maxLength={2000}
accessibilityLabel="Опис на оглас"
/>
{description.length > 0 && description.length < 10 && (
<Text style={{ color: theme.colors.error, fontSize: 12, marginTop: 4 }}>Описот треба да има најмалку 10 знаци</Text>
@ -248,6 +256,7 @@ export default function CreateAdScreen() {
placeholder="Пр: Скопје, Чаир"
value={location}
onChangeText={setLocation}
accessibilityLabel="Локација"
/>
</View>
<View
@ -281,6 +290,7 @@ export default function CreateAdScreen() {
placeholder="Пр: 500-1500 ден/час"
value={priceRange}
onChangeText={setPriceRange}
accessibilityLabel="Ценовен опсег"
/>
</View>
<View>
@ -291,6 +301,7 @@ export default function CreateAdScreen() {
placeholder="Пр: Достапен Петоци - Недели"
value={availability}
onChangeText={setAvailability}
accessibilityLabel="Расположивост"
/>
</View>
<View>

View File

@ -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={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
}
contentContainerStyle={{
paddingVertical: 16,
paddingHorizontal: 16,
@ -236,6 +246,9 @@ export default function ChatDetailScreen() {
}}
>
<TextInput
accessible
accessibilityLabel="Порака"
accessibilityRole="none"
value={text}
onChangeText={setText}
placeholder="Напишете порака..."
@ -256,6 +269,9 @@ export default function ChatDetailScreen() {
maxLength={1000}
/>
<Pressable
accessible
accessibilityLabel="Испрати порака"
accessibilityRole="button"
onPress={handleSend}
disabled={!text.trim() || sending}
style={{

View File

@ -1,4 +1,5 @@
import { View, Text, ScrollView, Alert } from "react-native";
import { useCallback, useState } from "react";
import { View, Text, ScrollView, Alert, RefreshControl } from "react-native";
import { useLocalSearchParams, Stack, useRouter } from "expo-router";
import { useQuery, useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
@ -25,6 +26,12 @@ export default function PostDetailScreen() {
const closePost = useMutation(api.posts.close);
const getOrCreateChat = useMutation(api.chats.getOrCreate);
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(() => {
setRefreshing(true);
setTimeout(() => setRefreshing(false), 800);
}, []);
if (!post) {
return (
@ -78,7 +85,12 @@ export default function PostDetailScreen() {
return (
<>
<Stack.Screen options={{ title: "Побарување", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 100 }}>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
}
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 100 }}>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>
@ -99,13 +111,13 @@ export default function PostDetailScreen() {
{post.location && (
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<Text style={{ fontSize: 14, color: theme.colors.textSecondary }}>📍 Локација:</Text>
<Text style={{ fontSize: 14, color: theme.colors.text }}>{post.location}</Text>
<Text selectable style={{ fontSize: 14, color: theme.colors.text }}>{post.location}</Text>
</View>
)}
{post.budget && (
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<Text style={{ fontSize: 14, color: theme.colors.textSecondary }}>💰 Буџет:</Text>
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.primary }}>{post.budget}</Text>
<Text selectable style={{ fontSize: 14, fontWeight: "600", color: theme.colors.primary }}>{post.budget}</Text>
</View>
)}
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>

View File

@ -36,7 +36,7 @@ export default function CreatePostScreen() {
<Text style={{ color: theme.colors.textSecondary, textAlign: "center", marginBottom: theme.spacing.lg }}>
Треба да сте најавени за да креирате побарување.
</Text>
<Button onPress={() => router.push("/(auth)/login" as any)}>Најави се</Button>
<Button onPress={() => router.push("/(auth)/login" as any)} accessibilityLabel="Најави се">Најави се</Button>
</View>
</>
);
@ -81,6 +81,7 @@ export default function CreatePostScreen() {
value={title}
onChangeText={setTitle}
maxLength={100}
accessibilityLabel="Наслов на побарување"
/>
</View>
@ -94,6 +95,7 @@ export default function CreatePostScreen() {
numberOfLines={4}
style={{ minHeight: 100, textAlignVertical: "top" }}
maxLength={2000}
accessibilityLabel="Опис на побарување"
/>
</View>
@ -134,6 +136,7 @@ export default function CreatePostScreen() {
value={location}
onChangeText={setLocation}
maxLength={100}
accessibilityLabel="Локација"
/>
</View>
@ -144,10 +147,11 @@ export default function CreatePostScreen() {
value={budget}
onChangeText={setBudget}
maxLength={100}
accessibilityLabel="Буџет"
/>
</View>
<Button onPress={handleSubmit} loading={submitting} disabled={submitting}>
<Button onPress={handleSubmit} loading={submitting} disabled={submitting} accessibilityLabel="Објави побарување">
Објави побарување
</Button>
</ScrollView>

View File

@ -98,6 +98,9 @@ export default function CreateReviewScreen() {
{[1, 2, 3, 4, 5].map((star) => (
<Pressable
key={star}
accessible
accessibilityLabel={`${star} ${star === 1 ? "ѕвезда" : "ѕвезди"}`}
accessibilityRole="button"
onPress={() => setRating(star)}
style={{ padding: 4 }}
>
@ -129,6 +132,7 @@ export default function CreateReviewScreen() {
multiline
numberOfLines={4}
style={{ minHeight: 100, textAlignVertical: "top" }}
accessibilityLabel="Коментар"
/>
</View>
@ -136,6 +140,7 @@ export default function CreateReviewScreen() {
loading={submitting}
disabled={rating === 0}
onPress={handleSubmit}
accessibilityLabel="Испрати оценка"
>
Испрати оценка
</Button>

View File

@ -24,6 +24,9 @@ export function AdCard({ ad }: AdCardProps) {
return (
<Link href={`/ad/${ad._id}` as any} asChild>
<Pressable
accessible
accessibilityLabel={`${ad.title}, ${categoryName}, ${ad.location}${ad.priceRange ? `, ${ad.priceRange}` : ""}`}
accessibilityRole="button"
style={{
backgroundColor: theme.colors.card,
borderRadius: theme.radius.lg,
@ -43,7 +46,7 @@ export function AdCard({ ad }: AdCardProps) {
)}
</View>
{ad.priceRange && (
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.primary, marginLeft: 8 }}>
<Text selectable style={{ fontSize: 14, fontWeight: "600", color: theme.colors.primary, marginLeft: 8 }}>
{ad.priceRange}
</Text>
)}

View File

@ -25,6 +25,9 @@ export function CategoryGrid({ categories, columns = 3 }: CategoryGridProps) {
return (
<Link key={cat.slug} href={`/(tabs)/(explore)?category=${cat.slug}`} asChild>
<Pressable
accessible
accessibilityLabel={cat.name}
accessibilityRole="button"
style={{
backgroundColor: theme.colors.surface,
borderRadius: theme.radius.md,

View File

@ -49,6 +49,9 @@ export function ChatListItem({ chat, currentUserId }: ChatListItemProps) {
return (
<Link href={`/chat/${chat._id}` as any} asChild>
<Pressable
accessible
accessibilityLabel={`Разговор со ${chat.otherUser?.name ?? "Непознат"}: ${previewText}`}
accessibilityRole="button"
style={{
flexDirection: "row",
alignItems: "center",

View File

@ -25,6 +25,9 @@ export function PostCard({ post }: PostCardProps) {
return (
<Link href={`/post/${post._id}` as any} asChild>
<Pressable
accessible
accessibilityLabel={`${post.title}, ${isOpen ? "отворено" : "затворено"}${post.budget ? `, ${post.budget}` : ""}`}
accessibilityRole="button"
style={{
backgroundColor: theme.colors.card,
borderRadius: theme.radius.lg,
@ -51,7 +54,7 @@ export function PostCard({ post }: PostCardProps) {
<Text style={{ fontSize: 13, color: theme.colors.textTertiary }}>📍 {post.location}</Text>
)}
{post.budget && (
<Text style={{ fontSize: 13, color: theme.colors.primary, fontWeight: "600" }}>
<Text selectable style={{ fontSize: 13, color: theme.colors.primary, fontWeight: "600" }}>
💰 {post.budget}
</Text>
)}

View File

@ -38,6 +38,8 @@ export function ReviewCard({ review, customerName, customerAvatarId }: ReviewCar
return (
<View
accessible
accessibilityLabel={`Оценка ${review.rating} од 5 од ${customerName}${review.comment ? `, ${review.comment}` : ""}`}
style={{
backgroundColor: theme.colors.card,
borderRadius: theme.radius.lg,
@ -50,7 +52,7 @@ export function ReviewCard({ review, customerName, customerAvatarId }: ReviewCar
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm }}>
<Avatar uri={customerAvatarId ?? null} name={customerName} size={36} />
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text }}>
<Text selectable style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text }}>
{customerName}
</Text>
<Text style={{ fontSize: 12, color: theme.colors.textTertiary }}>
@ -61,7 +63,7 @@ export function ReviewCard({ review, customerName, customerAvatarId }: ReviewCar
</View>
{review.comment ? (
<Text style={{ fontSize: 14, color: theme.colors.textSecondary, lineHeight: 20 }}>
<Text selectable style={{ fontSize: 14, color: theme.colors.textSecondary, lineHeight: 20 }}>
{review.comment}
</Text>
) : null}

View File

@ -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 (
<Image
accessible
accessibilityLabel={`Аватар на ${name}`}
source={uri}
style={{ width: size, height: size, borderRadius: size / 2 }}
contentFit="cover"
@ -29,6 +31,8 @@ export function Avatar({ uri, name, size = 44 }: AvatarProps) {
return (
<View
accessible
accessibilityLabel={`Аватар на ${name}`}
style={{
width: size,
height: size,

View File

@ -19,7 +19,11 @@ export function Badge({ label, variant = "default" }: BadgeProps) {
const { bg, text: textColor } = variants[variant];
return (
<View style={{ backgroundColor: bg, paddingHorizontal: 8, paddingVertical: 4, borderRadius: theme.radius.sm }}>
<View
accessible
accessibilityLabel={label}
style={{ backgroundColor: bg, paddingHorizontal: 8, paddingVertical: 4, borderRadius: theme.radius.sm }}
>
<Text style={{ color: textColor, fontSize: 12, fontWeight: "600" }}>{label}</Text>
</View>
);

View File

@ -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 (
<Pressable
accessible
accessibilityLabel={label}
accessibilityRole="button"
accessibilityState={{ disabled: disabled || loading }}
disabled={disabled || loading}
onPress={onPress}
style={[

View File

@ -10,6 +10,9 @@ export function Input({ error, style, ...props }: InputProps) {
return (
<TextInput
accessible
accessibilityLabel={props.placeholder || "Внесете текст"}
accessibilityRole="none"
placeholderTextColor={theme.colors.textTertiary}
style={[
{

View File

@ -9,7 +9,12 @@ export function Loading({ fullScreen = false }: LoadingProps) {
const theme = useTheme();
return (
<View style={[{ alignItems: "center", justifyContent: "center" }, fullScreen && { flex: 1 }]}>
<View
accessible
accessibilityLabel="Се вчитува"
accessibilityRole="progressbar"
style={[{ alignItems: "center", justifyContent: "center" }, fullScreen && { flex: 1 }]}
>
<ActivityIndicator size="large" color={theme.colors.primary} />
</View>
);

View File

@ -11,9 +11,13 @@ export function Rating({ value, count, size = 16 }: RatingProps) {
const theme = useTheme();
return (
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
<View
accessible
accessibilityLabel={`${value.toFixed(1)} ѕвезди${count !== undefined ? `, ${count} оцени` : ""}`}
style={{ flexDirection: "row", alignItems: "center", gap: 4 }}
>
<Text style={{ color: theme.colors.star, fontSize: size }}></Text>
<Text style={{ color: theme.colors.textSecondary, fontSize: 14, fontVariant: ["tabular-nums"] }}>
<Text style={{ color: theme.colors.textSecondary, fontSize: 14, fontVariant: ["tabular-nums"] as any }}>
{value.toFixed(1)}
</Text>
{count !== undefined && (

View File

@ -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(),

View File

@ -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,

View File

@ -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(),

View File

@ -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(),

View File

@ -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) => {

View File

@ -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(),