checkpoint

This commit is contained in:
echo 2026-06-04 20:37:09 +02:00
parent 8662037776
commit e8f816916d
25 changed files with 1047 additions and 530 deletions

View File

@ -7,7 +7,7 @@
## Tech Stack
- **Frontend:** Expo SDK 56, React Native 0.85, TypeScript, Expo Router (file-based routing), inline styles (no NativeWind/Tailwind)
- **Backend:** Convex (self-deployed at `https://backend-onhwnh06p0g3976vl34yx18v.testbed.mk:3210`)
- **Backend:** Convex (cloud at `https://decisive-owl-34.eu-west-1.convex.cloud`)
- **Auth:** Custom email/password with SHA-256 (Convex actions), stored in `accounts`/`sessions` tables — no @convex-dev/auth (incompatible with self-hosted)
- **Deployment:** EAS Build + Update
@ -18,7 +18,17 @@
- Self-deployed Convex does **not** support `@convex-dev/auth` — custom auth via `convex/auth.ts`
- Auth env vars already set: `AUTH_SECRET`, `AUTH_SECRET_1`-`AUTH_SECRET_10`, `AUTH_URL`
## Key Conventions
## Design System
- **Colors:** Warm terracotta (`#D4764A`) + deep navy (`#1A2A3A`), off-white bg (`#FDF8F3`)
- **Typography:** System font, hierarchy via `theme.typography` (h1/h2/h3/body/caption/label)
- **Shadows:** Defined in `theme.shadows` as `{ shadowColor, shadowOffset, shadowOpacity, shadowRadius, elevation }`
- **Theme context** via `ThemeProvider` + `useTheme()` from `components/theme.tsx`
- **Spacing:** 4/8/16/24/32/48 via `theme.spacing`, radius via `theme.radius`
- **Icons:** `@expo/vector-icons` (Ionicons) for tab bar; emoji for categories
- **Styling:** Inline styles only, no NativeWind, no StyleSheet.create
- **Card variants:** `default` (border), `elevated` (shadow), `outlined` (transparent bg + border)
- **Button variants:** `primary` (terracotta + shadow), `secondary` (surface + border), `outline`, `ghost`, `destructive`
- **Language split:** Macedonian for all user-facing text, English for code
- **Role system:** `handyman` | `customer` — set at registration. Handymen create ads; customers create posts; both can chat
@ -56,8 +66,9 @@ app/
```bash
npx expo start # Dev server
npx expo start --dev-client # Dev server with dev client
npm run convex:dev # Convex backend dev
CONVEX_DEPLOYMENT="" npx convex dev --url $URL --once # Deploy once
npx convex deploy # Deploy to cloud
npx tsc --noEmit # Type check
```

View File

@ -1 +1,15 @@
@AGENTS.md
<!-- convex-ai-start -->
This project uses [Convex](https://convex.dev) as its backend.
When working on Convex code, **always read
`convex/_generated/ai/guidelines.md` first** for important guidelines on
how to correctly use Convex APIs and patterns. The file contains rules that
override what you may have learned about Convex from training data.
Convex agent skills for common tasks can be installed by running
`npx convex ai-files install`.
<!-- convex-ai-end -->

View File

@ -51,15 +51,38 @@ export default function LoginScreen() {
style={{ flex: 1, backgroundColor: theme.colors.background }}
>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
contentContainerStyle={{ flexGrow: 1, justifyContent: "center", padding: theme.spacing.lg }}
keyboardShouldPersistTaps="handled"
>
<View style={{ marginTop: 60, marginBottom: theme.spacing.xl }}>
<Text style={{ fontSize: 32, fontWeight: "700", color: theme.colors.text }}>
<View
style={{
backgroundColor: theme.colors.card,
borderRadius: theme.radius.xl,
padding: theme.spacing.lg,
borderWidth: 1,
borderColor: theme.colors.borderLight,
...theme.shadows.md,
gap: theme.spacing.md,
}}
>
<View style={{ alignItems: "center", marginBottom: theme.spacing.sm }}>
<View
style={{
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: theme.colors.primaryLight,
alignItems: "center",
justifyContent: "center",
marginBottom: theme.spacing.md,
}}
>
<Text style={{ fontSize: 36 }}>🔧</Text>
</View>
<Text style={{ ...theme.typography.h1, color: theme.colors.text }}>
МојМајстор
</Text>
<Text style={{ fontSize: 16, color: theme.colors.textSecondary, marginTop: theme.spacing.sm }}>
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, marginTop: theme.spacing.xs }}>
Најдете мајстор во вашата близина
</Text>
</View>
@ -71,6 +94,7 @@ export default function LoginScreen() {
autoCapitalize="none"
keyboardType="email-address"
accessibilityLabel="Е-пошта"
label="Е-пошта"
/>
<Input
@ -79,15 +103,20 @@ export default function LoginScreen() {
onChangeText={setPassword}
secureTextEntry
accessibilityLabel="Лозинка"
label="Лозинка"
/>
<View style={{ marginTop: theme.spacing.sm }}>
<Button loading={loading} onPress={handleLogin} accessibilityLabel="Најави се">
<Button
loading={loading}
onPress={handleLogin}
accessibilityLabel="Најави се"
fullWidth
size="lg"
>
Најави се
</Button>
</View>
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.md }}>
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.sm }}>
<Text style={{ color: theme.colors.textSecondary }}>Немате профил? </Text>
<Text
accessible
@ -99,6 +128,7 @@ export default function LoginScreen() {
Регистрирајте се
</Text>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
);

View File

