- 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.
136 lines
4.8 KiB
TypeScript
136 lines
4.8 KiB
TypeScript
import { Stack } from "expo-router/stack";
|
||
import { ConvexProvider, ConvexReactClient } from "convex/react";
|
||
import { useState, createContext, useContext, useEffect, useCallback, Component, type ReactNode, type ErrorInfo } from "react";
|
||
import { View, Text, Pressable, ScrollView } from "react-native";
|
||
import { ThemeProvider, useTheme } from "../components/theme";
|
||
|
||
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);
|
||
|
||
type AuthState = {
|
||
token: string | null;
|
||
userId: string | null;
|
||
isAuthenticated: boolean;
|
||
login: (token: string, userId: string) => Promise<void>;
|
||
logout: () => Promise<void>;
|
||
};
|
||
|
||
const AuthContext = createContext<AuthState>({
|
||
token: null,
|
||
userId: null,
|
||
isAuthenticated: false,
|
||
login: async () => {},
|
||
logout: async () => {},
|
||
});
|
||
|
||
export function useAuth() {
|
||
return useContext(AuthContext);
|
||
}
|
||
|
||
const TOKEN_KEY = "mojmajstor_auth_token";
|
||
const USER_ID_KEY = "mojmajstor_auth_userId";
|
||
|
||
class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean; error: Error | null }> {
|
||
state: { hasError: boolean; error: Error | null } = { hasError: false, error: null };
|
||
|
||
static getDerivedStateFromError(error: Error) {
|
||
return { hasError: true, error };
|
||
}
|
||
|
||
reset = () => {
|
||
this.setState({ hasError: false, error: null });
|
||
};
|
||
|
||
render() {
|
||
if (this.state.hasError) {
|
||
return <ErrorFallback error={this.state.error!} onReset={this.reset} />;
|
||
}
|
||
return this.props.children;
|
||
}
|
||
}
|
||
|
||
function ErrorFallback({ error, onReset }: { error: Error; onReset: () => void }) {
|
||
const theme = useTheme();
|
||
return (
|
||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center", padding: 24, backgroundColor: theme.colors.background }}>
|
||
<Text style={{ fontSize: 48, marginBottom: 16 }}>⚠️</Text>
|
||
<Text style={{ fontSize: 20, fontWeight: "700", color: theme.colors.text, marginBottom: 8, textAlign: "center" }}>
|
||
Настана грешка
|
||
</Text>
|
||
<Text style={{ fontSize: 14, color: theme.colors.textSecondary, textAlign: "center", marginBottom: 8 }}>
|
||
Приложението наиде на неочекувана грешка.
|
||
</Text>
|
||
{__DEV__ && error?.message ? (
|
||
<ScrollView style={{ maxHeight: 120, marginBottom: 16 }} contentContainerStyle={{ padding: 12, backgroundColor: theme.colors.surface, borderRadius: 8 }}>
|
||
<Text style={{ fontSize: 12, color: theme.colors.error, fontFamily: "monospace" }}>
|
||
{error.message}
|
||
</Text>
|
||
</ScrollView>
|
||
) : null}
|
||
<Pressable
|
||
accessible
|
||
accessibilityLabel="Обиди се повторно"
|
||
accessibilityRole="button"
|
||
onPress={onReset}
|
||
style={{ backgroundColor: theme.colors.primary, borderRadius: 12, paddingVertical: 12, paddingHorizontal: 32 }}
|
||
>
|
||
<Text style={{ color: "#FFFFFF", fontWeight: "600", fontSize: 16 }}>Обиди се повторно</Text>
|
||
</Pressable>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
export default function RootLayout() {
|
||
const [token, setToken] = useState<string | null>(null);
|
||
const [userId, setUserId] = useState<string | null>(null);
|
||
const [loaded, setLoaded] = useState(false);
|
||
|
||
useEffect(() => {
|
||
Promise.all([
|
||
localStorage.getItem(TOKEN_KEY),
|
||
localStorage.getItem(USER_ID_KEY),
|
||
]).then(([t, u]) => {
|
||
if (t) setToken(t);
|
||
if (u) setUserId(u);
|
||
setLoaded(true);
|
||
});
|
||
}, []);
|
||
|
||
const login = useCallback(async (newToken: string, newUserId: string) => {
|
||
localStorage.setItem(TOKEN_KEY, newToken);
|
||
localStorage.setItem(USER_ID_KEY, newUserId);
|
||
setToken(newToken);
|
||
setUserId(newUserId);
|
||
}, []);
|
||
|
||
const logout = useCallback(async () => {
|
||
localStorage.removeItem(TOKEN_KEY);
|
||
localStorage.removeItem(USER_ID_KEY);
|
||
setToken(null);
|
||
setUserId(null);
|
||
}, []);
|
||
|
||
if (!loaded) return null;
|
||
|
||
return (
|
||
<ConvexProvider client={convex}>
|
||
<ThemeProvider>
|
||
<AuthContext.Provider
|
||
value={{ token, userId, isAuthenticated: !!token, login, logout }}
|
||
>
|
||
<ErrorBoundary>
|
||
<Stack screenOptions={{ headerShown: false }}>
|
||
<Stack.Screen name="(auth)" />
|
||
<Stack.Screen name="(tabs)" />
|
||
<Stack.Screen name="ad/[id]" options={{ presentation: "card" }} />
|
||
<Stack.Screen name="ad/create" options={{ presentation: "modal" }} />
|
||
<Stack.Screen name="post/[id]" options={{ presentation: "card" }} />
|
||
<Stack.Screen name="post/create" options={{ presentation: "modal" }} />
|
||
<Stack.Screen name="chat/[id]" options={{ presentation: "card" }} />
|
||
<Stack.Screen name="review/create" options={{ presentation: "modal" }} />
|
||
</Stack>
|
||
</ErrorBoundary>
|
||
</AuthContext.Provider>
|
||
</ThemeProvider>
|
||
</ConvexProvider>
|
||
);
|
||
} |