feat: Phase 1 - project setup, auth, and base UI

Set up Expo SDK 56 project with TypeScript and Expo Router (file-based).

Backend:
- Full Convex schema (users, accounts, sessions, ads, categories,
  reviews, chats, messages, posts) deployed to self-hosted instance
- Custom email/password auth with SHA-256 hashing via Convex actions
- Query/mutation scaffolds for all domain tables
- Convex environment variables configured on backend

App structure:
- Root layout with ConvexProvider, ThemeProvider, AuthContext
- (auth) group: login and register screens (Macedonian UI)
  with role selection (handyman/customer)
- (tabs) group: 5 tabs (Home, Explore, Posts, Chat, Profile)
  each with nested Stack layouts
- Placeholder detail screens for ad, post, chat, review flows
- 404 not-found screen in Macedonian

UI primitives (components/ui/):
- Button (4 variants), Input, Card, Loading, Avatar, Rating, Badge
- ThemeProvider with light/dark palette via React context

All user-facing strings in Macedonian. Code identifiers in English.
TypeScript passes with zero errors.
This commit is contained in:
echo 2026-05-29 18:18:58 +02:00
commit 5102ed23c1
56 changed files with 9826 additions and 0 deletions

45
.gitignore vendored Normal file
View File

@ -0,0 +1,45 @@
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
# Convex
convex/_generated/
# Env
.env.env.local

3
AGENTS.md Normal file
View File

@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v56.0.0/ before writing any code.

1
CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

34
app.json Normal file
View File

@ -0,0 +1,34 @@
{
"expo": {
"name": "МојМајстор",
"slug": "mojmajstor",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "mojmajstor",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "mk.mojmajstor.app"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"package": "mk.mojmajstor.app",
"softwareKeyboardLayoutMode": "resize"
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
"expo-image",
"expo-splash-screen"
]
}
}

10
app/(auth)/_layout.tsx Normal file
View File

@ -0,0 +1,10 @@
import { Stack } from "expo-router/stack";
export default function AuthLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="login" />
<Stack.Screen name="register" />
</Stack>
);
}

100
app/(auth)/login.tsx Normal file
View File

@ -0,0 +1,100 @@
import { useState } from "react";
import {
View,
Text,
KeyboardAvoidingView,
Platform,
ScrollView,
Alert,
} from "react-native";
import { useRouter } from "expo-router";
import { useMutation, useAction } from "convex/react";
import { api } from "../../convex/_generated/api";
import { Input } from "../../components/ui/input";
import { Button } from "../../components/ui/button";
import { useTheme } from "../../components/theme";
import { useAuth } from "../_layout";
export default function LoginScreen() {
const theme = useTheme();
const router = useRouter();
const { login } = useAuth();
const loginMutation = useMutation(api.auth.login);
const hashPassword = useAction(api.auth.hashPassword);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleLogin() {
if (!email || !password) {
Alert.alert("Грешка", "Пополнете ги сите полиња");
return;
}
setLoading(true);
try {
const passwordHash = await hashPassword({ password });
const result = await loginMutation({ email, passwordHash });
await login(result.token, result.user._id);
router.replace("/(tabs)/(home)");
} catch (e: any) {
Alert.alert("Најава неуспешна", "Погрешна е-пошта или лозинка");
} finally {
setLoading(false);
}
}
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1, backgroundColor: theme.colors.background }}
>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
keyboardShouldPersistTaps="handled"
>
<View style={{ marginTop: 60, marginBottom: theme.spacing.xl }}>
<Text style={{ fontSize: 32, fontWeight: "700", color: theme.colors.text }}>
МојМајстор
</Text>
<Text style={{ fontSize: 16, color: theme.colors.textSecondary, marginTop: theme.spacing.sm }}>
Најдете мајстор во вашата близина
</Text>
</View>
<Input
placeholder="Е-пошта"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<Input
placeholder="Лозинка"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<View style={{ marginTop: theme.spacing.sm }}>
<Button loading={loading} onPress={handleLogin}>
Најави се
</Button>
</View>
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.md }}>
<Text style={{ color: theme.colors.textSecondary }}>Немате профил? </Text>
<Text
style={{ color: theme.colors.primary, fontWeight: "600" }}
onPress={() => router.push("/(auth)/register")}
>
Регистрирајте се
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}

158
app/(auth)/register.tsx Normal file
View File

@ -0,0 +1,158 @@
import { useState } from "react";
import {
View,
Text,
KeyboardAvoidingView,
Platform,
ScrollView,
Alert,
Pressable,
} from "react-native";
import { useRouter } from "expo-router";
import { useMutation, useAction } from "convex/react";
import { api } from "../../convex/_generated/api";
import { Input } from "../../components/ui/input";
import { Button } from "../../components/ui/button";
import { useTheme } from "../../components/theme";
import { useAuth } from "../_layout";
type Role = "handyman" | "customer";
export default function RegisterScreen() {
const theme = useTheme();
const router = useRouter();
const { login } = useAuth();
const registerMutation = useMutation(api.auth.register);
const hashPassword = useAction(api.auth.hashPassword);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("customer");
const [loading, setLoading] = useState(false);
async function handleRegister() {
if (!name || !email || !password) {
Alert.alert("Грешка", "Пополнете ги сите задолжителни полиња");
return;
}
if (password.length < 8) {
Alert.alert("Грешка", "Лозинката мора да има најмалку 8 карактери");
return;
}
setLoading(true);
try {
const passwordHash = await hashPassword({ password });
const result = await registerMutation({
name,
email,
passwordHash,
role,
phone: phone || undefined,
});
await login(result.token, result.user!._id);
router.replace("/(tabs)/(home)");
} catch (e: any) {
Alert.alert("Регистрација неуспешна", e.message || "Обидете се повторно");
} finally {
setLoading(false);
}
}
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1, backgroundColor: theme.colors.background }}
>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
keyboardShouldPersistTaps="handled"
>
<View style={{ marginTop: 40, marginBottom: theme.spacing.md }}>
<Text style={{ fontSize: 28, fontWeight: "700", color: theme.colors.text }}>
Креирајте профил
</Text>
<Text style={{ fontSize: 16, color: theme.colors.textSecondary, marginTop: theme.spacing.sm }}>
Изберете ја вашата улога
</Text>
</View>
<View style={{ flexDirection: "row", gap: theme.spacing.md }}>
<Pressable
onPress={() => setRole("customer")}
style={{
flex: 1,
paddingVertical: theme.spacing.md,
paddingHorizontal: theme.spacing.sm,
borderRadius: theme.radius.md,
borderWidth: 2,
borderColor: role === "customer" ? theme.colors.primary : theme.colors.border,
backgroundColor: role === "customer" ? theme.colors.primaryLight : theme.colors.surface,
alignItems: "center",
gap: theme.spacing.sm,
}}
>
<Text style={{ fontSize: 32 }}>🏠</Text>
<Text style={{ fontWeight: "600", color: theme.colors.text }}>Клиент</Text>
<Text style={{ fontSize: 12, color: theme.colors.textSecondary, textAlign: "center" }}>
Барајте мајстори
</Text>
</Pressable>
<Pressable
onPress={() => setRole("handyman")}
style={{
flex: 1,
paddingVertical: theme.spacing.md,
paddingHorizontal: theme.spacing.sm,
borderRadius: theme.radius.md,
borderWidth: 2,
borderColor: role === "handyman" ? theme.colors.primary : theme.colors.border,
backgroundColor: role === "handyman" ? theme.colors.primaryLight : theme.colors.surface,
alignItems: "center",
gap: theme.spacing.sm,
}}
>
<Text style={{ fontSize: 32 }}>🔧</Text>
<Text style={{ fontWeight: "600", color: theme.colors.text }}>Мајстор</Text>
<Text style={{ fontSize: 12, color: theme.colors.textSecondary, textAlign: "center" }}>
Нудете услуги
</Text>
</Pressable>
</View>
<Input placeholder="Име и презиме" value={name} onChangeText={setName} />
<Input
placeholder="Е-пошта"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<Input
placeholder="Телефонски број (незадолжително)"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
/>
<Input placeholder="Лозинка (мин. 8 карактери)" value={password} onChangeText={setPassword} secureTextEntry />
<View style={{ marginTop: theme.spacing.sm }}>
<Button loading={loading} onPress={handleRegister}>
Регистрирај се
</Button>
</View>
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.md }}>
<Text style={{ color: theme.colors.textSecondary }}>Веќе имате профил? </Text>
<Text style={{ color: theme.colors.primary, fontWeight: "600" }} onPress={() => router.back()}>
Најавете се
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}