@ -67,15 +67,36 @@ export default function RegisterScreen() {
style={{ flex: 1, backgroundColor: theme.colors.background }}
>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
contentContainerStyle={{ flexGrow: 1, padding: theme.spacing.lg }}
keyboardShouldPersistTaps="handled"
>
<View style={{ marginTop: 40, marginBottom: theme.spacing.md }}>
<Text style={{ fontSize: 28, fontWeight: "700", color: theme.colors.text }}>
<View style={{
backgroundColor: theme.colors.card,
borderRadius: theme.radius.xl,
padding: theme.spacing.lg,
borderWidth: 1,
borderColor: theme.colors.borderLight,
...theme.shadows.md,
gap: theme.spacing.md,
}}>
<View style={{ alignItems: "center", marginBottom: theme.spacing.sm }}>
<View
style={{
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: theme.colors.primaryLight,
alignItems: "center",
justifyContent: "center",
marginBottom: theme.spacing.md,
}}
>
<Text style={{ fontSize: 32 }}>👤</Text>
</View>
<Text style={{ ...theme.typography.h1, color: theme.colors.text }}>
Креирајте профил
</Text>
<Text style={{ fontSize: 16, color: theme.colors.textSecondary, marginTop: theme.spacing.sm }}>
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, marginTop: theme.spacing.xs }}>
Изберете ја вашата улога
</Text>
</View>
@ -88,18 +109,29 @@ export default function RegisterScreen() {
onPress={() => setRole("customer")}
style={{
flex: 1,
paddingVertical: theme.spacing.md,
paddingVertical: theme.spacing.lg,
paddingHorizontal: theme.spacing.sm,
borderRadius: theme.radius.md,
borderWidth: 2,
borderColor: role === "customer" ? theme.colors.primary : theme.colors.border,
borderColor: role === "customer" ? theme.colors.primary : theme.colors.borderLight,
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>
<View
style={{
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: role === "customer" ? theme.colors.primary + "20" : theme.colors.surfaceAlt,
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ fontSize: 24 }}>🏠</Text>
</View>
<Text style={{ fontWeight: "600", fontSize: 15, color: theme.colors.text }}>Клиент</Text>
<Text style={{ fontSize: 12, color: theme.colors.textSecondary, textAlign: "center" }}>
Барајте мајстори
</Text>
@ -112,58 +144,85 @@ export default function RegisterScreen() {
onPress={() => setRole("handyman")}
style={{
flex: 1,
paddingVertical: theme.spacing.md,
paddingVertical: theme.spacing.lg,
paddingHorizontal: theme.spacing.sm,
borderRadius: theme.radius.md,
borderWidth: 2,
borderColor: role === "handyman" ? theme.colors.primary : theme.colors.border,
borderColor: role === "handyman" ? theme.colors.primary : theme.colors.borderLight,
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>
<View
style={{
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: role === "handyman" ? theme.colors.primary + "20" : theme.colors.surfaceAlt,
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ fontSize: 24 }}>🔧</Text>
</View>
<Text style={{ fontWeight: "600", fontSize: 15, color: theme.colors.text }}>Мајстор</Text>
<Text style={{ fontSize: 12, color: theme.colors.textSecondary, textAlign: "center" }}>
Нудете услуги
</Text>
</Pressable>
</View>
<Input placeholder="Име и презиме" value={name} onChangeText={setName} accessibilityLabel="Име и презиме" />
<Input
placeholder="Е-пошта"
placeholder="Име и презиме"
value={name}
onChangeText={setName}
accessibilityLabel="Име и презиме"
label="Име и презиме"
/>
<Input
placeholder="your@email.com"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
accessibilityLabel="Е-пошта"
label="Е-пошта"
/>
<Input
placeholder="Телефонски број (незадолжително)"
placeholder="+389 70 123 456"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
accessibilityLabel="Телефонски број"
label="Телефон (незадолжително)"
/>
<Input
placeholder="Минимум 8 карактери"
value={password}
onChangeText={setPassword}
secureTextEntry
accessibilityLabel="Лозинка"
label="Лозинка"
/>
<Input placeholder="Лозинка (мин. 8 карактери)" value={password} onChangeText={setPassword} secureTextEntry accessibilityLabel="Лозинка" />
<View style={{ marginTop: theme.spacing.sm }}>
<Button loading={loading} onPress={handleRegister} accessibilityLabel="Регистрирај се">
<Button loading={loading} onPress={handleRegister} accessibilityLabel="Регистрирај се" size="lg" fullWidth>
Регистрирај се
</Button>
</View>
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.md }}>
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.sm }}>
<Text style={{ color: theme.colors.textSecondary }}>Веќе имате профил? </Text>
<Text
accessible
accessibilityLabel="Најавете се"
accessibilityRole="link"
style={{ color: theme.colors.primary, fontWeight: "600" }} onPress={() => router.back()}>
style={{ color: theme.colors.primary, fontWeight: "600" }}
onPress={() => router.back()}
>
Најавете се
</Text>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
);

View File

@ -39,16 +39,27 @@ export default function ExploreScreen() {
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
}
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 40 }}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm }}>
<Link href="/(tabs)/(explore)" asChild>
<Pressable accessible accessibilityLabel="Назад кон сите категории" accessibilityRole="link" style={{ marginRight: theme.spacing.xs }}>
<Text style={{ fontSize: 20, color: theme.colors.primary }}> Сите</Text>
<Text style={{ ...theme.typography.bodyBold, color: theme.colors.primary }}> Сите</Text>
</Pressable>
</Link>
<Text style={{ fontSize: 28 }}>{catEmoji}</Text>
<Text selectable style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>
<View
style={{
width: 40,
height: 40,
borderRadius: theme.radius.md,
backgroundColor: theme.colors.primaryLight,
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ fontSize: 22 }}>{catEmoji}</Text>
</View>
<Text selectable style={{ ...theme.typography.h2, color: theme.colors.text }}>
{catName}
</Text>
</View>
@ -56,9 +67,12 @@ export default function ExploreScreen() {
{categoryAdsResult.status === "LoadingFirstPage" ? (
<Loading />
) : categoryAdsResult.results.length === 0 ? (
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
<View style={{ alignItems: "center", paddingVertical: 60 }}>
<Text style={{ fontSize: 48, marginBottom: theme.spacing.md }}>🔎</Text>
<Text style={{ ...theme.typography.body, color: theme.colors.textTertiary, textAlign: "center" }}>
Нема огласи во оваа категорија
</Text>
</View>
) : (
<View style={{ gap: theme.spacing.sm }}>
{categoryAdsResult.results.map((ad) => (
@ -74,15 +88,15 @@ export default function ExploreScreen() {
accessibilityRole="button"
onPress={() => categoryAdsResult.loadMore(20)}
style={{
backgroundColor: theme.colors.surface,
backgroundColor: theme.colors.surfaceAlt,
borderRadius: theme.radius.md,
paddingVertical: theme.spacing.md,
alignItems: "center",
borderWidth: 1,
borderColor: theme.colors.border,
borderColor: theme.colors.borderLight,
}}
>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 15 }}>Вчитај повеќе</Text>
</Pressable>
)}
</ScrollView>
@ -95,24 +109,27 @@ export default function ExploreScreen() {
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
}
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 40 }}
>
<Text style={{ fontSize: 20, fontWeight: "700", color: theme.colors.text }}>
<Text style={{ ...theme.typography.h2, color: theme.colors.text }}>
Пребарувај по категорија
</Text>
<CategoryGrid categories={displayCategories} />
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginTop: theme.spacing.sm }}>
<Text style={{ ...theme.typography.h2, color: theme.colors.text, marginTop: theme.spacing.sm }}>
Сите огласи
</Text>
{allAdsResult.status === "LoadingFirstPage" ? (
<Loading />
) : allAdsResult.results.length === 0 ? (
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
<View style={{ alignItems: "center", paddingVertical: 60 }}>
<Text style={{ fontSize: 48, marginBottom: theme.spacing.md }}>📦</Text>
<Text style={{ ...theme.typography.body, color: theme.colors.textTertiary, textAlign: "center" }}>
Нема огласи
</Text>
</View>
) : (
<View style={{ gap: theme.spacing.sm }}>
{allAdsResult.results.map((ad) => (
@ -128,15 +145,15 @@ export default function ExploreScreen() {
accessibilityRole="button"
onPress={() => allAdsResult.loadMore(20)}
style={{
backgroundColor: theme.colors.surface,
backgroundColor: theme.colors.surfaceAlt,
borderRadius: theme.radius.md,
paddingVertical: theme.spacing.md,
alignItems: "center",
borderWidth: 1,
borderColor: theme.colors.border,
borderColor: theme.colors.borderLight,
}}
>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 15 }}>Вчитај повеќе</Text>
</Pressable>
)}
</ScrollView>

View File

@ -1,15 +1,20 @@
import { View, Text, TextInput, ScrollView, RefreshControl, Pressable } from "react-native";
import { useState, useCallback } from "react";
import { useRouter } from "expo-router";
import { useTheme } from "../../../components/theme";
import { useQuery, usePaginatedQuery } from "convex/react";
import { api } from "../../../convex/_generated/api";
import { useAuth } from "../../_layout";
import { CategoryGrid } from "../../../components/category-grid";
import { AdCard } from "../../../components/ad-card";
import { Loading } from "../../../components/ui/loading";
import { Badge } from "../../../components/ui/badge";
import { CATEGORIES } from "../../../lib/constants";
export default function HomeScreen() {
const theme = useTheme();
const router = useRouter();
const { userId } = useAuth();
const [search, setSearch] = useState("");
const [refreshing, setRefreshing] = useState(false);
@ -40,17 +45,31 @@ export default function HomeScreen() {
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
}
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 40 }}
contentContainerStyle={{ paddingBottom: 40 }}
>
<View style={{ padding: theme.spacing.lg, gap: theme.spacing.md }}>
<View style={{ marginTop: theme.spacing.md }}>
<Text style={{ fontSize: 24, fontWeight: "700", color: theme.colors.text }}>
Добредојдовте 🏠
<Text style={{ ...theme.typography.h1, color: theme.colors.text }}>
Добредојдовте
</Text>
<Text selectable style={{ fontSize: 16, color: theme.colors.textSecondary, marginTop: theme.spacing.sm }}>
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, marginTop: theme.spacing.xs }}>
Најдете мајстор во вашата близина
</Text>
</View>
<View
style={{
flexDirection: "row",
alignItems: "center",
backgroundColor: theme.colors.surface,
borderRadius: theme.radius.md,
borderWidth: 1.5,
borderColor: theme.colors.borderLight,
paddingHorizontal: theme.spacing.md,
gap: theme.spacing.sm,
}}
>
<Text style={{ fontSize: 18 }}>🔍</Text>
<TextInput
accessible
accessibilityLabel="Пребарувај мајстори"
@ -60,35 +79,58 @@ export default function HomeScreen() {
value={search}
onChangeText={setSearch}
style={{
backgroundColor: theme.colors.surface,
borderRadius: theme.radius.md,
padding: theme.spacing.md,
flex: 1,
paddingVertical: 14,
fontSize: 16,
color: theme.colors.text,
borderWidth: 1,
borderColor: theme.colors.border,
}}
/>
{search.length > 0 && (
<Pressable
accessible
accessibilityLabel="Исчисти пребарување"
onPress={() => setSearch("")}
style={{ padding: 4 }}
>
<Text style={{ color: theme.colors.textTertiary, fontSize: 18 }}></Text>
</Pressable>
)}
</View>
{!search.trim() && (
<>
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginTop: theme.spacing.sm }}>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
<Text style={{ ...theme.typography.h2, color: theme.colors.text }}>
Категории
</Text>
<Pressable
accessible
accessibilityLabel="Види ги сите категории"
accessibilityRole="button"
onPress={() => router.push("/(tabs)/(explore)")}
>
<Text style={{ ...theme.typography.bodyBold, color: theme.colors.primary }}>
Сите
</Text>
</Pressable>
</View>
<CategoryGrid categories={displayCategories} />
</>
)}
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginTop: theme.spacing.sm }}>
<Text style={{ ...theme.typography.h2, color: theme.colors.text }}>
{search.trim() ? "Резултати од пребарување" : "Неодамна додадени"}
</Text>
{(search.trim() ? searchResults === undefined : isLoading) ? (
<Loading />
) : displayAds.length === 0 ? (
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
<View style={{ alignItems: "center", paddingVertical: theme.spacing.xl }}>
<Text style={{ fontSize: 48, marginBottom: theme.spacing.md }}>🔎</Text>
<Text style={{ ...theme.typography.body, color: theme.colors.textTertiary, textAlign: "center" }}>
{search.trim() ? "Не се пронајдени резултати" : "Нема огласи"}
</Text>
</View>
) : (
<View style={{ gap: theme.spacing.sm }}>
{displayAds.map((ad) => (
@ -104,17 +146,20 @@ export default function HomeScreen() {
accessibilityRole="button"
onPress={() => loadMoreAds(20)}
style={{
backgroundColor: theme.colors.surface,
backgroundColor: theme.colors.surfaceAlt,
borderRadius: theme.radius.md,
paddingVertical: theme.spacing.md,
alignItems: "center",
borderWidth: 1,
borderColor: theme.colors.border,
borderColor: theme.colors.borderLight,
}}
>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 15 }}>
Вчитај повеќе
</Text>
</Pressable>
)}
</View>
</ScrollView>
);
}

