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:
commit
5102ed23c1
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal 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
3
AGENTS.md
Normal 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.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
34
app.json
Normal 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
10
app/(auth)/_layout.tsx
Normal 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
100
app/(auth)/login.tsx
Normal 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
158
app/(auth)/register.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
app/(tabs)/(chat)/_layout.tsx
Normal file
9
app/(tabs)/(chat)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
app/(tabs)/(chat)/index.tsx
Normal file
20
app/(tabs)/(chat)/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
app/(tabs)/(explore)/_layout.tsx
Normal file
9
app/(tabs)/(explore)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
app/(tabs)/(explore)/index.tsx
Normal file
32
app/(tabs)/(explore)/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
app/(tabs)/(home)/_layout.tsx
Normal file
9
app/(tabs)/(home)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
app/(tabs)/(home)/index.tsx
Normal file
57
app/(tabs)/(home)/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
app/(tabs)/(posts)/_layout.tsx
Normal file
9
app/(tabs)/(posts)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
app/(tabs)/(posts)/index.tsx
Normal file
20
app/(tabs)/(posts)/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
app/(tabs)/(profile)/_layout.tsx
Normal file
9
app/(tabs)/(profile)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
app/(tabs)/(profile)/index.tsx
Normal file
81
app/(tabs)/(profile)/index.tsx
Normal 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
57
app/(tabs)/_layout.tsx
Normal 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
21
app/+not-found.tsx
Normal 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
83
app/_layout.tsx
Normal 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
22
app/ad/[id].tsx
Normal 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
20
app/ad/create.tsx
Normal 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
22
app/chat/[id].tsx
Normal 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
22
app/post/[id].tsx
Normal 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
20
app/post/create.tsx
Normal 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
21
app/review/create.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
assets/android-icon-background.png
Normal file
BIN
assets/android-icon-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/android-icon-foreground.png
Normal file
BIN
assets/android-icon-foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
BIN
assets/android-icon-monochrome.png
Normal file
BIN
assets/android-icon-monochrome.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/favicon.png
Normal file
BIN
assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
BIN
assets/splash-icon.png
Normal file
BIN
assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
89
components/theme.tsx
Normal file
89
components/theme.tsx
Normal 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
46
components/ui/avatar.tsx
Normal 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
26
components/ui/badge.tsx
Normal 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
70
components/ui/button.tsx
Normal 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
28
components/ui/card.tsx
Normal 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
31
components/ui/input.tsx
Normal 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
16
components/ui/loading.tsx
Normal 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
24
components/ui/rating.tsx
Normal 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
33
convex/ads.ts
Normal 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
118
convex/auth.ts
Normal 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
8
convex/categories.ts
Normal 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
10
convex/chats.ts
Normal 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
13
convex/messages.ts
Normal 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
26
convex/posts.ts
Normal 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
24
convex/reviews.ts
Normal 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
94
convex/schema.ts
Normal 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
23
convex/users.ts
Normal 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
6
desc.md
Normal 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
256
implementation.md
Normal 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
16
lib/constants.ts
Normal 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
7902
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal 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
15
tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user