View File

@ -0,0 +1,9 @@
import { Stack } from "expo-router/stack";
export default function ChatStack() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: "Чат", headerLargeTitle: true }} />
</Stack>
);
}

View File

@ -0,0 +1,20 @@
import { View, Text, ScrollView } from "react-native";
import { useTheme } from "../../../components/theme";
export default function ChatScreen() {
const theme = useTheme();
return (
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: theme.spacing.lg }}>
<View style={{ alignItems: "center", justifyContent: "center", paddingVertical: 60 }}>
<Text style={{ fontSize: 48, marginBottom: 16 }}>💬</Text>
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginBottom: 8 }}>
Чатови
</Text>
<Text style={{ color: theme.colors.textSecondary, textAlign: "center" }}>
Започнете разговор со мајстор или клиент.
</Text>
</View>
</ScrollView>
);
}

View File

@ -0,0 +1,9 @@
import { Stack } from "expo-router/stack";
export default function ExploreStack() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: "Пребарување", headerLargeTitle: true }} />
</Stack>
);
}

View File

@ -0,0 +1,32 @@
import { View, Text, ScrollView } from "react-native";
import { useTheme } from "../../../components/theme";
import { CATEGORIES } from "../../../lib/constants";
export default function ExploreScreen() {
const theme = useTheme();
return (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: theme.spacing.sm }}>
{CATEGORIES.map((cat) => (
<View key={cat.slug} style={{
backgroundColor: theme.colors.surface,
borderRadius: theme.radius.md,
paddingHorizontal: theme.spacing.md,
paddingVertical: theme.spacing.md,
borderWidth: 1,
borderColor: theme.colors.border,
minWidth: "45%",
alignItems: "center",
}}>
<Text style={{ fontSize: 28, marginBottom: 4 }}>{cat.icon === "construct-outline" ? "🔧" : "📋"}</Text>
<Text style={{ color: theme.colors.text, fontSize: 14, fontWeight: "500" }}>{cat.name}</Text>
</View>
))}
</View>
</ScrollView>
);
}

View File

@ -0,0 +1,9 @@
import { Stack } from "expo-router/stack";
export default function HomeStack() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: "Почетна", headerLargeTitle: true }} />
</Stack>
);
}

View File

@ -0,0 +1,57 @@
import { View, Text, Pressable, ScrollView } from "react-native";
import { Link } from "expo-router";
import { useTheme } from "../../../components/theme";
import { CATEGORIES } from "../../../lib/constants";
export default function HomeScreen() {
const theme = useTheme();
return (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 40 }}
>
<View style={{ marginTop: theme.spacing.md }}>
<Text style={{ fontSize: 24, fontWeight: "700", color: theme.colors.text }}>
Добредојдовте 🏠
</Text>
<Text style={{ fontSize: 16, color: theme.colors.textSecondary, marginTop: theme.spacing.sm }}>
Најдете мајстор во вашата близина
</Text>
</View>
<Link href="/(tabs)/(explore)" asChild>
<Pressable style={{
backgroundColor: theme.colors.surface,
borderRadius: theme.radius.md,
padding: theme.spacing.md,
borderWidth: 1,
borderColor: theme.colors.border,
}}>
<Text style={{ color: theme.colors.textTertiary, fontSize: 16 }}>🔍 Пребарувај мајстори...</Text>
</Pressable>
</Link>
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginTop: theme.spacing.sm }}>
Категории
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: theme.spacing.sm }}>
{CATEGORIES.map((cat) => (
<Link key={cat.slug} href={`/(tabs)/(explore)?category=${cat.slug}`} asChild>
<Pressable style={{
backgroundColor: theme.colors.surface,
borderRadius: theme.radius.md,
paddingHorizontal: theme.spacing.md,
paddingVertical: theme.spacing.sm,
borderWidth: 1,
borderColor: theme.colors.border,
}}>
<Text style={{ color: theme.colors.text, fontSize: 14 }}>{cat.name}</Text>
</Pressable>
</Link>
))}
</View>
</ScrollView>
);
}

View File

@ -0,0 +1,9 @@
import { Stack } from "expo-router/stack";
export default function PostsStack() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: "Побарувања", headerLargeTitle: true }} />
</Stack>
);
}

View File