View File

@ -7,6 +7,7 @@ import { useAuth } from "../../_layout";
import { useTheme } from "../../../components/theme";
import { PostCard } from "../../../components/post-card";
import { Loading } from "../../../components/ui/loading";
import { Button } from "../../../components/ui/button";
const TABS = [
{ key: "open", label: "Отворени" },
@ -48,29 +49,21 @@ export default function PostsScreen() {
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }}
>
{isAuthenticated && isCustomer && (
<Pressable
accessible
accessibilityLabel="Креирај ново побарување"
accessibilityRole="button"
<Button
onPress={() => router.push("/post/create" as any)}
size="lg"
accessibilityLabel="Креирај ново побарување"
>
<View
style={{
backgroundColor: theme.colors.primary,
borderRadius: theme.radius.md,
paddingVertical: 12,
paddingHorizontal: theme.spacing.lg,
alignItems: "center",
}}
>
<Text style={{ color: "#FFFFFF", fontWeight: "600", fontSize: 16 }}>
+ Ново побарување
</Text>
</View>
</Pressable>
</Button>
)}
<View style={{ flexDirection: "row", backgroundColor: theme.colors.surface, borderRadius: theme.radius.md, padding: 4 }}>
<View style={{
flexDirection: "row",
backgroundColor: theme.colors.surfaceAlt,
borderRadius: theme.radius.md,
padding: 3,
}}>
{TABS.map((tab) => (
<Pressable
key={tab.key}
@ -85,9 +78,7 @@ export default function PostsScreen() {
borderRadius: theme.radius.sm,
alignItems: "center",
backgroundColor: activeTab === tab.key ? theme.colors.card : "transparent",
...(activeTab === tab.key
? { boxShadow: "0 1px 3px rgba(0,0,0,0.08)" as any }
: {}),
...(activeTab === tab.key ? theme.shadows.sm : undefined),
}}
>
<Text
@ -107,14 +98,14 @@ export default function PostsScreen() {
<Loading />
) : postsResult.results.length === 0 ? (
<View style={{ alignItems: "center", paddingVertical: 60 }}>
<Text style={{ fontSize: 48, marginBottom: 16 }}>📋</Text>
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginBottom: 8 }}>
<Text style={{ fontSize: 48, marginBottom: theme.spacing.md }}>📋</Text>
<Text style={{ ...theme.typography.h3, color: theme.colors.text, marginBottom: theme.spacing.sm }}>
Нема побарувања
</Text>
<Text style={{ color: theme.colors.textSecondary, textAlign: "center" }}>
<Text style={{ ...theme.typography.body, color: theme.colors.textSecondary, textAlign: "center" }}>
{activeTab === "open"
? "Нема отворени побарувања во моментот."
: "Сеуште нема побарувања."}
: "Сè уште нема побарувања."}
</Text>
</View>
) : (
@ -132,15 +123,15 @@ export default function PostsScreen() {
accessibilityRole="button"
onPress={() => postsResult.loadMore(20)}
style={{
backgroundColor: theme.colors.surface,
backgroundColor: theme.colors.surfaceAlt,
borderRadius: theme.radius.md,
paddingVertical: theme.spacing.md,
alignItems: "center",
borderWidth: 1,
borderColor: theme.colors.border,
borderColor: theme.colors.borderLight,
}}
>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 15 }}>Вчитај повеќе</Text>
</Pressable>
)}
</ScrollView>

View File

