- 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.
293 lines
12 KiB
TypeScript
293 lines
12 KiB
TypeScript
import { View, Text, Pressable, ScrollView, RefreshControl, Alert } from "react-native";
|
||
import { useRouter } from "expo-router";
|
||
import { useState, useCallback } from "react";
|
||
import { useQuery, useMutation, usePaginatedQuery } from "convex/react";
|
||
import { api } from "../../../convex/_generated/api";
|
||
import { useAuth } from "../../_layout";
|
||
import { useTheme } from "../../../components/theme";
|
||
import { Button } from "../../../components/ui/button";
|
||
import { Badge } from "../../../components/ui/badge";
|
||
import { Rating } from "../../../components/ui/rating";
|
||
import { Loading } from "../../../components/ui/loading";
|
||
import { Card } from "../../../components/ui/card";
|
||
import { Avatar } from "../../../components/ui/avatar";
|
||
import { AdCard } from "../../../components/ad-card";
|
||
import { PostCard } from "../../../components/post-card";
|
||
import { getCategoryName } from "../../../lib/constants";
|
||
|
||
export default function ProfileScreen() {
|
||
const router = useRouter();
|
||
const theme = useTheme();
|
||
const { token, userId, isAuthenticated, logout } = useAuth();
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
|
||
const user = useQuery(api.users.getCurrentUser, isAuthenticated ? { token: token! } : "skip");
|
||
const myAdsResult = usePaginatedQuery(
|
||
api.ads.getByHandymanPaginated,
|
||
isAuthenticated && userId ? { handymanId: userId as any } : "skip",
|
||
{ initialNumItems: 20 }
|
||
);
|
||
const myPostsResult = usePaginatedQuery(
|
||
api.posts.getByCustomerPaginated,
|
||
isAuthenticated && userId ? { customerId: userId as any } : "skip",
|
||
{ initialNumItems: 20 }
|
||
);
|
||
const deleteAd = useMutation(api.ads.remove);
|
||
const logoutMutation = useMutation(api.auth.logout);
|
||
|
||
const onRefresh = useCallback(() => {
|
||
setRefreshing(true);
|
||
setTimeout(() => setRefreshing(false), 800);
|
||
}, []);
|
||
|
||
async function handleLogout() {
|
||
if (token) {
|
||
try {
|
||
await logoutMutation({ token });
|
||
} catch {}
|
||
}
|
||
await logout();
|
||
router.replace("/(auth)/login");
|
||
}
|
||
|
||
async function handleDeleteAd(adId: string) {
|
||
Alert.alert(
|
||
"Избриши оглас",
|
||
"Дали сте сигурни дека сакате да го избришете овој оглас?",
|
||
[
|
||
{ text: "Откажи", style: "cancel" },
|
||
{
|
||
text: "Избриши",
|
||
style: "destructive",
|
||
onPress: async () => {
|
||
try {
|
||
await deleteAd({ token: token!, adId: adId as any });
|
||
} catch (e: any) {
|
||
Alert.alert("Грешка", e.message || "Неуспешно бришење");
|
||
}
|
||
},
|
||
},
|
||
]
|
||
);
|
||
}
|
||
|
||
if (!isAuthenticated) {
|
||
return (
|
||
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: theme.colors.background, padding: theme.spacing.lg }}>
|
||
<Text style={{ fontSize: 48, marginBottom: 16 }}>👤</Text>
|
||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginBottom: 8 }}>
|
||
Најавете се
|
||
</Text>
|
||
<Text selectable style={{ color: theme.colors.textSecondary, textAlign: "center", marginBottom: theme.spacing.lg }}>
|
||
За да ги видите вашиот профил и податоци.
|
||
</Text>
|
||
<Button onPress={() => router.push("/(auth)/login")}>Најави се</Button>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
if (!user) {
|
||
return (
|
||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center", backgroundColor: theme.colors.background }}>
|
||
<Loading />
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const isHandyman = user.role === "handyman";
|
||
const myAds = myAdsResult.results;
|
||
const myPosts = myPostsResult.results;
|
||
|
||
return (
|
||
<ScrollView
|
||
contentInsetAdjustmentBehavior="automatic"
|
||
refreshControl={
|
||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
||
}
|
||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }}
|
||
>
|
||
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
|
||
<Avatar uri={user.avatarId ?? null} name={user.name || "Корисник"} size={80} />
|
||
<Text selectable style={{ fontSize: 20, fontWeight: "700", color: theme.colors.text, marginTop: theme.spacing.md }}>
|
||
{user.name || "Корисник"}
|
||
</Text>
|
||
<Badge label={isHandyman ? "Мајстор" : "Клиент"} variant={isHandyman ? "primary" : "default"} />
|
||
{user.email && (
|
||
<Text selectable style={{ color: theme.colors.textSecondary, fontSize: 14, marginTop: theme.spacing.xs }}>
|
||
{user.email}
|
||
</Text>
|
||
)}
|
||
{user.phone && (
|
||
<Text selectable style={{ color: theme.colors.textSecondary, fontSize: 14 }}>
|
||
{user.phone}
|
||
</Text>
|
||
)}
|
||
</View>
|
||
|
||
{isHandyman && (
|
||
<>
|
||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>
|
||
Мои огласи
|
||
</Text>
|
||
<Pressable
|
||
accessible
|
||
accessibilityLabel="Креирај нов оглас"
|
||
accessibilityRole="button"
|
||
onPress={() => router.push("/ad/create" as any)}
|
||
>
|
||
<View style={{ backgroundColor: theme.colors.primary, borderRadius: theme.radius.md, paddingHorizontal: theme.spacing.md, paddingVertical: theme.spacing.sm }}>
|
||
<Text style={{ color: "#FFFFFF", fontWeight: "600", fontSize: 14 }}>+ Нов оглас</Text>
|
||
</View>
|
||
</Pressable>
|
||
</View>
|
||
|
||
{myAdsResult.status === "LoadingFirstPage" ? (
|
||
<Loading />
|
||
) : myAds.length === 0 ? (
|
||
<Card>
|
||
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
|
||
<Text style={{ fontSize: 40, marginBottom: theme.spacing.sm }}>📋</Text>
|
||
<Text selectable style={{ color: theme.colors.textSecondary, textAlign: "center", fontSize: 15 }}>
|
||
Сеуште немате огласи. Креирајте нов оглас за да се прикажете на клиентите.
|
||
</Text>
|
||
</View>
|
||
</Card>
|
||
) : (
|
||
<View style={{ gap: theme.spacing.sm }}>
|
||
{myAds.map((ad) => (
|
||
<View key={ad._id} style={{ gap: theme.spacing.sm }}>
|
||
<AdCard ad={ad} />
|
||
<View style={{ flexDirection: "row", gap: theme.spacing.sm }}>
|
||
<Pressable
|
||
accessible
|
||
accessibilityLabel={`Уреди оглас: ${ad.title}`}
|
||
accessibilityRole="button"
|
||
onPress={() => router.push(`/ad/create?editId=${ad._id}` as any)}
|
||
style={{
|
||
flex: 1,
|
||
backgroundColor: theme.colors.surface,
|
||
borderRadius: theme.radius.md,
|
||
paddingVertical: 10,
|
||
alignItems: "center",
|
||
borderWidth: 1,
|
||
borderColor: theme.colors.border,
|
||
}}
|
||
>
|
||
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Уреди</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
accessible
|
||
accessibilityLabel={`Избриши оглас: ${ad.title}`}
|
||
accessibilityRole="button"
|
||
onPress={() => handleDeleteAd(ad._id)}
|
||
style={{
|
||
flex: 1,
|
||
backgroundColor: theme.colors.errorLight,
|
||
borderRadius: theme.radius.md,
|
||
paddingVertical: 10,
|
||
alignItems: "center",
|
||
}}
|
||
>
|
||
<Text style={{ color: theme.colors.error, fontWeight: "600", fontSize: 14 }}>Избриши</Text>
|
||
</Pressable>
|
||
</View>
|
||
</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>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{!isHandyman && (
|
||
<>
|
||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>
|
||
Мои побарувања
|
||
</Text>
|
||
<Pressable
|
||
accessible
|
||
accessibilityLabel="Креирај ново побарување"
|
||
accessibilityRole="button"
|
||
onPress={() => router.push("/post/create" as any)}
|
||
>
|
||
<View style={{ backgroundColor: theme.colors.primary, borderRadius: theme.radius.md, paddingHorizontal: theme.spacing.md, paddingVertical: theme.spacing.sm }}>
|
||
<Text style={{ color: "#FFFFFF", fontWeight: "600", fontSize: 14 }}>+ Ново побарување</Text>
|
||
</View>
|
||
</Pressable>
|
||
</View>
|
||
|
||
{myPostsResult.status === "LoadingFirstPage" ? (
|
||
<Loading />
|
||
) : myPosts.length === 0 ? (
|
||
<Card>
|
||
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
|
||
<Text style={{ fontSize: 40, marginBottom: theme.spacing.sm }}>📝</Text>
|
||
<Text selectable style={{ color: theme.colors.textSecondary, textAlign: "center", fontSize: 15 }}>
|
||
Сеуште немате побарувања. Креирајте ново побарување за да најдете мајстор.
|
||
</Text>
|
||
</View>
|
||
</Card>
|
||
) : (
|
||
<View style={{ gap: theme.spacing.sm }}>
|
||
{myPosts.map((post) => (
|
||
<PostCard key={post._id} post={post} />
|
||
))}
|
||
</View>
|
||
)}
|
||
|
||
{myPostsResult.status === "CanLoadMore" && (
|
||
<Pressable
|
||
accessible
|
||
accessibilityLabel="Вчитај повеќе побарувања"
|
||
accessibilityRole="button"
|
||
onPress={() => myPostsResult.loadMore(20)}
|
||
style={{
|
||
backgroundColor: theme.colors.surface,
|
||
borderRadius: theme.radius.md,
|
||
paddingVertical: theme.spacing.md,
|
||
alignItems: "center",
|
||
borderWidth: 1,
|
||
borderColor: theme.colors.border,
|
||
}}
|
||
>
|
||
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
|
||
</Pressable>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
<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 accessible accessibilityLabel="Оцени" accessibilityRole="button" style={{ paddingVertical: theme.spacing.sm }}>
|
||
<Text style={{ color: theme.colors.text, fontSize: 16 }}>Оцени</Text>
|
||
</Pressable>
|
||
<Pressable accessible accessibilityLabel="Поставки" accessibilityRole="button" style={{ paddingVertical: theme.spacing.sm }}>
|
||
<Text style={{ color: theme.colors.text, fontSize: 16 }}>Поставки</Text>
|
||
</Pressable>
|
||
</View>
|
||
|
||
<Button variant="destructive" onPress={handleLogout}>
|
||
Одјави се
|
||
</Button>
|
||
</ScrollView>
|
||
);
|
||
} |