@ -0,0 +1,20 @@
import { View, Text, ScrollView } from "react-native";
import { useTheme } from "../../../components/theme";
export default function PostsScreen() {
const theme = useTheme();
return (
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: theme.spacing.lg }}>
<View style={{ alignItems: "center", justifyContent: "center", paddingVertical: 60 }}>
<Text style={{ fontSize: 48, marginBottom: 16 }}>📋</Text>
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginBottom: 8 }}>
Побарувања
</Text>
<Text style={{ color: theme.colors.textSecondary, textAlign: "center" }}>
Објавете што ви треба и најдете мајстор кој ќе ви помогне.
</Text>
</View>
</ScrollView>
);
}

View File

@ -0,0 +1,9 @@
import { Stack } from "expo-router/stack";
export default function ProfileStack() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: "Профил", headerLargeTitle: true }} />
</Stack>
);
}

View File

@ -0,0 +1,81 @@
import { View, Text, Pressable, ScrollView } from "react-native";
import { useRouter } from "expo-router";
import { useQuery, useMutation } from "convex/react";
import { api } from "../../../convex/_generated/api";
import { useAuth } from "../../_layout";
import { useTheme } from "../../../components/theme";
import { Button } from "../../../components/ui/button";
export default function ProfileScreen() {
const router = useRouter();
const theme = useTheme();
const { token, isAuthenticated, logout } = useAuth();
const user = useQuery(api.users.getCurrentUser, isAuthenticated ? { token: token! } : "skip");
const logoutMutation = useMutation(api.auth.logout);
async function handleLogout() {
if (token) {
try {
await logoutMutation({ token });
} catch {}
}
await logout();
router.replace("/(auth)/login");
}
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 style={{ color: theme.colors.textSecondary, textAlign: "center", marginBottom: theme.spacing.lg }}>
За да ги видите вашиот профил и податоци.
</Text>
<Button onPress={() => router.push("/(auth)/login")}>Најави се</Button>
</View>
);
}
return (
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}>
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
<View style={{
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: theme.colors.primaryLight,
alignItems: "center",
justifyContent: "center",
}}>
<Text style={{ color: theme.colors.primary, fontWeight: "700", fontSize: 28 }}>
{user?.name?.charAt(0)?.toUpperCase() || "?"}
</Text>
</View>
<Text style={{ fontSize: 20, fontWeight: "700", color: theme.colors.text, marginTop: theme.spacing.md }}>
{user?.name || "Корисник"}
</Text>
<Text style={{ color: theme.colors.textSecondary }}>
{user?.role === "handyman" ? "Мајстор" : "Клиент"}
</Text>
</View>
<View style={{ backgroundColor: theme.colors.card, borderRadius: theme.radius.lg, padding: theme.spacing.md, gap: theme.spacing.sm }}>
<Pressable style={{ paddingVertical: theme.spacing.sm }}>
<Text style={{ color: theme.colors.text, fontSize: 16 }}>Мои огласи</Text>
</Pressable>
<Pressable style={{ paddingVertical: theme.spacing.sm }}>
<Text style={{ color: theme.colors.text, fontSize: 16 }}>Оцени</Text>
</Pressable>
<Pressable style={{ paddingVertical: theme.spacing.sm }}>
<Text style={{ color: theme.colors.text, fontSize: 16 }}>Поставки</Text>
</Pressable>
</View>
<Button variant="destructive" onPress={handleLogout}>
Одјави се
</Button>
</ScrollView>
);
}

57
app/(tabs)/_layout.tsx Normal file
View File

@ -0,0 +1,57 @@
import { Tabs } from "expo-router";
import { useTheme } from "../../components/theme";
export default function TabLayout() {
const theme = useTheme();
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: theme.colors.primary,
tabBarInactiveTintColor: theme.colors.textTertiary,
tabBarStyle: {
backgroundColor: theme.colors.card,
borderTopColor: theme.colors.border,
},
tabBarLabelStyle: { fontSize: 11 },
}}
>
<Tabs.Screen
name="(home)"
options={{
title: "Почетна",
tabBarIcon: ({ color, size }) => undefined,
}}
/>
<Tabs.Screen
name="(explore)"
options={{
title: "Пребарување",
tabBarIcon: ({ color, size }) => undefined,
}}
/>
<Tabs.Screen
name="(posts)"
options={{
title: "Побарувања",
tabBarIcon: ({ color, size }) => undefined,
}}
/>
<Tabs.Screen
name="(chat)"
options={{
title: "Чат",
tabBarIcon: ({ color, size }) => undefined,
}}
/>
<Tabs.Screen
name="(profile)"
options={{
title: "Профил",
tabBarIcon: ({ color, size }) => undefined,
}}
/>
</Tabs>
);
}

21
app/+not-found.tsx Normal file
View File

@ -0,0 +1,21 @@
import { View, Text } from "react-native";
import { Link } from "expo-router";
import { useTheme } from "../components/theme";
export default function NotFound() {
const theme = useTheme();
return (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: theme.colors.background, padding: theme.spacing.lg }}>
<Text style={{ fontSize: 24, fontWeight: "700", color: theme.colors.text, marginBottom: theme.spacing.sm }}>
Страницата не е пронајдена
</Text>
<Text style={{ color: theme.colors.textSecondary, marginBottom: theme.spacing.lg, textAlign: "center" }}>
Страницата што ја барате не постои.
</Text>
<Link href="/" style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 16 }}>
Назад кон почетна
</Link>
</View>
);
}

83
app/_layout.tsx Normal file
View File

@ -0,0 +1,83 @@
import { Stack } from "expo-router/stack";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { useState, createContext, useContext, useEffect, useCallback } from "react";
import { ThemeProvider } from "../components/theme";
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";
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 }}
>
<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>
</AuthContext.Provider>
</ThemeProvider>
</ConvexProvider>
);
}

22
app/ad/[id].tsx Normal file
View File

@ -0,0 +1,22 @@
import { View, Text, ScrollView } from "react-native";
import { useLocalSearchParams, Stack } from "expo-router";
import { useTheme } from "../../components/theme";
export default function AdDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const theme = useTheme();
return (
<>
<Stack.Screen options={{ title: "Оглас", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: 16 }}>
<View style={{ alignItems: "center", paddingVertical: 60 }}>
<Text style={{ fontSize: 48, marginBottom: 16 }}>📋</Text>
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text }}>
Оглас {id}
</Text>
</View>
</ScrollView>
</>
);
}

20
app/ad/create.tsx Normal file
View File

