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