@ -7,13 +7,11 @@ import { useAuth } from "../../_layout";
import { useTheme } from "../../../components/theme";
import { Button } from "../../../components/ui/button";
import { Badge } from "../../../components/ui/badge";
import { Rating } from "../../../components/ui/rating";
import { Loading } from "../../../components/ui/loading";
import { Card } from "../../../components/ui/card";
import { Avatar } from "../../../components/ui/avatar";
import { AdCard } from "../../../components/ad-card";
import { PostCard } from "../../../components/post-card";
import { getCategoryName } from "../../../lib/constants";
export default function ProfileScreen() {
const router = useRouter();
@ -73,15 +71,23 @@ export default function ProfileScreen() {
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 }}>
<View style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.background,
padding: theme.spacing.lg,
}}>
<Text style={{ fontSize: 64, marginBottom: theme.spacing.md }}>👤</Text>
<Text style={{ ...theme.typography.h2, color: theme.colors.text, marginBottom: theme.spacing.sm }}>
Најавете се
</Text>
<Text selectable style={{ color: theme.colors.textSecondary, textAlign: "center", marginBottom: theme.spacing.lg }}>
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, textAlign: "center", marginBottom: theme.spacing.lg }}>
За да ги видите вашиот профил и податоци.
</Text>
<Button onPress={() => router.push("/(auth)/login")}>Најави се</Button>
<Button onPress={() => router.push("/(auth)/login")} size="lg">
Најави се
</Button>
</View>
);
}
@ -106,52 +112,55 @@ export default function ProfileScreen() {
}
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }}
>
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
<Card variant="elevated" style={{ alignItems: "center", paddingVertical: theme.spacing.xl }}>
<Avatar uri={user.avatarId ?? null} name={user.name || "Корисник"} size={80} />
<Text selectable style={{ fontSize: 20, fontWeight: "700", color: theme.colors.text, marginTop: theme.spacing.md }}>
<Text selectable style={{ ...theme.typography.h2, color: theme.colors.text, marginTop: theme.spacing.md }}>
{user.name || "Корисник"}
</Text>
<Badge label={isHandyman ? "Мајстор" : "Клиент"} variant={isHandyman ? "primary" : "default"} />
{user.email && (
<Text selectable style={{ color: theme.colors.textSecondary, fontSize: 14, marginTop: theme.spacing.xs }}>
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, marginTop: theme.spacing.sm }}>
{user.email}
</Text>
)}
{user.phone && (
<Text selectable style={{ color: theme.colors.textSecondary, fontSize: 14 }}>
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary }}>
{user.phone}
</Text>
)}
</View>
</Card>
{isHandyman && (
<>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>
<Text style={{ ...theme.typography.h2, color: theme.colors.text }}>
Мои огласи
</Text>
<Pressable
accessible
accessibilityLabel="Креирај нов оглас"
accessibilityRole="button"
<Button
size="sm"
onPress={() => router.push("/ad/create" as any)}
accessibilityLabel="Креирај нов оглас"
>
<View style={{ backgroundColor: theme.colors.primary, borderRadius: theme.radius.md, paddingHorizontal: theme.spacing.md, paddingVertical: theme.spacing.sm }}>
<Text style={{ color: "#FFFFFF", fontWeight: "600", fontSize: 14 }}>+ Нов оглас</Text>
</View>
</Pressable>
+ Нов оглас
</Button>
</View>
{myAdsResult.status === "LoadingFirstPage" ? (
<Loading />
) : myAds.length === 0 ? (
<Card>
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
<Text style={{ fontSize: 40, marginBottom: theme.spacing.sm }}>📋</Text>
<Text selectable style={{ color: theme.colors.textSecondary, textAlign: "center", fontSize: 15 }}>
Сеуште немате огласи. Креирајте нов оглас за да се прикажете на клиентите.
<Card style={{ alignItems: "center", paddingVertical: theme.spacing.xl }}>
<Text style={{ fontSize: 48, marginBottom: theme.spacing.sm }}>📋</Text>
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, textAlign: "center" }}>
Сè уште немате огласи.
</Text>
</View>
<Button
variant="outline"
size="sm"
onPress={() => router.push("/ad/create" as any)}
style={{ marginTop: theme.spacing.md }}
>
Креирај оглас
</Button>
</Card>
) : (
<View style={{ gap: theme.spacing.sm }}>
@ -171,7 +180,7 @@ export default function ProfileScreen() {
paddingVertical: 10,
alignItems: "center",
borderWidth: 1,
borderColor: theme.colors.border,
borderColor: theme.colors.borderLight,
}}
>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Уреди</Text>
@ -204,15 +213,15 @@ export default function ProfileScreen() {
accessibilityRole="button"
onPress={() => myAdsResult.loadMore(20)}
style={{
backgroundColor: theme.colors.surface,
backgroundColor: theme.colors.surfaceAlt,
borderRadius: theme.radius.md,
paddingVertical: theme.spacing.md,
alignItems: "center",
borderWidth: 1,
borderColor: theme.colors.border,
borderColor: theme.colors.borderLight,
}}
>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 15 }}>Вчитај повеќе</Text>
</Pressable>
)}
</>
@ -221,31 +230,34 @@ export default function ProfileScreen() {
{!isHandyman && (
<>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>
<Text style={{ ...theme.typography.h2, color: theme.colors.text }}>
Мои побарувања
</Text>
<Pressable
accessible
accessibilityLabel="Креирај ново побарување"
accessibilityRole="button"
<Button
size="sm"
onPress={() => router.push("/post/create" as any)}
accessibilityLabel="Креирај ново побарување"
>
<View style={{ backgroundColor: theme.colors.primary, borderRadius: theme.radius.md, paddingHorizontal: theme.spacing.md, paddingVertical: theme.spacing.sm }}>
<Text style={{ color: "#FFFFFF", fontWeight: "600", fontSize: 14 }}>+ Ново побарување</Text>
</View>
</Pressable>
+ Ново
</Button>
</View>
{myPostsResult.status === "LoadingFirstPage" ? (
<Loading />
) : myPosts.length === 0 ? (
<Card>
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
<Text style={{ fontSize: 40, marginBottom: theme.spacing.sm }}>📝</Text>
<Text selectable style={{ color: theme.colors.textSecondary, textAlign: "center", fontSize: 15 }}>
Сеуште немате побарувања. Креирајте ново побарување за да најдете мајстор.
<Card style={{ alignItems: "center", paddingVertical: theme.spacing.xl }}>
<Text style={{ fontSize: 48, marginBottom: theme.spacing.sm }}>📝</Text>
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, textAlign: "center" }}>
Сè уште немате побарувања.
</Text>
</View>
<Button
variant="outline"
size="sm"
onPress={() => router.push("/post/create" as any)}
style={{ marginTop: theme.spacing.md }}
>
Креирај побарување
</Button>
</Card>
) : (
<View style={{ gap: theme.spacing.sm }}>
@ -262,30 +274,41 @@ export default function ProfileScreen() {
accessibilityRole="button"
onPress={() => myPostsResult.loadMore(20)}
style={{
backgroundColor: theme.colors.surface,
backgroundColor: theme.colors.surfaceAlt,
borderRadius: theme.radius.md,
paddingVertical: theme.spacing.md,
alignItems: "center",
borderWidth: 1,
borderColor: theme.colors.border,
borderColor: theme.colors.borderLight,
}}
>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 15 }}>Вчитај повеќе</Text>
</Pressable>
)}
</>
)}
<View style={{ backgroundColor: theme.colors.card, borderRadius: theme.radius.lg, padding: theme.spacing.md, gap: theme.spacing.sm, borderWidth: 1, borderColor: theme.colors.border }}>
<Pressable accessible accessibilityLabel="Оцени" accessibilityRole="button" style={{ paddingVertical: theme.spacing.sm }}>
<Text style={{ color: theme.colors.text, fontSize: 16 }}>Оцени</Text>
<Card>
<Pressable
accessible
accessibilityLabel="Поставки"
accessibilityRole="button"
style={{ paddingVertical: theme.spacing.sm }}
>
<Text style={{ ...theme.typography.body, color: theme.colors.text }}>Поставки</Text>
</Pressable>
<Pressable accessible accessibilityLabel="Поставки" accessibilityRole="button" style={{ paddingVertical: theme.spacing.sm }}>
<Text style={{ color: theme.colors.text, fontSize: 16 }}>Поставки</Text>
<View style={{ height: 1, backgroundColor: theme.colors.borderLight, marginVertical: theme.spacing.xs }} />
<Pressable
accessible
accessibilityLabel="Оцени"
accessibilityRole="button"
style={{ paddingVertical: theme.spacing.sm }}
>
<Text style={{ ...theme.typography.body, color: theme.colors.text }}>Оцени ја апликацијата</Text>
</Pressable>
</View>
</Card>
<Button variant="destructive" onPress={handleLogout}>
<Button variant="destructive" onPress={handleLogout} fullWidth>
Одјави се
</Button>
</ScrollView>

View File