@ -0,0 +1,20 @@
import { View, Text, ScrollView } from "react-native";
import { Stack } from "expo-router";
import { useTheme } from "../../components/theme";
export default function CreateAdScreen() {
const theme = useTheme();
return (
<>
<Stack.Screen options={{ title: "Нов оглас", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: 16 }}>
<View style={{ alignItems: "center", paddingVertical: 60 }}>
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text }}>
Креирање оглас доаѓа во Фаза 3
</Text>
</View>
</ScrollView>
</>
);
}

22
app/chat/[id].tsx Normal file
View File

@ -0,0 +1,22 @@
import { View, Text, ScrollView } from "react-native";
import { useLocalSearchParams, Stack } from "expo-router";
import { useTheme } from "../../components/theme";
export default function ChatDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const theme = useTheme();
return (
<>
<Stack.Screen options={{ title: "Разговор", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: 16 }}>
<View style={{ alignItems: "center", paddingVertical: 60 }}>
<Text style={{ fontSize: 48, marginBottom: 16 }}>💬</Text>
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text }}>
Чат {id} доаѓа во Фаза 5
</Text>
</View>
</ScrollView>
</>
);
}

22
app/post/[id].tsx Normal file
View File

@ -0,0 +1,22 @@
import { View, Text, ScrollView } from "react-native";
import { useLocalSearchParams, Stack } from "expo-router";
import { useTheme } from "../../components/theme";
export default function PostDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const theme = useTheme();
return (
<>
<Stack.Screen options={{ title: "Побарување", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: 16 }}>
<View style={{ alignItems: "center", paddingVertical: 60 }}>
<Text style={{ fontSize: 48, marginBottom: 16 }}>📋</Text>
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text }}>
Побарување {id}
</Text>
</View>
</ScrollView>
</>
);
}

20
app/post/create.tsx Normal file
View File

@ -0,0 +1,20 @@
import { View, Text, ScrollView } from "react-native";
import { Stack } from "expo-router";
import { useTheme } from "../../components/theme";
export default function CreatePostScreen() {
const theme = useTheme();
return (
<>
<Stack.Screen options={{ title: "Ново побарување", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: 16 }}>
<View style={{ alignItems: "center", paddingVertical: 60 }}>
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text }}>
Креирање побарување доаѓа во Фаза 4
</Text>
</View>
</ScrollView>
</>
);
}

21
app/review/create.tsx Normal file
View File

@ -0,0 +1,21 @@
import { View, Text, ScrollView } from "react-native";
import { Stack } from "expo-router";
import { useTheme } from "../../components/theme";
export default function CreateReviewScreen() {
const theme = useTheme();
return (
<>
<Stack.Screen options={{ title: "Оценка", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: 16 }}>
<View style={{ alignItems: "center", paddingVertical: 60 }}>
<Text style={{ fontSize: 48, marginBottom: 16 }}></Text>
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text }}>
Оценка доаѓа во Фаза 6
</Text>
</View>
</ScrollView>
</>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

BIN
assets/splash-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

89
components/theme.tsx Normal file
View File

@ -0,0 +1,89 @@
import { createContext, useContext } from "react";
import { useColorScheme } from "react-native";
type ThemeColors = {
primary: string;
primaryLight: string;
secondary: string;
background: string;
surface: string;
card: string;
border: string;
text: string;
textSecondary: string;
textTertiary: string;
error: string;
errorLight: string;
star: string;
};
type Theme = {
colors: ThemeColors;
spacing: {
xs: number;
sm: number;
md: number;
lg: number;
xl: number;
};
radius: {
sm: number;
md: number;
lg: number;
xl: number;
};
};
const LightTheme: Theme = {
colors: {
primary: "#2563EB",
primaryLight: "#DBEAFE",
secondary: "#059669",
background: "#FFFFFF",
surface: "#F9FAFB",
card: "#FFFFFF",
border: "#E5E7EB",
text: "#111827",
textSecondary: "#6B7280",
textTertiary: "#9CA3AF",
error: "#DC2626",
errorLight: "#FEE2E2",
star: "#F59E0B",
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
radius: { sm: 6, md: 12, lg: 16, xl: 24 },
};
const DarkTheme: Theme = {
colors: {
primary: "#60A5FA",
primaryLight: "#1E3A5F",
secondary: "#34D399",
background: "#111827",
surface: "#1F2937",
card: "#1F2937",
border: "#374151",
text: "#F9FAFB",
textSecondary: "#9CA3AF",
textTertiary: "#6B7280",
error: "#F87171",
errorLight: "#451111",
star: "#FBBF24",
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
radius: { sm: 6, md: 12, lg: 16, xl: 24 },
};
export type { Theme };
const ThemeContext = createContext<Theme>(LightTheme);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const scheme = useColorScheme();
const theme = scheme === "dark" ? DarkTheme : LightTheme;
return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
return useContext(ThemeContext);
}

46
components/ui/avatar.tsx Normal file
View File

@ -0,0 +1,46 @@
import { View, Text } from "react-native";
import { Image, type ImageContentFit } from "expo-image";
import { useTheme } from "../theme";
interface AvatarProps {
uri?: string | null;
name: string;
size?: number;
}
export function Avatar({ uri, name, size = 44 }: AvatarProps) {
const theme = useTheme();
const initials = name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2);
if (uri) {
return (
<Image
source={uri}
style={{ width: size, height: size, borderRadius: size / 2 }}
contentFit="cover"
/>
);
}
return (
<View
style={{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: theme.colors.primaryLight,
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: size * 0.38 }}>
{initials}
</Text>
</View>
);
}

26
components/ui/badge.tsx Normal file
View File

@ -0,0 +1,26 @@
import { View, Text } from "react-native";
import { useTheme } from "../theme";
interface BadgeProps {
label: string;
variant?: "default" | "primary" | "success" | "error";
}
export function Badge({ label, variant = "default" }: BadgeProps) {
const theme = useTheme();
const variants = {
default: { bg: theme.colors.surface, text: theme.colors.textSecondary },
primary: { bg: theme.colors.primaryLight, text: theme.colors.primary },
success: { bg: "#D1FAE5", text: "#059669" },
error: { bg: theme.colors.errorLight, text: theme.colors.error },
};
const { bg, text: textColor } = variants[variant];
return (
<View style={{ backgroundColor: bg, paddingHorizontal: 8, paddingVertical: 4, borderRadius: theme.radius.sm }}>
<Text style={{ color: textColor, fontSize: 12, fontWeight: "600" }}>{label}</Text>
</View>
);
}

70
components/ui/button.tsx Normal file
View File

