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:
parent
c700d139f7
commit
3e5b85a08e
13
app.json
13
app.json
@ -20,7 +20,18 @@
|
|||||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||||
},
|
},
|
||||||
"package": "mk.mojmajstor.app",
|
"package": "mk.mojmajstor.app",
|
||||||
"softwareKeyboardLayoutMode": "resize"
|
"softwareKeyboardLayoutMode": "resize",
|
||||||
|
"intentFilters": [
|
||||||
|
{
|
||||||
|
"action": "VIEW",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"scheme": "mojmajstor"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"category": ["BROWSABLE", "DEFAULT"]
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
|
|||||||
@ -70,6 +70,7 @@ export default function LoginScreen() {
|
|||||||
onChangeText={setEmail}
|
onChangeText={setEmail}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
|
accessibilityLabel="Е-пошта"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
@ -77,10 +78,11 @@ export default function LoginScreen() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChangeText={setPassword}
|
onChangeText={setPassword}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
|
accessibilityLabel="Лозинка"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={{ marginTop: theme.spacing.sm }}>
|
<View style={{ marginTop: theme.spacing.sm }}>
|
||||||
<Button loading={loading} onPress={handleLogin}>
|
<Button loading={loading} onPress={handleLogin} accessibilityLabel="Најави се">
|
||||||
Најави се
|
Најави се
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
@ -88,6 +90,9 @@ export default function LoginScreen() {
|
|||||||
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.md }}>
|
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.md }}>
|
||||||
<Text style={{ color: theme.colors.textSecondary }}>Немате профил? </Text>
|
<Text style={{ color: theme.colors.textSecondary }}>Немате профил? </Text>
|
||||||
<Text
|
<Text
|
||||||
|
accessible
|
||||||
|
accessibilityLabel="Регистрирајте се"
|
||||||
|
accessibilityRole="link"
|
||||||
style={{ color: theme.colors.primary, fontWeight: "600" }}
|
style={{ color: theme.colors.primary, fontWeight: "600" }}
|
||||||
onPress={() => router.push("/(auth)/register")}
|
onPress={() => router.push("/(auth)/register")}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -82,6 +82,9 @@ export default function RegisterScreen() {
|
|||||||
|
|
||||||
<View style={{ flexDirection: "row", gap: theme.spacing.md }}>
|
<View style={{ flexDirection: "row", gap: theme.spacing.md }}>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel="Изберете улога: Клиент"
|
||||||
|
accessibilityRole="button"
|
||||||
onPress={() => setRole("customer")}
|
onPress={() => setRole("customer")}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@ -103,6 +106,9 @@ export default function RegisterScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel="Изберете улога: Мајстор"
|
||||||
|
accessibilityRole="button"
|
||||||
onPress={() => setRole("handyman")}
|
onPress={() => setRole("handyman")}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@ -124,31 +130,37 @@ export default function RegisterScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Input placeholder="Име и презиме" value={name} onChangeText={setName} />
|
<Input placeholder="Име и презиме" value={name} onChangeText={setName} accessibilityLabel="Име и презиме" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Е-пошта"
|
placeholder="Е-пошта"
|
||||||
value={email}
|
value={email}
|
||||||
onChangeText={setEmail}
|
onChangeText={setEmail}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
|
accessibilityLabel="Е-пошта"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Телефонски број (незадолжително)"
|
placeholder="Телефонски број (незадолжително)"
|
||||||
value={phone}
|
value={phone}
|
||||||
onChangeText={setPhone}
|
onChangeText={setPhone}
|
||||||
keyboardType="phone-pad"
|
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 }}>
|
<View style={{ marginTop: theme.spacing.sm }}>
|
||||||
<Button loading={loading} onPress={handleRegister}>
|
<Button loading={loading} onPress={handleRegister} accessibilityLabel="Регистрирај се">
|
||||||
Регистрирај се
|
Регистрирај се
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.md }}>
|
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.md }}>
|
||||||
<Text style={{ color: theme.colors.textSecondary }}>Веќе имате профил? </Text>
|
<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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -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 { useRouter } from "expo-router";
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
import { useQuery } from "convex/react";
|
import { useQuery } from "convex/react";
|
||||||
import { api } from "../../../convex/_generated/api";
|
import { api } from "../../../convex/_generated/api";
|
||||||
import { useAuth } from "../../_layout";
|
import { useAuth } from "../../_layout";
|
||||||
@ -12,16 +13,25 @@ export default function ChatScreen() {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { token, userId, isAuthenticated } = useAuth();
|
const { token, userId, isAuthenticated } = useAuth();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const chats = useQuery(
|
const chats = useQuery(
|
||||||
api.chats.listByUser,
|
api.chats.listByUser,
|
||||||
isAuthenticated && token ? { token } : "skip"
|
isAuthenticated && token ? { token } : "skip"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onRefresh = useCallback(() => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setTimeout(() => setRefreshing(false), 800);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
||||||
|
}
|
||||||
contentContainerStyle={{ padding: theme.spacing.lg }}
|
contentContainerStyle={{ padding: theme.spacing.lg }}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@ -45,6 +55,7 @@ export default function ChatScreen() {
|
|||||||
Чатови
|
Чатови
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
|
selectable
|
||||||
style={{
|
style={{
|
||||||
color: theme.colors.textSecondary,
|
color: theme.colors.textSecondary,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
@ -80,6 +91,9 @@ export default function ChatScreen() {
|
|||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
||||||
|
}
|
||||||
contentContainerStyle={{ padding: theme.spacing.lg }}
|
contentContainerStyle={{ padding: theme.spacing.lg }}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@ -102,7 +116,7 @@ export default function ChatScreen() {
|
|||||||
>
|
>
|
||||||
Немате разговори
|
Немате разговори
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ color: theme.colors.textSecondary, textAlign: "center" }}>
|
<Text selectable style={{ color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||||
Започнете разговор со мајстор или клиент.
|
Започнете разговор со мајстор или клиент.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -113,6 +127,9 @@ export default function ChatScreen() {
|
|||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
||||||
|
}
|
||||||
style={{ backgroundColor: theme.colors.background }}
|
style={{ backgroundColor: theme.colors.background }}
|
||||||
>
|
>
|
||||||
{chats.map((chat) => (
|
{chats.map((chat) => (
|
||||||
|
|||||||
@ -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 { useLocalSearchParams, Link } from "expo-router";
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
import { useTheme } from "../../../components/theme";
|
import { useTheme } from "../../../components/theme";
|
||||||
import { useQuery } from "convex/react";
|
import { usePaginatedQuery, useQuery } from "convex/react";
|
||||||
import { api } from "../../../convex/_generated/api";
|
import { api } from "../../../convex/_generated/api";
|
||||||
import { CategoryGrid } from "../../../components/category-grid";
|
import { CategoryGrid } from "../../../components/category-grid";
|
||||||
import { AdCard } from "../../../components/ad-card";
|
import { AdCard } from "../../../components/ad-card";
|
||||||
@ -11,13 +12,20 @@ import { CATEGORIES, CATEGORY_EMOJI, getCategoryName } from "../../../lib/consta
|
|||||||
export default function ExploreScreen() {
|
export default function ExploreScreen() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { category } = useLocalSearchParams<{ category?: string }>();
|
const { category } = useLocalSearchParams<{ category?: string }>();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const categories = useQuery(api.categories.list);
|
const categories = useQuery(api.categories.list);
|
||||||
const categoryAds = useQuery(
|
const categoryAdsResult = usePaginatedQuery(
|
||||||
api.ads.getByCategory,
|
api.ads.getByCategoryPaginated,
|
||||||
category ? { category } : "skip"
|
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;
|
const displayCategories = categories ?? CATEGORIES;
|
||||||
|
|
||||||
@ -28,33 +36,55 @@ export default function ExploreScreen() {
|
|||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
||||||
|
}
|
||||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
|
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
|
||||||
>
|
>
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm }}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm }}>
|
||||||
<Link href="/(tabs)/(explore)" asChild>
|
<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>
|
<Text style={{ fontSize: 20, color: theme.colors.primary }}>← Сите</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Link>
|
</Link>
|
||||||
<Text style={{ fontSize: 28 }}>{catEmoji}</Text>
|
<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}
|
{catName}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{categoryAds === undefined ? (
|
{categoryAdsResult.status === "LoadingFirstPage" ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
) : categoryAds.length === 0 ? (
|
) : categoryAdsResult.results.length === 0 ? (
|
||||||
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
|
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
|
||||||
Нема огласи во оваа категорија
|
Нема огласи во оваа категорија
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<View style={{ gap: theme.spacing.sm }}>
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
{categoryAds.map((ad) => (
|
{categoryAdsResult.results.map((ad) => (
|
||||||
<AdCard key={ad._id} ad={ad} />
|
<AdCard key={ad._id} ad={ad} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</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>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -62,6 +92,9 @@ export default function ExploreScreen() {
|
|||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
||||||
|
}
|
||||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
|
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: 20, fontWeight: "700", color: theme.colors.text }}>
|
<Text style={{ fontSize: 20, fontWeight: "700", color: theme.colors.text }}>
|
||||||
@ -74,19 +107,38 @@ export default function ExploreScreen() {
|
|||||||
Сите огласи
|
Сите огласи
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{allAds === undefined ? (
|
{allAdsResult.status === "LoadingFirstPage" ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
) : allAds.length === 0 ? (
|
) : allAdsResult.results.length === 0 ? (
|
||||||
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
|
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
|
||||||
Нема огласи
|
Нема огласи
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<View style={{ gap: theme.spacing.sm }}>
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
{allAds.map((ad) => (
|
{allAdsResult.results.map((ad) => (
|
||||||
<AdCard key={ad._id} ad={ad} />
|
<AdCard key={ad._id} ad={ad} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</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>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { View, Text, TextInput, ScrollView } from "react-native";
|
import { View, Text, TextInput, ScrollView, RefreshControl, Pressable } from "react-native";
|
||||||
import { useState } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { useTheme } from "../../../components/theme";
|
import { useTheme } from "../../../components/theme";
|
||||||
import { useQuery } from "convex/react";
|
import { useQuery, usePaginatedQuery } from "convex/react";
|
||||||
import { api } from "../../../convex/_generated/api";
|
import { api } from "../../../convex/_generated/api";
|
||||||
import { CategoryGrid } from "../../../components/category-grid";
|
import { CategoryGrid } from "../../../components/category-grid";
|
||||||
import { AdCard } from "../../../components/ad-card";
|
import { AdCard } from "../../../components/ad-card";
|
||||||
@ -11,32 +11,50 @@ import { CATEGORIES } from "../../../lib/constants";
|
|||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const categories = useQuery(api.categories.list);
|
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(
|
const searchResults = useQuery(
|
||||||
api.ads.search,
|
api.ads.search,
|
||||||
search.trim() ? { query: search.trim() } : "skip"
|
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 displayCategories = categories ?? CATEGORIES;
|
||||||
|
const displayAds = search.trim() ? (searchResults ?? []) : ads;
|
||||||
|
|
||||||
|
const isLoading = !search.trim() && adsStatus === "LoadingFirstPage";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
||||||
|
}
|
||||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 40 }}
|
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 40 }}
|
||||||
>
|
>
|
||||||
<View style={{ marginTop: theme.spacing.md }}>
|
<View style={{ marginTop: theme.spacing.md }}>
|
||||||
<Text style={{ fontSize: 24, fontWeight: "700", color: theme.colors.text }}>
|
<Text style={{ fontSize: 24, fontWeight: "700", color: theme.colors.text }}>
|
||||||
Добредојдовте 🏠
|
Добредојдовте 🏠
|
||||||
</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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
|
accessible
|
||||||
|
accessibilityLabel="Пребарувај мајстори"
|
||||||
|
accessibilityRole="search"
|
||||||
placeholder="Пребарувај мајстори..."
|
placeholder="Пребарувај мајстори..."
|
||||||
placeholderTextColor={theme.colors.textTertiary}
|
placeholderTextColor={theme.colors.textTertiary}
|
||||||
value={search}
|
value={search}
|
||||||
@ -65,19 +83,38 @@ export default function HomeScreen() {
|
|||||||
{search.trim() ? "Резултати од пребарување" : "Неодамна додадени"}
|
{search.trim() ? "Резултати од пребарување" : "Неодамна додадени"}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{ads === undefined ? (
|
{(search.trim() ? searchResults === undefined : isLoading) ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
) : ads.length === 0 ? (
|
) : displayAds.length === 0 ? (
|
||||||
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
|
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
|
||||||
{search.trim() ? "Не се пронајдени резултати" : "Нема огласи"}
|
{search.trim() ? "Не се пронајдени резултати" : "Нема огласи"}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<View style={{ gap: theme.spacing.sm }}>
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
{ads.map((ad) => (
|
{displayAds.map((ad) => (
|
||||||
<AdCard key={ad._id} ad={ad} />
|
<AdCard key={ad._id} ad={ad} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</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>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -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 { useRouter } from "expo-router";
|
||||||
import { useState } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { useQuery } from "convex/react";
|
import { usePaginatedQuery, useQuery } from "convex/react";
|
||||||
import { api } from "../../../convex/_generated/api";
|
import { api } from "../../../convex/_generated/api";
|
||||||
import { useAuth } from "../../_layout";
|
import { useAuth } from "../../_layout";
|
||||||
import { useTheme } from "../../../components/theme";
|
import { useTheme } from "../../../components/theme";
|
||||||
@ -24,21 +24,36 @@ export default function PostsScreen() {
|
|||||||
isAuthenticated && token ? { token } : "skip"
|
isAuthenticated && token ? { token } : "skip"
|
||||||
);
|
);
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>("open");
|
const [activeTab, setActiveTab] = useState<TabKey>("open");
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const posts = useQuery(
|
const postsResult = usePaginatedQuery(
|
||||||
api.posts.list,
|
api.posts.listPaginated,
|
||||||
activeTab === "open" ? { status: "open" } : {}
|
activeTab === "open" ? { status: "open" as const } : {},
|
||||||
|
{ initialNumItems: 20 }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onRefresh = useCallback(() => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setTimeout(() => setRefreshing(false), 800);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isCustomer = currentUser?.role !== "handyman";
|
const isCustomer = currentUser?.role !== "handyman";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
||||||
|
}
|
||||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }}
|
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }}
|
||||||
>
|
>
|
||||||
{isAuthenticated && isCustomer && (
|
{isAuthenticated && isCustomer && (
|
||||||
<Pressable onPress={() => router.push("/post/create" as any)}>
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel="Креирај ново побарување"
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push("/post/create" as any)}
|
||||||
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: theme.colors.primary,
|
backgroundColor: theme.colors.primary,
|
||||||
@ -59,6 +74,10 @@ export default function PostsScreen() {
|
|||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`Прикажи ${tab.label.toLowerCase()} побарувања`}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ selected: activeTab === tab.key }}
|
||||||
onPress={() => setActiveTab(tab.key)}
|
onPress={() => setActiveTab(tab.key)}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@ -84,9 +103,9 @@ export default function PostsScreen() {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{posts === undefined ? (
|
{postsResult.status === "LoadingFirstPage" ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
) : posts.length === 0 ? (
|
) : postsResult.results.length === 0 ? (
|
||||||
<View style={{ alignItems: "center", paddingVertical: 60 }}>
|
<View style={{ alignItems: "center", paddingVertical: 60 }}>
|
||||||
<Text style={{ fontSize: 48, marginBottom: 16 }}>📋</Text>
|
<Text style={{ fontSize: 48, marginBottom: 16 }}>📋</Text>
|
||||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginBottom: 8 }}>
|
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginBottom: 8 }}>
|
||||||
@ -100,11 +119,30 @@ export default function PostsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={{ gap: theme.spacing.md }}>
|
<View style={{ gap: theme.spacing.md }}>
|
||||||
{posts.map((post) => (
|
{postsResult.results.map((post) => (
|
||||||
<PostCard key={post._id} post={post} />
|
<PostCard key={post._id} post={post} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</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>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -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 { 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 { api } from "../../../convex/_generated/api";
|
||||||
import { useAuth } from "../../_layout";
|
import { useAuth } from "../../_layout";
|
||||||
import { useTheme } from "../../../components/theme";
|
import { useTheme } from "../../../components/theme";
|
||||||
@ -11,20 +12,34 @@ import { Loading } from "../../../components/ui/loading";
|
|||||||
import { Card } from "../../../components/ui/card";
|
import { Card } from "../../../components/ui/card";
|
||||||
import { Avatar } from "../../../components/ui/avatar";
|
import { Avatar } from "../../../components/ui/avatar";
|
||||||
import { AdCard } from "../../../components/ad-card";
|
import { AdCard } from "../../../components/ad-card";
|
||||||
|
import { PostCard } from "../../../components/post-card";
|
||||||
import { getCategoryName } from "../../../lib/constants";
|
import { getCategoryName } from "../../../lib/constants";
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { token, userId, isAuthenticated, logout } = useAuth();
|
const { token, userId, isAuthenticated, logout } = useAuth();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const user = useQuery(api.users.getCurrentUser, isAuthenticated ? { token: token! } : "skip");
|
const user = useQuery(api.users.getCurrentUser, isAuthenticated ? { token: token! } : "skip");
|
||||||
const myAds = useQuery(
|
const myAdsResult = usePaginatedQuery(
|
||||||
api.ads.getByHandyman,
|
api.ads.getByHandymanPaginated,
|
||||||
isAuthenticated && userId ? { handymanId: userId as any } : "skip"
|
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 deleteAd = useMutation(api.ads.remove);
|
||||||
const logoutMutation = useMutation(api.auth.logout);
|
const logoutMutation = useMutation(api.auth.logout);
|
||||||
|
|
||||||
|
const onRefresh = useCallback(() => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setTimeout(() => setRefreshing(false), 800);
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
@ -63,7 +78,7 @@ export default function ProfileScreen() {
|
|||||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginBottom: 8 }}>
|
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginBottom: 8 }}>
|
||||||
Најавете се
|
Најавете се
|
||||||
</Text>
|
</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>
|
</Text>
|
||||||
<Button onPress={() => router.push("/(auth)/login")}>Најави се</Button>
|
<Button onPress={() => router.push("/(auth)/login")}>Најави се</Button>
|
||||||
@ -80,53 +95,61 @@ export default function ProfileScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isHandyman = user.role === "handyman";
|
const isHandyman = user.role === "handyman";
|
||||||
|
const myAds = myAdsResult.results;
|
||||||
|
const myPosts = myPostsResult.results;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
||||||
|
}
|
||||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }}
|
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }}
|
||||||
>
|
>
|
||||||
{/* Profile header */}
|
|
||||||
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
|
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
|
||||||
<Avatar uri={user.avatarId ?? null} name={user.name || "Корисник"} size={80} />
|
<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 || "Корисник"}
|
{user.name || "Корисник"}
|
||||||
</Text>
|
</Text>
|
||||||
<Badge label={isHandyman ? "Мајстор" : "Клиент"} variant={isHandyman ? "primary" : "default"} />
|
<Badge label={isHandyman ? "Мајстор" : "Клиент"} variant={isHandyman ? "primary" : "default"} />
|
||||||
{user.email && (
|
{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}
|
{user.email}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{user.phone && (
|
{user.phone && (
|
||||||
<Text style={{ color: theme.colors.textSecondary, fontSize: 14 }}>
|
<Text selectable style={{ color: theme.colors.textSecondary, fontSize: 14 }}>
|
||||||
{user.phone}
|
{user.phone}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Handyman sections */}
|
|
||||||
{isHandyman && (
|
{isHandyman && (
|
||||||
<>
|
<>
|
||||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>
|
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>
|
||||||
Мои огласи
|
Мои огласи
|
||||||
</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 }}>
|
<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>
|
<Text style={{ color: "#FFFFFF", fontWeight: "600", fontSize: 14 }}>+ Нов оглас</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{myAds === undefined ? (
|
{myAdsResult.status === "LoadingFirstPage" ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
) : myAds.length === 0 ? (
|
) : myAds.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
|
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
|
||||||
<Text style={{ fontSize: 40, marginBottom: theme.spacing.sm }}>📋</Text>
|
<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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
@ -137,6 +160,9 @@ export default function ProfileScreen() {
|
|||||||
<AdCard ad={ad} />
|
<AdCard ad={ad} />
|
||||||
<View style={{ flexDirection: "row", gap: theme.spacing.sm }}>
|
<View style={{ flexDirection: "row", gap: theme.spacing.sm }}>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`Уреди оглас: ${ad.title}`}
|
||||||
|
accessibilityRole="button"
|
||||||
onPress={() => router.push(`/ad/create?editId=${ad._id}` as any)}
|
onPress={() => router.push(`/ad/create?editId=${ad._id}` as any)}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@ -151,6 +177,9 @@ export default function ProfileScreen() {
|
|||||||
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Уреди</Text>
|
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Уреди</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`Избриши оглас: ${ad.title}`}
|
||||||
|
accessibilityRole="button"
|
||||||
onPress={() => handleDeleteAd(ad._id)}
|
onPress={() => handleDeleteAd(ad._id)}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@ -167,40 +196,91 @@ export default function ProfileScreen() {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</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 && (
|
{!isHandyman && (
|
||||||
<>
|
<>
|
||||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>
|
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>
|
||||||
Мои побарувања
|
Мои побарувања
|
||||||
</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 }}>
|
<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>
|
<Text style={{ color: "#FFFFFF", fontWeight: "600", fontSize: 14 }}>+ Ново побарување</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Card>
|
{myPostsResult.status === "LoadingFirstPage" ? (
|
||||||
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
|
<Loading />
|
||||||
<Text style={{ fontSize: 40, marginBottom: theme.spacing.sm }}>📝</Text>
|
) : myPosts.length === 0 ? (
|
||||||
<Text style={{ color: theme.colors.textSecondary, textAlign: "center", fontSize: 15 }}>
|
<Card>
|
||||||
Сеуште немате побарувања. Креирајте ново побарување за да најдете мајстор.
|
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
|
||||||
</Text>
|
<Text style={{ fontSize: 40, marginBottom: theme.spacing.sm }}>📝</Text>
|
||||||
|
<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>
|
</View>
|
||||||
</Card>
|
)}
|
||||||
|
|
||||||
|
{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 }}>
|
<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>
|
<Text style={{ color: theme.colors.text, fontSize: 16 }}>Оцени</Text>
|
||||||
</Pressable>
|
</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>
|
<Text style={{ color: theme.colors.text, fontSize: 16 }}>Поставки</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -21,35 +21,35 @@ export default function TabLayout() {
|
|||||||
name="(home)"
|
name="(home)"
|
||||||
options={{
|
options={{
|
||||||
title: "Почетна",
|
title: "Почетна",
|
||||||
tabBarIcon: ({ color, size }) => undefined,
|
tabBarAccessibilityLabel: "Почетна",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="(explore)"
|
name="(explore)"
|
||||||
options={{
|
options={{
|
||||||
title: "Пребарување",
|
title: "Пребарување",
|
||||||
tabBarIcon: ({ color, size }) => undefined,
|
tabBarAccessibilityLabel: "Пребарување",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="(posts)"
|
name="(posts)"
|
||||||
options={{
|
options={{
|
||||||
title: "Побарувања",
|
title: "Побарувања",
|
||||||
tabBarIcon: ({ color, size }) => undefined,
|
tabBarAccessibilityLabel: "Побарувања",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="(chat)"
|
name="(chat)"
|
||||||
options={{
|
options={{
|
||||||
title: "Чат",
|
title: "Чат",
|
||||||
tabBarIcon: ({ color, size }) => undefined,
|
tabBarAccessibilityLabel: "Чат",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="(profile)"
|
name="(profile)"
|
||||||
options={{
|
options={{
|
||||||
title: "Профил",
|
title: "Профил",
|
||||||
tabBarIcon: ({ color, size }) => undefined,
|
tabBarAccessibilityLabel: "Профил",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@ -1,21 +1,28 @@
|
|||||||
import { View, Text } from "react-native";
|
import { View, Text, Pressable } from "react-native";
|
||||||
import { Link } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useTheme } from "../components/theme";
|
import { useTheme } from "../components/theme";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: theme.colors.background, padding: theme.spacing.lg }}>
|
<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>
|
||||||
<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>
|
</Text>
|
||||||
<Link href="/" style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 16 }}>
|
<Pressable
|
||||||
Назад кон почетна
|
accessible
|
||||||
</Link>
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import { Stack } from "expo-router/stack";
|
import { Stack } from "expo-router/stack";
|
||||||
import { ConvexProvider, ConvexReactClient } from "convex/react";
|
import { ConvexProvider, ConvexReactClient } from "convex/react";
|
||||||
import { useState, createContext, useContext, useEffect, useCallback } from "react";
|
import { useState, createContext, useContext, useEffect, useCallback, Component, type ReactNode, type ErrorInfo } from "react";
|
||||||
import { ThemeProvider } from "../components/theme";
|
import { View, Text, Pressable, ScrollView } from "react-native";
|
||||||
|
import { ThemeProvider, useTheme } from "../components/theme";
|
||||||
|
|
||||||
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);
|
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);
|
||||||
|
|
||||||
@ -28,6 +29,56 @@ export function useAuth() {
|
|||||||
const TOKEN_KEY = "mojmajstor_auth_token";
|
const TOKEN_KEY = "mojmajstor_auth_token";
|
||||||
const USER_ID_KEY = "mojmajstor_auth_userId";
|
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() {
|
export default function RootLayout() {
|
||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
const [userId, setUserId] = useState<string | null>(null);
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
@ -66,16 +117,18 @@ export default function RootLayout() {
|
|||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{ token, userId, isAuthenticated: !!token, login, logout }}
|
value={{ token, userId, isAuthenticated: !!token, login, logout }}
|
||||||
>
|
>
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
<ErrorBoundary>
|
||||||
<Stack.Screen name="(auth)" />
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(auth)" />
|
||||||
<Stack.Screen name="ad/[id]" options={{ presentation: "card" }} />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen name="ad/create" options={{ presentation: "modal" }} />
|
<Stack.Screen name="ad/[id]" options={{ presentation: "card" }} />
|
||||||
<Stack.Screen name="post/[id]" options={{ presentation: "card" }} />
|
<Stack.Screen name="ad/create" options={{ presentation: "modal" }} />
|
||||||
<Stack.Screen name="post/create" options={{ presentation: "modal" }} />
|
<Stack.Screen name="post/[id]" options={{ presentation: "card" }} />
|
||||||
<Stack.Screen name="chat/[id]" options={{ presentation: "card" }} />
|
<Stack.Screen name="post/create" options={{ presentation: "modal" }} />
|
||||||
<Stack.Screen name="review/create" options={{ presentation: "modal" }} />
|
<Stack.Screen name="chat/[id]" options={{ presentation: "card" }} />
|
||||||
</Stack>
|
<Stack.Screen name="review/create" options={{ presentation: "modal" }} />
|
||||||
|
</Stack>
|
||||||
|
</ErrorBoundary>
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ConvexProvider>
|
</ConvexProvider>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { View, Text, ScrollView, Pressable, Alert } from "react-native";
|
import { View, Text, ScrollView, Pressable, Alert, RefreshControl } from "react-native";
|
||||||
import { useLocalSearchParams, Stack, useRouter } from "expo-router";
|
import { useLocalSearchParams, Stack, useRouter } from "expo-router";
|
||||||
import { useQuery, useMutation } from "convex/react";
|
import { useQuery, useMutation } from "convex/react";
|
||||||
import { api } from "../../convex/_generated/api";
|
import { api } from "../../convex/_generated/api";
|
||||||
@ -63,6 +63,12 @@ export default function AdDetailScreen() {
|
|||||||
const deleteAd = useMutation(api.ads.remove);
|
const deleteAd = useMutation(api.ads.remove);
|
||||||
const startChat = useMutation(api.chats.getOrCreate);
|
const startChat = useMutation(api.chats.getOrCreate);
|
||||||
const [startingChat, setStartingChat] = useState(false);
|
const [startingChat, setStartingChat] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const onRefresh = useCallback(() => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setTimeout(() => setRefreshing(false), 800);
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function handleStartChat() {
|
async function handleStartChat() {
|
||||||
if (!token || !ad) return;
|
if (!token || !ad) return;
|
||||||
@ -128,9 +134,14 @@ export default function AdDetailScreen() {
|
|||||||
/>
|
/>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
||||||
|
}
|
||||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 100 }}
|
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 100 }}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`Слика за ${ad.title}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: theme.colors.surface,
|
backgroundColor: theme.colors.surface,
|
||||||
borderRadius: theme.radius.lg,
|
borderRadius: theme.radius.lg,
|
||||||
@ -151,7 +162,7 @@ export default function AdDetailScreen() {
|
|||||||
<View>
|
<View>
|
||||||
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>{ad.title}</Text>
|
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>{ad.title}</Text>
|
||||||
{ad.priceRange && (
|
{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}
|
{ad.priceRange}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@ -159,7 +170,7 @@ export default function AdDetailScreen() {
|
|||||||
|
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm, flexWrap: "wrap" }}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm, flexWrap: "wrap" }}>
|
||||||
<Badge label={categoryName} variant="primary" />
|
<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>
|
</View>
|
||||||
|
|
||||||
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
|
{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 style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
|
||||||
Расположивост
|
Расположивост
|
||||||
</Text>
|
</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>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -184,6 +195,9 @@ export default function AdDetailScreen() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`Профил на ${handyman?.name || "Мајстор"}`}
|
||||||
|
accessibilityRole="link"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (handyman) router.push(`/(tabs)/(profile)`);
|
if (handyman) router.push(`/(tabs)/(profile)`);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -61,7 +61,7 @@ export default function CreateAdScreen() {
|
|||||||
<Text style={{ color: theme.colors.textSecondary, textAlign: "center", marginBottom: theme.spacing.lg }}>
|
<Text style={{ color: theme.colors.textSecondary, textAlign: "center", marginBottom: theme.spacing.lg }}>
|
||||||
Треба да бидете најавени за да креирате оглас.
|
Треба да бидете најавени за да креирате оглас.
|
||||||
</Text>
|
</Text>
|
||||||
<Button onPress={() => router.push("/(auth)/login")}>Најави се</Button>
|
<Button onPress={() => router.push("/(auth)/login")} accessibilityLabel="Најави се">Најави се</Button>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -147,6 +147,9 @@ export default function CreateAdScreen() {
|
|||||||
{["Категорија", "Детали", "Локација", "Цена"].map((label, i) => (
|
{["Категорија", "Детали", "Локација", "Цена"].map((label, i) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={i}
|
key={i}
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`Чекор ${label}`}
|
||||||
|
accessibilityRole="button"
|
||||||
onPress={() => setStep(i)}
|
onPress={() => setStep(i)}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@ -170,9 +173,12 @@ export default function CreateAdScreen() {
|
|||||||
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>Изберете категорија</Text>
|
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>Изберете категорија</Text>
|
||||||
<View style={{ gap: theme.spacing.sm }}>
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
{CATEGORIES.map((cat) => (
|
{CATEGORIES.map((cat) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={cat.slug}
|
key={cat.slug}
|
||||||
onPress={() => setCategory(cat.slug)}
|
accessible
|
||||||
|
accessibilityLabel={cat.name}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => setCategory(cat.slug)}
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@ -210,6 +216,7 @@ export default function CreateAdScreen() {
|
|||||||
value={title}
|
value={title}
|
||||||
onChangeText={setTitle}
|
onChangeText={setTitle}
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
|
accessibilityLabel="Наслов на оглас"
|
||||||
/>
|
/>
|
||||||
{title.length > 0 && title.length < 3 && (
|
{title.length > 0 && title.length < 3 && (
|
||||||
<Text style={{ color: theme.colors.error, fontSize: 12, marginTop: 4 }}>Насловот треба да има најмалку 3 знаци</Text>
|
<Text style={{ color: theme.colors.error, fontSize: 12, marginTop: 4 }}>Насловот треба да има најмалку 3 знаци</Text>
|
||||||
@ -227,6 +234,7 @@ export default function CreateAdScreen() {
|
|||||||
numberOfLines={5}
|
numberOfLines={5}
|
||||||
style={{ minHeight: 120, textAlignVertical: "top" }}
|
style={{ minHeight: 120, textAlignVertical: "top" }}
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
|
accessibilityLabel="Опис на оглас"
|
||||||
/>
|
/>
|
||||||
{description.length > 0 && description.length < 10 && (
|
{description.length > 0 && description.length < 10 && (
|
||||||
<Text style={{ color: theme.colors.error, fontSize: 12, marginTop: 4 }}>Описот треба да има најмалку 10 знаци</Text>
|
<Text style={{ color: theme.colors.error, fontSize: 12, marginTop: 4 }}>Описот треба да има најмалку 10 знаци</Text>
|
||||||
@ -248,6 +256,7 @@ export default function CreateAdScreen() {
|
|||||||
placeholder="Пр: Скопје, Чаир"
|
placeholder="Пр: Скопје, Чаир"
|
||||||
value={location}
|
value={location}
|
||||||
onChangeText={setLocation}
|
onChangeText={setLocation}
|
||||||
|
accessibilityLabel="Локација"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
@ -281,6 +290,7 @@ export default function CreateAdScreen() {
|
|||||||
placeholder="Пр: 500-1500 ден/час"
|
placeholder="Пр: 500-1500 ден/час"
|
||||||
value={priceRange}
|
value={priceRange}
|
||||||
onChangeText={setPriceRange}
|
onChangeText={setPriceRange}
|
||||||
|
accessibilityLabel="Ценовен опсег"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View>
|
<View>
|
||||||
@ -291,6 +301,7 @@ export default function CreateAdScreen() {
|
|||||||
placeholder="Пр: Достапен Петоци - Недели"
|
placeholder="Пр: Достапен Петоци - Недели"
|
||||||
value={availability}
|
value={availability}
|
||||||
onChangeText={setAvailability}
|
onChangeText={setAvailability}
|
||||||
|
accessibilityLabel="Расположивост"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View>
|
<View>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -7,6 +7,7 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
|
RefreshControl,
|
||||||
type ListRenderItemInfo,
|
type ListRenderItemInfo,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useLocalSearchParams, Stack, useRouter } from "expo-router";
|
import { useLocalSearchParams, Stack, useRouter } from "expo-router";
|
||||||
@ -42,6 +43,12 @@ export default function ChatDetailScreen() {
|
|||||||
const sendMessage = useMutation(api.messages.send);
|
const sendMessage = useMutation(api.messages.send);
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const onRefresh = useCallback(() => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setTimeout(() => setRefreshing(false), 800);
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
const content = text.trim();
|
const content = text.trim();
|
||||||
@ -170,6 +177,9 @@ export default function ChatDetailScreen() {
|
|||||||
data={messages}
|
data={messages}
|
||||||
keyExtractor={(item) => item._id}
|
keyExtractor={(item) => item._id}
|
||||||
inverted
|
inverted
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
||||||
|
}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingVertical: 16,
|
paddingVertical: 16,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
@ -236,6 +246,9 @@ export default function ChatDetailScreen() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
accessible
|
||||||
|
accessibilityLabel="Порака"
|
||||||
|
accessibilityRole="none"
|
||||||
value={text}
|
value={text}
|
||||||
onChangeText={setText}
|
onChangeText={setText}
|
||||||
placeholder="Напишете порака..."
|
placeholder="Напишете порака..."
|
||||||
@ -256,6 +269,9 @@ export default function ChatDetailScreen() {
|
|||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
/>
|
/>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel="Испрати порака"
|
||||||
|
accessibilityRole="button"
|
||||||
onPress={handleSend}
|
onPress={handleSend}
|
||||||
disabled={!text.trim() || sending}
|
disabled={!text.trim() || sending}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -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 { useLocalSearchParams, Stack, useRouter } from "expo-router";
|
||||||
import { useQuery, useMutation } from "convex/react";
|
import { useQuery, useMutation } from "convex/react";
|
||||||
import { api } from "../../convex/_generated/api";
|
import { api } from "../../convex/_generated/api";
|
||||||
@ -25,6 +26,12 @@ export default function PostDetailScreen() {
|
|||||||
|
|
||||||
const closePost = useMutation(api.posts.close);
|
const closePost = useMutation(api.posts.close);
|
||||||
const getOrCreateChat = useMutation(api.chats.getOrCreate);
|
const getOrCreateChat = useMutation(api.chats.getOrCreate);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const onRefresh = useCallback(() => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setTimeout(() => setRefreshing(false), 800);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
return (
|
return (
|
||||||
@ -78,7 +85,12 @@ export default function PostDetailScreen() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Побарување", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
|
<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={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>
|
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>
|
||||||
@ -99,13 +111,13 @@ export default function PostDetailScreen() {
|
|||||||
{post.location && (
|
{post.location && (
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||||
<Text style={{ fontSize: 14, color: theme.colors.textSecondary }}>📍 Локација:</Text>
|
<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>
|
</View>
|
||||||
)}
|
)}
|
||||||
{post.budget && (
|
{post.budget && (
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||||
<Text style={{ fontSize: 14, color: theme.colors.textSecondary }}>💰 Буџет:</Text>
|
<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>
|
||||||
)}
|
)}
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export default function CreatePostScreen() {
|
|||||||
<Text style={{ color: theme.colors.textSecondary, textAlign: "center", marginBottom: theme.spacing.lg }}>
|
<Text style={{ color: theme.colors.textSecondary, textAlign: "center", marginBottom: theme.spacing.lg }}>
|
||||||
Треба да сте најавени за да креирате побарување.
|
Треба да сте најавени за да креирате побарување.
|
||||||
</Text>
|
</Text>
|
||||||
<Button onPress={() => router.push("/(auth)/login" as any)}>Најави се</Button>
|
<Button onPress={() => router.push("/(auth)/login" as any)} accessibilityLabel="Најави се">Најави се</Button>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -81,6 +81,7 @@ export default function CreatePostScreen() {
|
|||||||
value={title}
|
value={title}
|
||||||
onChangeText={setTitle}
|
onChangeText={setTitle}
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
|
accessibilityLabel="Наслов на побарување"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@ -94,6 +95,7 @@ export default function CreatePostScreen() {
|
|||||||
numberOfLines={4}
|
numberOfLines={4}
|
||||||
style={{ minHeight: 100, textAlignVertical: "top" }}
|
style={{ minHeight: 100, textAlignVertical: "top" }}
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
|
accessibilityLabel="Опис на побарување"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@ -134,6 +136,7 @@ export default function CreatePostScreen() {
|
|||||||
value={location}
|
value={location}
|
||||||
onChangeText={setLocation}
|
onChangeText={setLocation}
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
|
accessibilityLabel="Локација"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@ -144,10 +147,11 @@ export default function CreatePostScreen() {
|
|||||||
value={budget}
|
value={budget}
|
||||||
onChangeText={setBudget}
|
onChangeText={setBudget}
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
|
accessibilityLabel="Буџет"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button onPress={handleSubmit} loading={submitting} disabled={submitting}>
|
<Button onPress={handleSubmit} loading={submitting} disabled={submitting} accessibilityLabel="Објави побарување">
|
||||||
Објави побарување
|
Објави побарување
|
||||||
</Button>
|
</Button>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@ -98,6 +98,9 @@ export default function CreateReviewScreen() {
|
|||||||
{[1, 2, 3, 4, 5].map((star) => (
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={star}
|
key={star}
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`${star} ${star === 1 ? "ѕвезда" : "ѕвезди"}`}
|
||||||
|
accessibilityRole="button"
|
||||||
onPress={() => setRating(star)}
|
onPress={() => setRating(star)}
|
||||||
style={{ padding: 4 }}
|
style={{ padding: 4 }}
|
||||||
>
|
>
|
||||||
@ -129,6 +132,7 @@ export default function CreateReviewScreen() {
|
|||||||
multiline
|
multiline
|
||||||
numberOfLines={4}
|
numberOfLines={4}
|
||||||
style={{ minHeight: 100, textAlignVertical: "top" }}
|
style={{ minHeight: 100, textAlignVertical: "top" }}
|
||||||
|
accessibilityLabel="Коментар"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@ -136,6 +140,7 @@ export default function CreateReviewScreen() {
|
|||||||
loading={submitting}
|
loading={submitting}
|
||||||
disabled={rating === 0}
|
disabled={rating === 0}
|
||||||
onPress={handleSubmit}
|
onPress={handleSubmit}
|
||||||
|
accessibilityLabel="Испрати оценка"
|
||||||
>
|
>
|
||||||
Испрати оценка
|
Испрати оценка
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -24,6 +24,9 @@ export function AdCard({ ad }: AdCardProps) {
|
|||||||
return (
|
return (
|
||||||
<Link href={`/ad/${ad._id}` as any} asChild>
|
<Link href={`/ad/${ad._id}` as any} asChild>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`${ad.title}, ${categoryName}, ${ad.location}${ad.priceRange ? `, ${ad.priceRange}` : ""}`}
|
||||||
|
accessibilityRole="button"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: theme.colors.card,
|
backgroundColor: theme.colors.card,
|
||||||
borderRadius: theme.radius.lg,
|
borderRadius: theme.radius.lg,
|
||||||
@ -43,7 +46,7 @@ export function AdCard({ ad }: AdCardProps) {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{ad.priceRange && (
|
{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}
|
{ad.priceRange}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -25,6 +25,9 @@ export function CategoryGrid({ categories, columns = 3 }: CategoryGridProps) {
|
|||||||
return (
|
return (
|
||||||
<Link key={cat.slug} href={`/(tabs)/(explore)?category=${cat.slug}`} asChild>
|
<Link key={cat.slug} href={`/(tabs)/(explore)?category=${cat.slug}`} asChild>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={cat.name}
|
||||||
|
accessibilityRole="button"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: theme.colors.surface,
|
backgroundColor: theme.colors.surface,
|
||||||
borderRadius: theme.radius.md,
|
borderRadius: theme.radius.md,
|
||||||
|
|||||||
@ -49,6 +49,9 @@ export function ChatListItem({ chat, currentUserId }: ChatListItemProps) {
|
|||||||
return (
|
return (
|
||||||
<Link href={`/chat/${chat._id}` as any} asChild>
|
<Link href={`/chat/${chat._id}` as any} asChild>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`Разговор со ${chat.otherUser?.name ?? "Непознат"}: ${previewText}`}
|
||||||
|
accessibilityRole="button"
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@ -25,6 +25,9 @@ export function PostCard({ post }: PostCardProps) {
|
|||||||
return (
|
return (
|
||||||
<Link href={`/post/${post._id}` as any} asChild>
|
<Link href={`/post/${post._id}` as any} asChild>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`${post.title}, ${isOpen ? "отворено" : "затворено"}${post.budget ? `, ${post.budget}` : ""}`}
|
||||||
|
accessibilityRole="button"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: theme.colors.card,
|
backgroundColor: theme.colors.card,
|
||||||
borderRadius: theme.radius.lg,
|
borderRadius: theme.radius.lg,
|
||||||
@ -51,7 +54,7 @@ export function PostCard({ post }: PostCardProps) {
|
|||||||
<Text style={{ fontSize: 13, color: theme.colors.textTertiary }}>📍 {post.location}</Text>
|
<Text style={{ fontSize: 13, color: theme.colors.textTertiary }}>📍 {post.location}</Text>
|
||||||
)}
|
)}
|
||||||
{post.budget && (
|
{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}
|
💰 {post.budget}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -38,6 +38,8 @@ export function ReviewCard({ review, customerName, customerAvatarId }: ReviewCar
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`Оценка ${review.rating} од 5 од ${customerName}${review.comment ? `, ${review.comment}` : ""}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: theme.colors.card,
|
backgroundColor: theme.colors.card,
|
||||||
borderRadius: theme.radius.lg,
|
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 }}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm }}>
|
||||||
<Avatar uri={customerAvatarId ?? null} name={customerName} size={36} />
|
<Avatar uri={customerAvatarId ?? null} name={customerName} size={36} />
|
||||||
<View style={{ flex: 1 }}>
|
<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}
|
{customerName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: 12, color: theme.colors.textTertiary }}>
|
<Text style={{ fontSize: 12, color: theme.colors.textTertiary }}>
|
||||||
@ -61,7 +63,7 @@ export function ReviewCard({ review, customerName, customerAvatarId }: ReviewCar
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{review.comment ? (
|
{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}
|
{review.comment}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { View, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { Image, type ImageContentFit } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useTheme } from "../theme";
|
import { useTheme } from "../theme";
|
||||||
|
|
||||||
interface AvatarProps {
|
interface AvatarProps {
|
||||||
@ -20,6 +20,8 @@ export function Avatar({ uri, name, size = 44 }: AvatarProps) {
|
|||||||
if (uri) {
|
if (uri) {
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`Аватар на ${name}`}
|
||||||
source={uri}
|
source={uri}
|
||||||
style={{ width: size, height: size, borderRadius: size / 2 }}
|
style={{ width: size, height: size, borderRadius: size / 2 }}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
@ -29,6 +31,8 @@ export function Avatar({ uri, name, size = 44 }: AvatarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`Аватар на ${name}`}
|
||||||
style={{
|
style={{
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
|
|||||||
@ -19,7 +19,11 @@ export function Badge({ label, variant = "default" }: BadgeProps) {
|
|||||||
const { bg, text: textColor } = variants[variant];
|
const { bg, text: textColor } = variants[variant];
|
||||||
|
|
||||||
return (
|
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>
|
<Text style={{ color: textColor, fontSize: 12, fontWeight: "600" }}>{label}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -11,6 +11,7 @@ interface ButtonProps {
|
|||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
style?: ViewStyle;
|
style?: ViewStyle;
|
||||||
|
accessibilityLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
@ -21,6 +22,7 @@ export function Button({
|
|||||||
children,
|
children,
|
||||||
style,
|
style,
|
||||||
onPress,
|
onPress,
|
||||||
|
accessibilityLabel,
|
||||||
}: ButtonProps) {
|
}: ButtonProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
@ -44,8 +46,14 @@ export function Button({
|
|||||||
destructive: "#FFFFFF",
|
destructive: "#FFFFFF",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const label = typeof children === "string" ? children : accessibilityLabel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ disabled: disabled || loading }}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@ -10,6 +10,9 @@ export function Input({ error, style, ...props }: InputProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={props.placeholder || "Внесете текст"}
|
||||||
|
accessibilityRole="none"
|
||||||
placeholderTextColor={theme.colors.textTertiary}
|
placeholderTextColor={theme.colors.textTertiary}
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
|
|||||||
@ -9,7 +9,12 @@ export function Loading({ fullScreen = false }: LoadingProps) {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
return (
|
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} />
|
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -11,9 +11,13 @@ export function Rating({ value, count, size = 16 }: RatingProps) {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
return (
|
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.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)}
|
{value.toFixed(1)}
|
||||||
</Text>
|
</Text>
|
||||||
{count !== undefined && (
|
{count !== undefined && (
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { query, mutation } from "./_generated/server";
|
import { query, mutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
import { paginationOptsValidator } from "convex/server";
|
||||||
|
|
||||||
export const list = query({
|
export const list = query({
|
||||||
args: {},
|
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({
|
export const getByCategory = query({
|
||||||
args: { category: v.string() },
|
args: { category: v.string() },
|
||||||
handler: async (ctx, args) => {
|
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({
|
export const getById = query({
|
||||||
args: { id: v.id("ads") },
|
args: { id: v.id("ads") },
|
||||||
handler: async (ctx, args) => {
|
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({
|
export const create = mutation({
|
||||||
args: {
|
args: {
|
||||||
token: v.string(),
|
token: v.string(),
|
||||||
|
|||||||
@ -26,12 +26,12 @@ export const login = mutation({
|
|||||||
)
|
)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!account) throw new Error("Invalid credentials");
|
if (!account) throw new Error("Погрешна е-пошта или лозинка");
|
||||||
|
|
||||||
const user = await ctx.db.get(account.userId);
|
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();
|
const token = crypto.randomUUID();
|
||||||
await ctx.db.insert("sessions", {
|
await ctx.db.insert("sessions", {
|
||||||
@ -61,7 +61,7 @@ export const register = mutation({
|
|||||||
)
|
)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (existing) throw new Error("Email already registered");
|
if (existing) throw new Error("Е-поштата е веќе регистрирана");
|
||||||
|
|
||||||
const userId = await ctx.db.insert("users", {
|
const userId = await ctx.db.insert("users", {
|
||||||
name: args.name,
|
name: args.name,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { query, mutation } from "./_generated/server";
|
import { query, mutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
import { paginationOptsValidator } from "convex/server";
|
||||||
|
|
||||||
export const listByUser = query({
|
export const listByUser = query({
|
||||||
args: { token: v.string() },
|
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({
|
export const getOrCreate = mutation({
|
||||||
args: {
|
args: {
|
||||||
token: v.string(),
|
token: v.string(),
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { query, mutation } from "./_generated/server";
|
import { query, mutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
import { paginationOptsValidator } from "convex/server";
|
||||||
|
|
||||||
export const listByChat = query({
|
export const listByChat = query({
|
||||||
args: { token: v.string(), chatId: v.id("chats") },
|
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({
|
export const send = mutation({
|
||||||
args: {
|
args: {
|
||||||
token: v.string(),
|
token: v.string(),
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { query, mutation } from "./_generated/server";
|
import { query, mutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
import { paginationOptsValidator } from "convex/server";
|
||||||
|
|
||||||
export const list = query({
|
export const list = query({
|
||||||
args: { status: v.optional(v.union(v.literal("open"), v.literal("closed"))) },
|
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({
|
export const getByCustomer = query({
|
||||||
args: { customerId: v.id("users") },
|
args: { customerId: v.id("users") },
|
||||||
handler: async (ctx, args) => {
|
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({
|
export const getById = query({
|
||||||
args: { id: v.id("posts") },
|
args: { id: v.id("posts") },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { query, mutation } from "./_generated/server";
|
import { query, mutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
import { paginationOptsValidator } from "convex/server";
|
||||||
|
|
||||||
export const getByAd = query({
|
export const getByAd = query({
|
||||||
args: { adId: v.id("ads") },
|
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({
|
export const getByHandyman = query({
|
||||||
args: { handymanId: v.id("users") },
|
args: { handymanId: v.id("users") },
|
||||||
handler: async (ctx, args) => {
|
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({
|
export const create = mutation({
|
||||||
args: {
|
args: {
|
||||||
token: v.string(),
|
token: v.string(),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user