@ -1,26 +1,35 @@
import { Tabs } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTheme } from "../../components/theme";
export default function TabLayout() {
const theme = useTheme();
const insets = useSafeAreaInsets();
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: theme.colors.primary,
tabBarInactiveTintColor: theme.colors.textTertiary,
tabBarActiveTintColor: theme.colors.tabActive,
tabBarInactiveTintColor: theme.colors.tabInactive,
tabBarStyle: {
backgroundColor: theme.colors.card,
borderTopColor: theme.colors.border,
borderTopWidth: 1,
borderTopColor: theme.colors.borderLight,
paddingTop: 4,
paddingBottom: insets.bottom,
height: 56 + insets.bottom,
},
tabBarLabelStyle: { fontSize: 11 },
tabBarLabelStyle: { fontSize: 11, fontWeight: "500", marginTop: -2 },
tabBarHideOnKeyboard: true,
}}
>
<Tabs.Screen
name="(home)"
options={{
title: "Почетна",
tabBarIcon: ({ color, size }) => <Ionicons name="home-outline" size={size} color={color} />,
tabBarAccessibilityLabel: "Почетна",
}}
/>
@ -28,6 +37,7 @@ export default function TabLayout() {
name="(explore)"
options={{
title: "Пребарување",
tabBarIcon: ({ color, size }) => <Ionicons name="search-outline" size={size} color={color} />,
tabBarAccessibilityLabel: "Пребарување",
}}
/>
@ -35,6 +45,7 @@ export default function TabLayout() {
name="(posts)"
options={{
title: "Побарувања",
tabBarIcon: ({ color, size }) => <Ionicons name="document-text-outline" size={size} color={color} />,
tabBarAccessibilityLabel: "Побарувања",
}}
/>
@ -42,6 +53,7 @@ export default function TabLayout() {
name="(chat)"
options={{
title: "Чат",
tabBarIcon: ({ color, size }) => <Ionicons name="chatbubble-outline" size={size} color={color} />,
tabBarAccessibilityLabel: "Чат",
}}
/>
@ -49,6 +61,7 @@ export default function TabLayout() {
name="(profile)"
options={{
title: "Профил",
tabBarIcon: ({ color, size }) => <Ionicons name="person-outline" size={size} color={color} />,
tabBarAccessibilityLabel: "Профил",
}}
/>

View File

@ -2,6 +2,7 @@ import { Stack } from "expo-router/stack";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { useState, createContext, useContext, useEffect, useCallback, Component, type ReactNode, type ErrorInfo } from "react";
import { View, Text, Pressable, ScrollView } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { ThemeProvider, useTheme } from "../components/theme";
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);
@ -86,8 +87,8 @@ export default function RootLayout() {
useEffect(() => {
Promise.all([
localStorage.getItem(TOKEN_KEY),
localStorage.getItem(USER_ID_KEY),
AsyncStorage.getItem(TOKEN_KEY),
AsyncStorage.getItem(USER_ID_KEY),
]).then(([t, u]) => {
if (t) setToken(t);
if (u) setUserId(u);
@ -96,15 +97,15 @@ export default function RootLayout() {
}, []);
const login = useCallback(async (newToken: string, newUserId: string) => {
localStorage.setItem(TOKEN_KEY, newToken);
localStorage.setItem(USER_ID_KEY, newUserId);
await AsyncStorage.setItem(TOKEN_KEY, newToken);
await AsyncStorage.setItem(USER_ID_KEY, newUserId);
setToken(newToken);
setUserId(newUserId);
}, []);
const logout = useCallback(async () => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_ID_KEY);
await AsyncStorage.removeItem(TOKEN_KEY);
await AsyncStorage.removeItem(USER_ID_KEY);
setToken(null);
setUserId(null);
}, []);

View File

@ -143,54 +143,85 @@ export default function AdDetailScreen() {
accessible
accessibilityLabel={`Слика за ${ad.title}`}
style={{
backgroundColor: theme.colors.surface,
backgroundColor: theme.colors.surfaceAlt,
borderRadius: theme.radius.lg,
aspectRatio: 16 / 9,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
borderColor: theme.colors.border,
borderColor: theme.colors.borderLight,
borderStyle: "dashed",
}}
>
<Text style={{ fontSize: 48 }}>{categoryEmoji}</Text>
<Text style={{ color: theme.colors.textTertiary, marginTop: theme.spacing.sm, fontSize: 14 }}>
<View
style={{
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: theme.colors.primaryLight,
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ fontSize: 32 }}>{categoryEmoji}</Text>
</View>
<Text style={{ ...theme.typography.caption, color: theme.colors.textTertiary, marginTop: theme.spacing.sm }}>
Нема слики
</Text>
</View>
<View>
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>{ad.title}</Text>
<View style={{ gap: theme.spacing.xs }}>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
<View style={{ flex: 1 }}>
<Text style={{ ...theme.typography.h2, color: theme.colors.text }}>{ad.title}</Text>
</View>
{ad.priceRange && (
<Text selectable style={{ fontSize: 18, fontWeight: "600", color: theme.colors.primary, marginTop: theme.spacing.xs }}>
<View
style={{
backgroundColor: theme.colors.primaryLight,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: theme.radius.sm,
marginLeft: theme.spacing.sm,
}}
>
<Text selectable style={{ fontSize: 15, fontWeight: "700", color: theme.colors.primaryDark }}>
{ad.priceRange}
</Text>
</View>
)}
</View>
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm, flexWrap: "wrap" }}>
<Badge label={categoryName} variant="primary" />
<Text selectable style={{ fontSize: 14, color: theme.colors.textSecondary }}>📍 {ad.location}</Text>
<Text selectable style={{ ...theme.typography.caption, color: theme.colors.textSecondary }}>
📍 {ad.location}
</Text>
</View>
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
<Rating value={ad.ratingAvg} count={ad.reviewCount} size={20} />
<Rating value={ad.ratingAvg} count={ad.reviewCount} size={18} />
)}
</View>
{ad.availability && (
<Card>
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
<Text style={{ ...theme.typography.captionBold, color: theme.colors.text, marginBottom: theme.spacing.xs }}>
Расположивост
</Text>
<Text selectable style={{ fontSize: 14, color: theme.colors.textSecondary }}>{ad.availability}</Text>
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary }}>
{ad.availability}
</Text>
</Card>
)}
<Card>
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
<Text style={{ ...theme.typography.captionBold, color: theme.colors.text, marginBottom: theme.spacing.xs }}>
Опис
</Text>
<Text style={{ fontSize: 15, color: theme.colors.textSecondary, lineHeight: 22 }}>{ad.description}</Text>
<Text style={{ ...theme.typography.body, color: theme.colors.textSecondary, lineHeight: 22 }}>
{ad.description}
</Text>
</Card>
<Card>
@ -205,24 +236,33 @@ export default function AdDetailScreen() {
>
<Avatar uri={handyman?.avatarId ?? null} name={handyman?.name || "Мајстор"} size={48} />
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text }}>
<Text style={{ ...theme.typography.bodyBold, color: theme.colors.text }}>
{handyman?.name || "Мајстор"}
</Text>
<Text style={{ fontSize: 13, color: theme.colors.textSecondary }}>Мајстор</Text>
<Text style={{ ...theme.typography.caption, color: theme.colors.textSecondary }}>
Мајстор
</Text>
</View>
<Text style={{ color: theme.colors.primary, fontSize: 20 }}></Text>
</Pressable>
</Card>
{!isOwner && isAuthenticated && (
<View style={{ gap: theme.spacing.sm }}>
<Button loading={startingChat} onPress={handleStartChat}>Започни разговор</Button>
<Button loading={startingChat} onPress={handleStartChat} size="lg" fullWidth>
Започни разговор
</Button>
{!existingUserReview && (
<Button variant="outline" onPress={() => router.push(`/review/create?adId=${ad._id}` as any)}>
<Button
variant="outline"
onPress={() => router.push(`/review/create?adId=${ad._id}` as any)}
fullWidth
>
Напиши оценка
</Button>
)}
{existingUserReview && (
<Text style={{ fontSize: 13, color: theme.colors.textTertiary, textAlign: "center" }}>
<Text style={{ ...theme.typography.caption, color: theme.colors.textTertiary, textAlign: "center" }}>
Веќе напишавте оценка за овој оглас
</Text>
)}
@ -234,10 +274,11 @@ export default function AdDetailScreen() {
<Button
variant="outline"
onPress={() => router.push(`/ad/create?editId=${ad._id}` as any)}
fullWidth
>
Уреди оглас
</Button>
<Button variant="destructive" onPress={handleDelete}>
<Button variant="destructive" onPress={handleDelete} fullWidth>
Избриши оглас
</Button>
</View>
@ -245,27 +286,28 @@ export default function AdDetailScreen() {
{!isOwner && !isAuthenticated && (
<Card>
<Text style={{ fontSize: 14, color: theme.colors.textSecondary, textAlign: "center" }}>
<Text style={{ ...theme.typography.body, color: theme.colors.textSecondary, textAlign: "center" }}>
Најавете се за да започнете разговор или да напишете оценка.
</Text>
<View style={{ marginTop: theme.spacing.md }}>
<Button onPress={() => router.push("/(auth)/login")}>Најави се</Button>
<Button onPress={() => router.push("/(auth)/login")} fullWidth>
Најави се
</Button>
</View>
</Card>
)}
{/* Reviews section */}
{reviews && reviews.length > 0 && (
<View style={{ gap: theme.spacing.sm }}>
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>
<Text style={{ ...theme.typography.h2, color: theme.colors.text }}>
Оцени
</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.xs }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>
<Text style={{ ...theme.typography.h2, color: theme.colors.text }}>
{ad.ratingAvg?.toFixed(1)}
</Text>
<Text style={{ fontSize: 14, color: theme.colors.textTertiary }}>
<Text style={{ ...theme.typography.caption, color: theme.colors.textTertiary }}>
({ad.reviewCount} {ad.reviewCount === 1 ? "оценка" : "оцени"})
</Text>
</View>

View File

@ -3,7 +3,7 @@ import { Link } from "expo-router";
import { useTheme } from "./theme";
import { Badge } from "./ui/badge";
import { Rating } from "./ui/rating";
import { getCategoryName } from "../lib/constants";
import { getCategoryName, CATEGORY_EMOJI } from "../lib/constants";
interface AdCardProps {
ad: {
@ -20,6 +20,7 @@ interface AdCardProps {
export function AdCard({ ad }: AdCardProps) {
const theme = useTheme();
const categoryName = getCategoryName(ad.category);
const emoji = CATEGORY_EMOJI[ad.category] ?? "🔧";
return (
<Link href={`/ad/${ad._id}` as any} asChild>
@ -30,31 +31,56 @@ export function AdCard({ ad }: AdCardProps) {
style={{
backgroundColor: theme.colors.card,
borderRadius: theme.radius.lg,
padding: theme.spacing.md,
borderWidth: 1,
borderColor: theme.colors.border,
gap: theme.spacing.sm,
borderColor: theme.colors.borderLight,
...theme.shadows.sm,
overflow: "hidden",
}}
>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
<View style={{ padding: theme.spacing.md, gap: theme.spacing.sm }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm }}>
<View
style={{
width: 40,
height: 40,
borderRadius: theme.radius.md,
backgroundColor: theme.colors.primaryLight,
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ fontSize: 20 }}>{emoji}</Text>
</View>
<View style={{ flex: 1, gap: 2 }}>
<Text style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text }}>
<Text style={{ ...theme.typography.h3, color: theme.colors.text }} numberOfLines={1}>
{ad.title}
</Text>
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
<Rating value={ad.ratingAvg} count={ad.reviewCount} size={14} />
<Rating value={ad.ratingAvg} count={ad.reviewCount} size={13} />
)}
</View>
{ad.priceRange && (
<Text selectable style={{ fontSize: 14, fontWeight: "600", color: theme.colors.primary, marginLeft: 8 }}>
<View
style={{
backgroundColor: theme.colors.primaryLight,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: theme.radius.sm,
}}
>
<Text selectable style={{ fontSize: 13, fontWeight: "700", color: theme.colors.primaryDark }}>
{ad.priceRange}
</Text>
</View>
)}
</View>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<Badge label={categoryName} variant="primary" />
<Text style={{ fontSize: 13, color: theme.colors.textTertiary }}>📍 {ad.location}</Text>
<Badge label={categoryName} variant="primary" size="sm" />
<Text style={{ ...theme.typography.caption, color: theme.colors.textTertiary }}>
📍 {ad.location}
</Text>
</View>
</View>
</Pressable>
</Link>

View File

@ -15,6 +15,13 @@ interface CategoryGridProps {
columns?: number;
}
function getCategoryColor(slug: string): string {
const colors = ["#D4764A", "#2B5797", "#4A8C6F", "#C47A4A", "#8B5CF6", "#E07B5A",
"#3B82F6", "#7C3AED", "#06B6D4", "#D97706", "#059669", "#6B7280"];
const idx = slug.split("").reduce((a, c) => a + c.charCodeAt(0), 0);
return colors[idx % colors.length];
}
export function CategoryGrid({ categories, columns = 3 }: CategoryGridProps) {
const theme = useTheme();
@ -22,6 +29,7 @@ export function CategoryGrid({ categories, columns = 3 }: CategoryGridProps) {
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: theme.spacing.sm }}>
{categories.map((cat) => {
const emoji = CATEGORY_EMOJI[cat.slug] ?? cat.icon ?? "📋";
const color = getCategoryColor(cat.slug);
return (
<Link key={cat.slug} href={`/(tabs)/(explore)?category=${cat.slug}`} asChild>
<Pressable
@ -32,22 +40,35 @@ export function CategoryGrid({ categories, columns = 3 }: CategoryGridProps) {
backgroundColor: theme.colors.surface,
borderRadius: theme.radius.md,
paddingVertical: theme.spacing.md,
paddingHorizontal: theme.spacing.sm,
paddingHorizontal: theme.spacing.xs,
borderWidth: 1,
borderColor: theme.colors.border,
borderColor: theme.colors.borderLight,
width: `${Math.floor((100 - (columns - 1) * 3.3) / columns)}%` as any,
alignItems: "center",
gap: 4,
gap: 6,
}}
>
<Text style={{ fontSize: 28 }}>{emoji}</Text>
<View
style={{
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: color + "18",
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ fontSize: 22 }}>{emoji}</Text>
</View>
<Text
style={{
color: theme.colors.text,
fontSize: 12,
fontWeight: "500",
fontWeight: "600",
textAlign: "center",
lineHeight: 16,
}}
numberOfLines={2}
>
{cat.name}
</Text>

View File

@ -58,42 +58,28 @@ export function ChatListItem({ chat, currentUserId }: ChatListItemProps) {
paddingVertical: theme.spacing.md,
paddingHorizontal: theme.spacing.lg,
gap: theme.spacing.md,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.card,
borderBottomWidth: 1,
borderBottomColor: theme.colors.borderLight,
}}
>
<Avatar
uri={chat.otherUser?.avatarId ?? null}
name={chat.otherUser?.name ?? "Непознат"}
size={50}
size={52}
/>
<View style={{ flex: 1, gap: 2 }}>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Text
style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text, flex: 1 }}
numberOfLines={1}
>
<View style={{ flex: 1, gap: 3 }}>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
<Text style={{ ...theme.typography.bodyBold, color: theme.colors.text, flex: 1 }} numberOfLines={1}>
{chat.otherUser?.name ?? "Непознат"}
</Text>
{chat.lastMessage && (
<Text
style={{ fontSize: 12, color: theme.colors.textTertiary, marginLeft: 8 }}
>
<Text style={{ ...theme.typography.label, color: theme.colors.textTertiary, marginLeft: 8 }}>
{formatRelativeTime(chat.lastMessage.createdAt)}
</Text>
)}
</View>
<Text
style={{ fontSize: 14, color: theme.colors.textSecondary }}
numberOfLines={1}
>
<Text style={{ ...theme.typography.caption, color: theme.colors.textSecondary }} numberOfLines={1}>
{previewText}
</Text>
</View>

View File

@ -2,7 +2,7 @@ import { View, Text, Pressable } from "react-native";
import { Link } from "expo-router";
import { useTheme } from "./theme";
import { Badge } from "./ui/badge";
import { getCategoryName } from "../lib/constants";
import { getCategoryName, CATEGORY_EMOJI } from "../lib/constants";
interface PostCardProps {
post: {
@ -20,6 +20,7 @@ interface PostCardProps {
export function PostCard({ post }: PostCardProps) {
const theme = useTheme();
const categoryName = post.category ? getCategoryName(post.category) : null;
const emoji = post.category ? (CATEGORY_EMOJI[post.category] ?? "📋") : "📋";
const isOpen = post.status === "open";
return (
@ -31,34 +32,55 @@ export function PostCard({ post }: PostCardProps) {
style={{
backgroundColor: theme.colors.card,
borderRadius: theme.radius.lg,
padding: theme.spacing.md,
borderWidth: 1,
borderColor: theme.colors.border,
gap: theme.spacing.sm,
borderColor: theme.colors.borderLight,
...theme.shadows.sm,
overflow: "hidden",
}}
>
<View style={{
padding: theme.spacing.md,
gap: theme.spacing.sm,
}}>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
<Text style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text, flex: 1 }} numberOfLines={2}>
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm, flex: 1 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: theme.radius.md,
backgroundColor: isOpen ? theme.colors.successLight : theme.colors.surfaceAlt,
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ fontSize: 18 }}>{emoji}</Text>
</View>
<Text style={{ ...theme.typography.h3, color: theme.colors.text, flex: 1 }} numberOfLines={1}>
{post.title}
</Text>
<Badge label={isOpen ? "Отворено" : "Затворено"} variant={isOpen ? "success" : "error"} />
</View>
<Badge label={isOpen ? "Отворено" : "Затворено"} variant={isOpen ? "success" : "error"} size="sm" />
</View>
<Text style={{ fontSize: 14, color: theme.colors.textSecondary }} numberOfLines={2}>
<Text style={{ ...theme.typography.body, color: theme.colors.textSecondary }} numberOfLines={2}>
{post.description}
</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
{categoryName && <Badge label={categoryName} variant="primary" />}
{categoryName && <Badge label={categoryName} variant="primary" size="sm" />}
{post.location && (
<Text style={{ fontSize: 13, color: theme.colors.textTertiary }}>📍 {post.location}</Text>
<Text style={{ ...theme.typography.caption, color: theme.colors.textTertiary }}>
📍 {post.location}
</Text>
)}
{post.budget && (
<Text selectable style={{ fontSize: 13, color: theme.colors.primary, fontWeight: "600" }}>
<Text selectable style={{ ...theme.typography.captionBold, color: theme.colors.primary }}>
💰 {post.budget}
</Text>
)}
</View>
</View>
</Pressable>
</Link>
);

View File

@ -45,25 +45,25 @@ export function ReviewCard({ review, customerName, customerAvatarId }: ReviewCar
borderRadius: theme.radius.lg,
padding: theme.spacing.md,
borderWidth: 1,
borderColor: theme.colors.border,
borderColor: theme.colors.borderLight,
gap: theme.spacing.sm,
}}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm }}>
<Avatar uri={customerAvatarId ?? null} name={customerName} size={36} />
<View style={{ flex: 1 }}>
<Text selectable style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text }}>
<Text selectable style={{ ...theme.typography.bodyBold, color: theme.colors.text }}>
{customerName}
</Text>
<Text style={{ fontSize: 12, color: theme.colors.textTertiary }}>
<Text style={{ ...theme.typography.label, color: theme.colors.textTertiary }}>
{formatTimeAgo(review.createdAt)}
</Text>
</View>
<Rating value={review.rating} size={16} />
<Rating value={review.rating} size={14} />
</View>
{review.comment ? (
<Text selectable style={{ fontSize: 14, color: theme.colors.textSecondary, lineHeight: 20 }}>
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, lineHeight: 22 }}>
{review.comment}
</Text>
) : null}