@ -0,0 +1,70 @@
import { Pressable, Text, ActivityIndicator, type ViewStyle } from "react-native";
import { useTheme } from "../theme";
type Variant = "primary" | "secondary" | "outline" | "destructive";
interface ButtonProps {
variant?: Variant;
loading?: boolean;
size?: "sm" | "md" | "lg";
disabled?: boolean;
onPress?: () => void;
children: React.ReactNode;
style?: ViewStyle;
}
export function Button({
variant = "primary",
loading = false,
size = "md",
disabled = false,
children,
style,
onPress,
}: ButtonProps) {
const theme = useTheme();
const sizeStyles: Record<string, ViewStyle> = {
sm: { paddingVertical: theme.spacing.sm, paddingHorizontal: theme.spacing.md, borderRadius: theme.radius.sm },
md: { paddingVertical: 12, paddingHorizontal: theme.spacing.lg, borderRadius: theme.radius.md },
lg: { paddingVertical: 16, paddingHorizontal: theme.spacing.xl, borderRadius: theme.radius.md },
};
const variantStyles: Record<Variant, ViewStyle> = {
primary: { backgroundColor: theme.colors.primary },
secondary: { backgroundColor: theme.colors.secondary },
outline: { backgroundColor: "transparent", borderWidth: 1, borderColor: theme.colors.primary },
destructive: { backgroundColor: theme.colors.error },
};
const textColors: Record<Variant, string> = {
primary: "#FFFFFF",
secondary: "#FFFFFF",
outline: theme.colors.primary,
destructive: "#FFFFFF",
};
return (
<Pressable
disabled={disabled || loading}
onPress={onPress}
style={[
{ alignItems: "center", justifyContent: "center" },
sizeStyles[size],
variantStyles[variant],
disabled && { opacity: 0.5 },
style,
]}
>
{loading ? (
<ActivityIndicator color={variant === "outline" ? theme.colors.primary : "#FFFFFF"} size="small" />
) : typeof children === "string" ? (
<Text style={{ color: textColors[variant], fontWeight: "600", fontSize: size === "sm" ? 14 : 16 }}>
{children}
</Text>
) : (
children
)}
</Pressable>
);
}

28
components/ui/card.tsx Normal file
View File

@ -0,0 +1,28 @@
import { View, type ViewStyle } from "react-native";
import { useTheme } from "../theme";
interface CardProps {
children: React.ReactNode;
style?: ViewStyle;
onPress?: () => void;
}
export function Card({ children, style, onPress }: CardProps) {
const theme = useTheme();
return (
<View
style={[
{
backgroundColor: theme.colors.card,
borderRadius: theme.radius.lg,
padding: theme.spacing.md,
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.08)",
},
style,
]}
>
{children}
</View>
);
}

31
components/ui/input.tsx Normal file
View File

@ -0,0 +1,31 @@
import { TextInput, type TextInputProps, type ViewStyle } from "react-native";
import { useTheme } from "../theme";
interface InputProps extends TextInputProps {
error?: string;
}
export function Input({ error, style, ...props }: InputProps) {
const theme = useTheme();
return (
<TextInput
placeholderTextColor={theme.colors.textTertiary}
style={[
{
backgroundColor: theme.colors.surface,
borderColor: error ? theme.colors.error : theme.colors.border,
borderWidth: 1,
borderRadius: theme.radius.md,
paddingVertical: 12,
paddingHorizontal: theme.spacing.md,
fontSize: 16,
color: theme.colors.text,
},
error && { borderColor: theme.colors.error },
typeof style === "object" ? style : undefined,
]}
{...props}
/>
);
}

16
components/ui/loading.tsx Normal file
View File

@ -0,0 +1,16 @@
import { View, ActivityIndicator } from "react-native";
import { useTheme } from "../theme";
interface LoadingProps {
fullScreen?: boolean;
}
export function Loading({ fullScreen = false }: LoadingProps) {
const theme = useTheme();
return (
<View style={[{ alignItems: "center", justifyContent: "center" }, fullScreen && { flex: 1 }]}>
<ActivityIndicator size="large" color={theme.colors.primary} />
</View>
);
}

24
components/ui/rating.tsx Normal file
View File

@ -0,0 +1,24 @@
import { View, Text } from "react-native";
import { useTheme } from "../theme";
interface RatingProps {
value: number;
count?: number;
size?: number;
}
export function Rating({ value, count, size = 16 }: RatingProps) {
const theme = useTheme();
return (
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
<Text style={{ color: theme.colors.star, fontSize: size }}></Text>
<Text style={{ color: theme.colors.textSecondary, fontSize: 14, fontVariant: ["tabular-nums"] }}>
{value.toFixed(1)}
</Text>
{count !== undefined && (
<Text style={{ color: theme.colors.textTertiary, fontSize: 12 }}>({count})</Text>
)}
</View>
);
}

33
convex/ads.ts Normal file
View File

@ -0,0 +1,33 @@
import { query } from "./_generated/server";
import { v } from "convex/values";
export const search = query({
args: { query: v.string(), category: v.optional(v.string()) },
handler: async (ctx, args) => {
if (args.category) {
return await ctx.db
.query("ads")
.withIndex("by_category", (q) => q.eq("category", args.category!))
.order("desc")
.collect();
}
return await ctx.db.query("ads").order("desc").take(50);
},
});
export const getByHandyman = query({
args: { handymanId: v.id("users") },
handler: async (ctx, args) => {
return await ctx.db
.query("ads")
.withIndex("by_handyman", (q) => q.eq("handymanId", args.handymanId))
.collect();
},
});
export const getById = query({
args: { id: v.id("ads") },
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
},
});

118
convex/auth.ts Normal file
View File

@ -0,0 +1,118 @@
import { v } from "convex/values";
import { query, mutation, action } from "./_generated/server";
export const getCurrentUser = query({
args: { token: v.optional(v.string()) },
handler: async (ctx, args) => {
if (!args.token) return null;
const session = await ctx.db
.query("sessions")
.withIndex("by_token", (q) => q.eq("token", args.token!))
.first();
if (!session) return null;
const user = await ctx.db.get(session.userId);
return user || null;
},
});
export const login = mutation({
args: { email: v.string(), passwordHash: v.string() },
handler: async (ctx, args) => {
const account = await ctx.db
.query("accounts")
.withIndex("by_provider_email", (q) =>
q.eq("provider", "password").eq("providerId", args.email)
)
.first();
if (!account) throw new Error("Invalid credentials");
const user = await ctx.db.get(account.userId);
if (!user) throw new Error("User not found");
if (account.secret !== args.passwordHash) throw new Error("Invalid credentials");
const token = crypto.randomUUID();
await ctx.db.insert("sessions", {
userId: user._id,
token,
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
createdAt: Date.now(),
});
return { token, user };
},
});
export const register = mutation({
args: {
name: v.string(),
email: v.string(),
passwordHash: v.string(),
role: v.string(),
phone: v.optional(v.string()),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("accounts")
.withIndex("by_provider_email", (q) =>
q.eq("provider", "password").eq("providerId", args.email)
)
.first();
if (existing) throw new Error("Email already registered");
const userId = await ctx.db.insert("users", {
name: args.name,
email: args.email,
phone: args.phone,
role: args.role,
reviewCount: 0,
createdAt: Date.now(),
});
await ctx.db.insert("accounts", {
userId,
provider: "password",
providerId: args.email,
secret: args.passwordHash,
createdAt: Date.now(),
});
const token = crypto.randomUUID();
await ctx.db.insert("sessions", {
userId,
token,
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
createdAt: Date.now(),
});
const user = await ctx.db.get(userId);
return { token, user };
},
});
export const logout = mutation({
args: { token: v.string() },
handler: async (ctx, args) => {
const session = await ctx.db
.query("sessions")
.withIndex("by_token", (q) => q.eq("token", args.token))
.first();
if (session) {
await ctx.db.delete(session._id);
}
},
});
export const hashPassword = action({
args: { password: v.string() },
handler: async (_ctx, args) => {
const encoder = new TextEncoder();
const data = encoder.encode(args.password);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
},
});

8
convex/categories.ts Normal file
View File

@ -0,0 +1,8 @@
import { query } from "./_generated/server";
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("categories").withIndex("by_slug").order("asc").collect();
},
});

10
convex/chats.ts Normal file
View File

@ -0,0 +1,10 @@
import { query } from "./_generated/server";
import { v } from "convex/values";
export const listByUser = query({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
const allChats = await ctx.db.query("chats").order("desc").collect();
return allChats.filter((chat) => chat.participantIds.includes(args.userId));
},
});

13
convex/messages.ts Normal file
View File

@ -0,0 +1,13 @@
import { query } from "./_generated/server";
import { v } from "convex/values";
export const listByChat = query({
args: { chatId: v.id("chats") },
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.withIndex("by_chat", (q) => q.eq("chatId", args.chatId))
.order("desc")
.collect();
},
});

26
convex/posts.ts Normal file
View File

@ -0,0 +1,26 @@
import { query } from "./_generated/server";
import { v } from "convex/values";
export const list = query({
args: { status: v.optional(v.union(v.literal("open"), v.literal("closed"))) },
handler: async (ctx, args) => {
if (args.status) {
return await ctx.db
.query("posts")
.withIndex("by_status", (q) => q.eq("status", args.status!))
.order("desc")
.collect();
}
return await ctx.db.query("posts").order("desc").collect();
},
});
export const getByCustomer = query({
args: { customerId: v.id("users") },
handler: async (ctx, args) => {
return await ctx.db
.query("posts")
.withIndex("by_customer", (q) => q.eq("customerId", args.customerId))
.collect();
},
});

24
convex/reviews.ts Normal file
View File

@ -0,0 +1,24 @@
import { query } from "./_generated/server";
import { v } from "convex/values";
export const getByAd = query({
args: { adId: v.id("ads") },
handler: async (ctx, args) => {
return await ctx.db
.query("reviews")
.withIndex("by_ad", (q) => q.eq("adId", args.adId))
.order("desc")
.collect();
},
});
export const getByHandyman = query({
args: { handymanId: v.id("users") },
handler: async (ctx, args) => {
return await ctx.db
.query("reviews")
.withIndex("by_handyman", (q) => q.eq("handymanId", args.handymanId))
.order("desc")
.collect();
},
});

94
convex/schema.ts Normal file
View File