View File

@ -4,17 +4,53 @@ import { useColorScheme } from "react-native";
type ThemeColors = {
primary: string;
primaryLight: string;
primaryDark: string;
secondary: string;
secondaryLight: string;
background: string;
surface: string;
surfaceAlt: string;
card: string;
border: string;
borderLight: string;
text: string;
textSecondary: string;
textTertiary: string;
textInverse: string;
error: string;
errorLight: string;
success: string;
successLight: string;
star: string;
starLight: string;
tabActive: string;
tabInactive: string;
};
type Shadow = {
shadowColor: string;
shadowOffset: { width: number; height: number };
shadowOpacity: number;
shadowRadius: number;
elevation: number;
};
type ThemeShadows = {
sm: Shadow;
md: Shadow;
lg: Shadow;
button: Shadow;
};
type Typography = {
h1: { fontSize: number; fontWeight: "700"; lineHeight: number };
h2: { fontSize: number; fontWeight: "600"; lineHeight: number };
h3: { fontSize: number; fontWeight: "600"; lineHeight: number };
body: { fontSize: number; fontWeight: "400"; lineHeight: number };
bodyBold: { fontSize: number; fontWeight: "600"; lineHeight: number };
caption: { fontSize: number; fontWeight: "400"; lineHeight: number };
captionBold: { fontSize: number; fontWeight: "600"; lineHeight: number };
label: { fontSize: number; fontWeight: "500"; lineHeight: number };
};
type Theme = {
@ -25,56 +61,117 @@ type Theme = {
md: number;
lg: number;
xl: number;
xxl: number;
};
radius: {
sm: number;
md: number;
lg: number;
xl: number;
full: number;
};
shadows: ThemeShadows;
typography: Typography;
};
const makeShadow = (elevation: number, color: string): Shadow => {
const shadows: Record<number, Shadow> = {
2: { shadowColor: color, shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 3, elevation: 2 },
4: { shadowColor: color, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.12, shadowRadius: 8, elevation: 4 },
8: { shadowColor: color, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.15, shadowRadius: 16, elevation: 8 },
};
return shadows[elevation] || shadows[2];
};
const spacing = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48 };
const radius = { sm: 6, md: 12, lg: 16, xl: 24, full: 999 };
const typography: Typography = {
h1: { fontSize: 28, fontWeight: "700", lineHeight: 36 },
h2: { fontSize: 22, fontWeight: "600", lineHeight: 30 },
h3: { fontSize: 18, fontWeight: "600", lineHeight: 24 },
body: { fontSize: 15, fontWeight: "400", lineHeight: 22 },
bodyBold: { fontSize: 15, fontWeight: "600", lineHeight: 22 },
caption: { fontSize: 13, fontWeight: "400", lineHeight: 18 },
captionBold: { fontSize: 13, fontWeight: "600", lineHeight: 18 },
label: { fontSize: 11, fontWeight: "500", lineHeight: 16 },
};
const LightTheme: Theme = {
colors: {
primary: "#2563EB",
primaryLight: "#DBEAFE",
secondary: "#059669",
background: "#FFFFFF",
surface: "#F9FAFB",
primary: "#D4764A",
primaryLight: "#FDF0EA",
primaryDark: "#B85E34",
secondary: "#1A2A3A",
secondaryLight: "#E8ECF0",
background: "#FDF8F3",
surface: "#FFFFFF",
surfaceAlt: "#F5F0EB",
card: "#FFFFFF",
border: "#E5E7EB",
text: "#111827",
textSecondary: "#6B7280",
textTertiary: "#9CA3AF",
border: "#E8E2DA",
borderLight: "#F0EBE5",
text: "#1A1A2E",
textSecondary: "#8B7E74",
textTertiary: "#B0A69C",
textInverse: "#FFFFFF",
error: "#DC2626",
errorLight: "#FEE2E2",
success: "#4A8C6F",
successLight: "#E8F3EE",
star: "#F59E0B",
starLight: "#FEF3C7",
tabActive: "#D4764A",
tabInactive: "#B0A69C",
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
radius: { sm: 6, md: 12, lg: 16, xl: 24 },
spacing,
radius,
shadows: {
sm: makeShadow(2, "#000000"),
md: makeShadow(4, "#000000"),
lg: makeShadow(8, "#000000"),
button: { shadowColor: "#D4764A", shadowOffset: { width: 0, height: 3 }, shadowOpacity: 0.3, shadowRadius: 6, elevation: 4 },
},
typography,
};
const DarkTheme: Theme = {
colors: {
primary: "#60A5FA",
primaryLight: "#1E3A5F",
secondary: "#34D399",
background: "#111827",
surface: "#1F2937",
card: "#1F2937",
border: "#374151",
text: "#F9FAFB",
primary: "#E89B78",
primaryLight: "#2D1F18",
primaryDark: "#D4764A",
secondary: "#E8ECF0",
secondaryLight: "#2A3040",
background: "#0F0F1A",
surface: "#1A1A2E",
surfaceAlt: "#222240",
card: "#1A1A2E",
border: "#2A2A45",
borderLight: "#222240",
text: "#F0EDE8",
textSecondary: "#9CA3AF",
textTertiary: "#6B7280",
textInverse: "#FFFFFF",
error: "#F87171",
errorLight: "#451111",
success: "#6EC99B",
successLight: "#1A3A2E",
star: "#FBBF24",
starLight: "#3A2E1A",
tabActive: "#E89B78",
tabInactive: "#6B7280",
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
radius: { sm: 6, md: 12, lg: 16, xl: 24 },
spacing,
radius,
shadows: {
sm: makeShadow(2, "#000000"),
md: makeShadow(4, "#000000"),
lg: makeShadow(8, "#000000"),
button: { shadowColor: "#E89B78", shadowOffset: { width: 0, height: 3 }, shadowOpacity: 0.3, shadowRadius: 6, elevation: 4 },
},
typography,
};
export type { Theme };
export type { Theme, ThemeColors, ThemeShadows, Typography };
const ThemeContext = createContext<Theme>(LightTheme);

View File

@ -3,28 +3,41 @@ import { useTheme } from "../theme";
interface BadgeProps {
label: string;
variant?: "default" | "primary" | "success" | "error";
variant?: "default" | "primary" | "success" | "error" | "warning";
size?: "sm" | "md";
}
export function Badge({ label, variant = "default" }: BadgeProps) {
export function Badge({ label, variant = "default", size = "md" }: 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 styles = {
default: { bg: theme.colors.surfaceAlt, text: theme.colors.textSecondary, border: theme.colors.borderLight },
primary: { bg: theme.colors.primaryLight, text: theme.colors.primaryDark, border: theme.colors.primaryLight },
success: { bg: theme.colors.successLight, text: theme.colors.success, border: theme.colors.successLight },
error: { bg: theme.colors.errorLight, text: theme.colors.error, border: theme.colors.errorLight },
warning: { bg: theme.colors.starLight, text: "#92400E", border: theme.colors.starLight },
};
const { bg, text: textColor } = variants[variant];
const { bg, text: textColor, border } = styles[variant];
const isSmall = size === "sm";
return (
<View
accessible
accessibilityLabel={label}
style={{ backgroundColor: bg, paddingHorizontal: 8, paddingVertical: 4, borderRadius: theme.radius.sm }}
style={{
backgroundColor: bg,
paddingHorizontal: isSmall ? 6 : 10,
paddingVertical: isSmall ? 3 : 5,
borderRadius: theme.radius.sm,
borderWidth: 1,
borderColor: border,
alignSelf: "flex-start",
}}
>
<Text style={{ color: textColor, fontSize: 12, fontWeight: "600" }}>{label}</Text>
<Text style={{ color: textColor, fontSize: isSmall ? 11 : 12, fontWeight: "600", letterSpacing: 0.2 }}>
{label}
</Text>
</View>
);
}

View File

@ -1,13 +1,14 @@
import { Pressable, Text, ActivityIndicator, type ViewStyle } from "react-native";
import { useTheme } from "../theme";
type Variant = "primary" | "secondary" | "outline" | "destructive";
type Variant = "primary" | "secondary" | "outline" | "ghost" | "destructive";
interface ButtonProps {
variant?: Variant;
loading?: boolean;
size?: "sm" | "md" | "lg";
disabled?: boolean;
fullWidth?: boolean;
onPress?: () => void;
children: React.ReactNode;
style?: ViewStyle;
@ -19,6 +20,7 @@ export function Button({
loading = false,
size = "md",
disabled = false,
fullWidth = false,
children,
style,
onPress,
@ -27,22 +29,24 @@ export function Button({
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 },
sm: { paddingVertical: 8, paddingHorizontal: theme.spacing.md, borderRadius: theme.radius.sm },
md: { paddingVertical: 14, paddingHorizontal: theme.spacing.lg, borderRadius: theme.radius.md },
lg: { paddingVertical: 18, paddingHorizontal: theme.spacing.xl, borderRadius: theme.radius.lg },
};
const variantStyles: Record<Variant, ViewStyle> = {
primary: { backgroundColor: theme.colors.primary },
secondary: { backgroundColor: theme.colors.secondary },
outline: { backgroundColor: "transparent", borderWidth: 1, borderColor: theme.colors.primary },
primary: { backgroundColor: theme.colors.primary, ...theme.shadows.button },
secondary: { backgroundColor: theme.colors.surface, borderWidth: 1.5, borderColor: theme.colors.border },
outline: { backgroundColor: "transparent", borderWidth: 1.5, borderColor: theme.colors.primary },
ghost: { backgroundColor: "transparent" },
destructive: { backgroundColor: theme.colors.error },
};
const textColors: Record<Variant, string> = {
primary: "#FFFFFF",
secondary: "#FFFFFF",
primary: theme.colors.textInverse,
secondary: theme.colors.text,
outline: theme.colors.primary,
ghost: theme.colors.primary,
destructive: "#FFFFFF",
};
@ -57,17 +61,30 @@ export function Button({
disabled={disabled || loading}
onPress={onPress}
style={[
{ alignItems: "center", justifyContent: "center" },
{
alignItems: "center",
justifyContent: "center",
flexDirection: "row",
gap: theme.spacing.sm,
},
sizeStyles[size],
variantStyles[variant],
fullWidth && { width: "100%" },
disabled && { opacity: 0.5 },
style,
]}
>
{loading ? (
<ActivityIndicator color={variant === "outline" ? theme.colors.primary : "#FFFFFF"} size="small" />
<ActivityIndicator color={textColors[variant]} size="small" />
) : typeof children === "string" ? (
<Text style={{ color: textColors[variant], fontWeight: "600", fontSize: size === "sm" ? 14 : 16 }}>
<Text
style={{
color: textColors[variant],
fontWeight: "600",
fontSize: size === "sm" ? 14 : 16,
letterSpacing: 0.3,
}}
>
{children}
</Text>
) : (

View File

@ -1,28 +1,53 @@
import { View, type ViewStyle } from "react-native";
import { View, Pressable, type ViewStyle } from "react-native";
import { useTheme } from "../theme";
interface CardProps {
children: React.ReactNode;
style?: ViewStyle;
onPress?: () => void;
variant?: "default" | "elevated" | "outlined";
}
export function Card({ children, style, onPress }: CardProps) {
export function Card({ children, style, onPress, variant = "default" }: CardProps) {
const theme = useTheme();
return (
<View
style={[
{
const variantStyles: Record<string, ViewStyle> = {
default: {
backgroundColor: theme.colors.card,
borderRadius: theme.radius.lg,
padding: theme.spacing.md,
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.08)",
borderWidth: 1,
borderColor: theme.colors.borderLight,
},
style,
]}
elevated: {
backgroundColor: theme.colors.card,
borderRadius: theme.radius.lg,
padding: theme.spacing.md,
...theme.shadows.md,
},
outlined: {
backgroundColor: "transparent",
borderRadius: theme.radius.lg,
padding: theme.spacing.md,
borderWidth: 1.5,
borderColor: theme.colors.border,
},
};
if (onPress) {
return (
<Pressable
onPress={onPress}
style={[variantStyles[variant], style]}
>
{children}
</Pressable>
);
}
return (
<View style={[variantStyles[variant], style]}>
{children}
</View>
);
}

View File

@ -1,26 +1,34 @@
import { TextInput, type TextInputProps, type ViewStyle } from "react-native";
import { View, TextInput, Text, type TextInputProps, type ViewStyle } from "react-native";
import { useTheme } from "../theme";
interface InputProps extends TextInputProps {
error?: string;
label?: string;
containerStyle?: ViewStyle;
}
export function Input({ error, style, ...props }: InputProps) {
export function Input({ error, label, containerStyle, style, ...props }: InputProps) {
const theme = useTheme();
return (
<View style={[{ gap: theme.spacing.xs }, containerStyle]}>
{label && (
<Text style={{ color: theme.colors.textSecondary, ...theme.typography.captionBold }}>
{label}
</Text>
)}
<TextInput
accessible
accessibilityLabel={props.placeholder || "Внесете текст"}
accessibilityLabel={label || props.placeholder || "Внесете текст"}
accessibilityRole="none"
placeholderTextColor={theme.colors.textTertiary}
style={[
{
backgroundColor: theme.colors.surface,
borderColor: error ? theme.colors.error : theme.colors.border,
borderWidth: 1,
borderWidth: 1.5,
borderRadius: theme.radius.md,
paddingVertical: 12,
paddingVertical: 14,
paddingHorizontal: theme.spacing.md,
fontSize: 16,
color: theme.colors.text,
@ -30,5 +38,11 @@ export function Input({ error, style, ...props }: InputProps) {
]}
{...props}
/>
{error && (
<Text style={{ color: theme.colors.error, fontSize: 12, marginTop: 2 }}>
{error}
</Text>
)}
</View>
);
}

25
convex/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
/* This TypeScript project config describes the environment that
* Convex functions run in and is used to typecheck them.
* You can modify it, but some settings are required to use Convex.
*/
"compilerOptions": {
/* These settings are not required by Convex and can be modified. */
"allowJs": true,
"strict": true,
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
/* These compiler options are required by Convex */
"target": "ESNext",
"lib": ["ES2023", "dom"],
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"isolatedModules": true,
"noEmit": true
},
"include": ["./**/*"],
"exclude": ["./_generated"]
}

2
mise.toml Normal file
View File

@ -0,0 +1,2 @@
[tools]
java = "21.0.2"

46
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@expo/vector-icons": "^15.0.2",
"@react-native-async-storage/async-storage": "^2.2.0",
"convex": "^1.24.0",
"expo": "~55.0.26",
"expo-constants": "~55.0.16",
@ -3214,6 +3215,18 @@
}
}
},
"node_modules/@react-native-async-storage/async-storage": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
"license": "MIT",
"dependencies": {
"merge-options": "^3.0.4"
},
"peerDependencies": {
"react-native": "^0.0.0-0 || >=0.65 <1.0"
}
},
"node_modules/@react-native/assets-registry": {
"version": "0.83.6",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.83.6.tgz",
@ -5888,6 +5901,15 @@
"node": ">=0.12.0"
}
},
"node_modules/is-plain-obj": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@ -6352,9 +6374,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -6375,9 +6394,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -6398,9 +6414,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -6421,9 +6434,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -6626,6 +6636,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/merge-options": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
"integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
"license": "MIT",
"dependencies": {
"is-plain-obj": "^2.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",

View File

@ -12,6 +12,7 @@
},
"dependencies": {
"@expo/vector-icons": "^15.0.2",
"@react-native-async-storage/async-storage": "^2.2.0",
"convex": "^1.24.0",
"expo": "~55.0.26",
"expo-constants": "~55.0.16",