@ -0,0 +1,94 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
name: v.string(),
phone: v.optional(v.string()),
email: v.optional(v.string()),
role: v.string(),
avatarId: v.optional(v.string()),
reviewCount: v.number(),
createdAt: v.number(),
}).index("by_email", ["email"]),
accounts: defineTable({
userId: v.id("users"),
provider: v.string(),
providerId: v.string(),
secret: v.string(),
createdAt: v.number(),
}).index("by_provider_email", ["provider", "providerId"]),
sessions: defineTable({
userId: v.id("users"),
token: v.string(),
expiresAt: v.number(),
createdAt: v.number(),
}).index("by_token", ["token"]),
ads: defineTable({
handymanId: v.id("users"),
title: v.string(),
description: v.string(),
category: v.string(),
location: v.string(),
lat: v.optional(v.number()),
lng: v.optional(v.number()),
priceRange: v.optional(v.string()),
imageIds: v.optional(v.array(v.string())),
availability: v.optional(v.string()),
ratingAvg: v.optional(v.number()),
reviewCount: v.number(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_handyman", ["handymanId"])
.index("by_category", ["category"]),
categories: defineTable({
name: v.string(),
slug: v.string(),
icon: v.optional(v.string()),
sortOrder: v.number(),
}).index("by_slug", ["slug"]),
reviews: defineTable({
adId: v.id("ads"),
customerId: v.id("users"),
handymanId: v.id("users"),
rating: v.number(),
comment: v.optional(v.string()),
createdAt: v.number(),
})
.index("by_ad", ["adId"])
.index("by_handyman", ["handymanId"]),
chats: defineTable({
participantIds: v.array(v.id("users")),
lastMessageAt: v.number(),
createdBy: v.id("users"),
}).index("by_participant", ["participantIds"]),
messages: defineTable({
chatId: v.id("chats"),
senderId: v.id("users"),
content: v.string(),
imageId: v.optional(v.string()),
createdAt: v.number(),
}).index("by_chat", ["chatId", "createdAt"]),
posts: defineTable({
customerId: v.id("users"),
title: v.string(),
description: v.string(),
category: v.optional(v.string()),
location: v.optional(v.string()),
budget: v.optional(v.string()),
status: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_customer", ["customerId"])
.index("by_status", ["status"]),
});

23
convex/users.ts Normal file
View File

@ -0,0 +1,23 @@
import { v } from "convex/values";
import { query } from "./_generated/server";
export const getCurrentUser = query({
args: { token: v.string() },
handler: async (ctx, args) => {
const session = await ctx.db
.query("sessions")
.withIndex("by_token", (q) => q.eq("token", args.token))
.first();
if (!session) return null;
const user = await ctx.db.get(session.userId);
return user;
},
});
export const getById = query({
args: { id: v.id("users") },
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
},
});

6
desc.md Normal file
View File

@ -0,0 +1,6 @@
We are building an expo react native app that connects local handymen with costumers.
As a backend we will use self deployed convex instance.
Handymen can post adds describing their skills, location and availability.
Costumers can search for handymen, post about their needs and leave a review.
We will have in app chat where costumers can chat with handymen...
We will build app in macedonian language..

256
implementation.md Normal file
View File

@ -0,0 +1,256 @@
# МојМајстор - Implementation Plan
## Overview
МојМајстор (My Handyman) is a mobile app connecting local handymen with customers in Macedonia. Built with Expo React Native and a self-deployed Convex backend, all UI text in Macedonian.
---
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Frontend | Expo SDK 52+, React Native, TypeScript |
| Navigation | Expo Router (file-based) |
| Backend | Convex (self-hosted) |
| Auth | Convex Auth (email/password + phone) |
| Styling | NativeWind (Tailwind for RN) |
| Chat | Convex real-time queries |
| Maps | react-native-maps |
| Image Upload | Convex file storage + expo-image-picker |
| Deployment | EAS Build + Update |
---
## Data Model (Convex Schema)
### users
- `_id`, `name`, `phone`, `email`, `role` ("handyman" | "customer"), `avatarId?`, `createdAt`
### ads
- `_id`, `handymanId` (ref users), `title`, `description`, `category`, `location`, `lat?`, `lng?`, `priceRange?`, `imageIds[]`, `availability`, `ratingAvg?`, `reviewCount`, `createdAt`, `updatedAt`
### categories
- `_id`, `name` (Macedonian), `slug`, `icon?`, `sortOrder`
### reviews
- `_id`, `adId` (ref ads), `customerId` (ref users), `handymanId` (ref users), `rating` (1-5), `comment`, `createdAt`
### chats
- `_id`, `participantIds[]` (ref users), `lastMessageAt`, `createdBy`
### messages
- `_id`, `chatId` (ref chats), `senderId` (ref users), `content`, `imageId?`, `createdAt`
### posts (customer needs)
- `_id`, `customerId` (ref users), `title`, `description`, `category`, `location`, `budget?`, `status` ("open" | "closed"), `createdAt`, `updatedAt`
---
## Project Structure
```
mojmajstor/
├── app/ # Expo Router pages
│ ├── _layout.tsx # Root layout + auth state
│ ├── (auth)/
│ │ ├── _layout.tsx
│ │ ├── login.tsx
│ │ └── register.tsx
│ ├── (tabs)/
│ │ ├── _layout.tsx # Bottom tab navigator
│ │ ├── index.tsx # Home / search
│ │ ├── explore.tsx # Browse categories & ads
│ │ ├── posts.tsx # Customer needs feed
│ │ ├── chat.tsx # Chat list
│ │ └── profile.tsx # User profile
│ ├── ad/
│ │ ├── [id].tsx # Ad detail
│ │ └── create.tsx # Create ad (handyman)
│ ├── post/
│ │ ├── [id].tsx # Post detail
│ │ └── create.tsx # Create post (customer)
│ ├── chat/
│ │ └── [id].tsx # Chat conversation
│ └── review/
│ └── create.tsx # Leave a review
├── components/
│ ├── ui/ # Reusable UI primitives
│ │ ├── Button.tsx
│ │ ├── Card.tsx
│ │ ├── Input.tsx
│ │ ├── Rating.tsx
│ │ ├── Avatar.tsx
│ │ ├── Badge.tsx
│ │ └── Loading.tsx
│ ├── AdCard.tsx
│ ├── PostCard.tsx
│ ├── ChatListItem.tsx
│ ├── MessageBubble.tsx
│ ├── CategoryGrid.tsx
│ ├── SearchBar.tsx
│ ├── LocationPicker.tsx
│ └── ReviewCard.tsx
├── convex/ # Backend
│ ├── _generated/
│ ├── schema.ts
│ ├── auth.config.ts
│ ├── users.ts
│ ├── ads.ts
│ ├── categories.ts
│ ├── reviews.ts
│ ├── chats.ts
│ ├── messages.ts
│ └── posts.ts
├── lib/
│ ├── constants.ts # Colors, spacing, Macedonian strings
│ ├── hooks.ts # Custom hooks
│ └── utils.ts
├── assets/
│ └── images/
├── app.json
├── package.json
├── tailwind.config.js
├── tsconfig.json
└── eas.json
```
---
## Implementation Phases
### Phase 1: Project Setup & Auth (Week 1)
**Goal:** Bootable app with authentication.
1. Initialize Expo project with TypeScript template
2. Install dependencies: `expo-router`, `nativewind`, `convex`, `@convex-dev/auth`, `expo-image-picker`, `react-native-maps`, `expo-location`
3. Configure NativeWind (Tailwind) + constants (colors, Macedonian strings)
4. Set up Convex project & self-deploy
5. Define `schema.ts` with all tables
6. Configure Convex Auth (email/password + phone)
7. Build root `_layout.tsx` with auth state observer
8. Build `(auth)/login.tsx` - email/phone login
9. Build `(auth)/register.tsx` - role selection (handyman/customer) + profile creation
10. Build UI primitives: Button, Input, Card, Loading
**Deliverable:** User can sign up, log in, see role-specific tab layout.
---
### Phase 2: Home & Categories (Week 2)
**Goal:** Browse and discover handymen.
1. Seed `categories` table (Мајстор за сѐ, Водоинсталатер, Електричар, Тескар, Фарбар, Керамичар, Зидар, Градежник, столар, Молер, Електричар, Клима монтажер, etc.)
2. Build `(tabs)/_layout.tsx` - bottom nav (Почетна, Пребарување, Огласи, Чат, Профил)
3. Build `(tabs)/index.tsx` - home screen with hero search + category grid + featured ads
4. Build `CategoryGrid` component with icons per category
5. Build `SearchBar` component with text + location filter
6. Build `(tabs)/explore.tsx` - category list → filtered ad list
7. Build `AdCard` component (image, title, rating, location, price range)
8. Create `ads.ts` Convex queries: `list`, `getByCategory`, `search`, `getByHandyman`
9. Wire up real data to ad listings
**Deliverable:** Customer can browse categories and see handyman ads.
---
### Phase 3: Ad Creation & Detail (Week 3)
**Goal:** Handymen can post and manage ads.
1. Build `ad/create.tsx` - multi-step form:
- Step 1: Category selection
- Step 2: Title, description, price range
- Step 3: Location (map picker or text)
- Step 4: Availability schedule
- Step 5: Photo upload (up to 5)
2. Build `ad/[id].tsx` - ad detail page:
- Gallery, description, location map, availability, rating, reviews
- "Започни разговор" (Start chat) button
- "Напиши оценка" (Write review) button
3. Create `ads.ts` mutations: `create`, `update`, `delete`
4. Handle image upload via Convex file storage + `expo-image-picker`
5. Build `profile.tsx` - handyman view with their ads list, edit/delete
**Deliverable:** Handymen can create, view, edit, delete their ads. Customers can view full details.
---
### Phase 4: Customer Posts (Week 4)
**Goal:** Customers can post their needs; handymen can respond.
1. Build `post/create.tsx` - customer need form (title, description, category, location, budget, urgency)
2. Build `post/[id].tsx` - post detail with responses
3. Build `(tabs)/posts.tsx` - feed of open customer posts
4. Create `posts.ts` Convex queries & mutations: `create`, `list`, `getByCustomer`, `close`
5. Add "Одговори на оглас" (Respond to post) flow for handymen → starts a chat
**Deliverable:** Customers post needs, handymen browse and respond.
---
### Phase 5: In-App Chat (Week 5)
**Goal:** Real-time messaging between customer and handyman.
1. Build `(tabs)/chat.tsx` - chat list with last message preview, unread indicator
2. Build `chat/[id].tsx` - full conversation screen with:
- Message bubbles (text + optional image)
- Input bar with send button + image picker
- Real-time updates via Convex `onUpdate`
3. Create `chats.ts` Convex queries & mutations: `create`, `listByUser`
4. Create `messages.ts` Convex queries & mutations: `send`, `listByChat` (paginated)
5. Add push notification support via Expo Notifications
6. Add "Започни разговор" button on ad detail → creates or opens existing chat
**Deliverable:** Users can chat in real-time with image support.
---
### Phase 6: Reviews & Ratings (Week 6)
**Goal:** Customer can rate and review handymen.
1. Build `review/create.tsx` - star rating + comment form
2. Build `ReviewCard` component
3. Add review summary to `AdCard` and ad detail page
4. Create `reviews.ts` Convex queries & mutations: `create`, `getByAd`, `getByHandyman`
5. Update `ads` table: compute `ratingAvg` and `reviewCount` on review creation (Convex trigger)
6. Sort ads by rating in search/explore
**Deliverable:** Customers leave reviews; ratings visible on ads.
---
### Phase 7: Polish & Production (Week 7)
**Goal:** Production-ready app.
1. Full Macedonian localization (all strings in `lib/constants.ts`)
2. Error handling & loading states on every screen
3. Pull-to-refresh on list screens
4. Pagination on ad feeds and chat messages
5. Offline-first considerations (Convex optimistic updates)
6. Deep linking configuration
7. App icon & splash screen (Macedonian-themed)
8. EAS Build configuration for iOS + Android
9. App store metadata (Македонски)
10. Performance audit (list virtualization, image optimization)
11. Accessibility pass (labels, contrast, font scaling)
**Deliverable:** Published app on App Store & Google Play.
---
## Key Decisions & Notes
- **Language:** All user-facing text is in Macedonian (Македонски). Code variables and comments in English.
- **Role system:** Determined at registration. Handymen see ad creation; customers see post creation. Both can chat.
- **Maps:** Use `react-native-maps` with OpenStreetMap tiles (no Google API key needed for MVP).
- **Convex self-deploy:** Deploy via Docker on own infra for full data sovereignty.
- **Auth strategy:** Start with email/password, add phone OTP via Twilio in Phase 7.
- **Image optimization:** Compress before upload using `expo-image-manipulator`. Max 5 images per ad.
- **Search:** Convex full-text search on ad title/description. Location filter via lat/lng bounding box.

16
lib/constants.ts Normal file
View File

@ -0,0 +1,16 @@
export const CATEGORIES = [
{ slug: "majstor-za-se", name: "Мајстор за сѐ", icon: "construct-outline", sortOrder: 0 },
{ slug: "vodoinstalater", name: "Водоинсталатер", icon: "water-outline", sortOrder: 1 },
{ slug: "elektrichar", name: "Електричар", icon: "flash-outline", sortOrder: 2 },
{ slug: "teskar", name: "Тескар", icon: "hammer-outline", sortOrder: 3 },
{ slug: "farbar", name: "Фарбар", icon: "brush-outline", sortOrder: 4 },
{ slug: "keramichar", name: "Керамичар", icon: "grid-outline", sortOrder: 5 },
{ slug: "zidar", name: "Зидар", icon: "business-outline", sortOrder: 6 },
{ slug: "gradezhnik", name: "Градежник", icon: "build-outline", sortOrder: 7 },
{ slug: "stolar", name: "Столар", icon: "cube-outline", sortOrder: 8 },
{ slug: "moler", name: "Молер", icon: "color-palette-outline", sortOrder: 9 },
{ slug: "klima-montazher", name: "Клима монтажер", icon: "snow-outline", sortOrder: 10 },
{ slug: "drugo", name: "Друго", icon: "ellipsis-horizontal-outline", sortOrder: 11 },
] as const;
export type CategorySlug = (typeof CATEGORIES)[number]["slug"];

7902
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "mojmajstor",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"convex:dev": "convex dev --url https://backend-onhwnh06p0g3976vl34yx18v.testbed.mk:3210",
"convex:deploy": "convex deploy --url https://backend-onhwnh06p0g3976vl34yx18v.testbed.mk:3210"
},
"dependencies": {
"@expo/vector-icons": "^15.0.2",
"convex": "^1.39.1",
"expo": "~56.0.7",
"expo-constants": "~56.0.16",
"expo-haptics": "~56.0.3",
"expo-image": "~56.0.9",
"expo-image-picker": "~56.0.14",
"expo-linking": "~56.0.13",
"expo-location": "~56.0.14",
"expo-router": "~56.2.8",
"expo-splash-screen": "~56.0.10",
"expo-status-bar": "~56.0.4",
"react": "19.2.3",
"react-native": "0.85.3",
"react-native-maps": "1.27.2",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2"
},
"devDependencies": {
"@types/react": "~19.2.2",
"typescript": "~6.0.3"
},
"private": true
}

15
tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}