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 ## Tech Stack
- **Frontend:** Expo SDK 56, React Native 0.85, TypeScript, Expo Router (file-based routing), inline styles (no NativeWind/Tailwind) - **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) - **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 - **Deployment:** EAS Build + Update
@ -18,7 +18,17 @@
- Self-deployed Convex does **not** support `@convex-dev/auth` — custom auth via `convex/auth.ts` - 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` - 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 - **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 - **Role system:** `handyman` | `customer` — set at registration. Handymen create ads; customers create posts; both can chat
@ -56,8 +66,9 @@ app/
```bash ```bash
npx expo start # Dev server npx expo start # Dev server
npx expo start --dev-client # Dev server with dev client
npm run convex:dev # Convex backend dev 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 npx tsc --noEmit # Type check
``` ```

View File

@ -1 +1,15 @@
@AGENTS.md @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,53 +51,83 @@ export default function LoginScreen() {
style={{ flex: 1, backgroundColor: theme.colors.background }} style={{ flex: 1, backgroundColor: theme.colors.background }}
> >
<ScrollView <ScrollView
contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ flexGrow: 1, justifyContent: "center", padding: theme.spacing.lg }}
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
<View style={{ marginTop: 60, marginBottom: theme.spacing.xl }}> <View
<Text style={{ fontSize: 32, fontWeight: "700", color: theme.colors.text }}> style={{
МојМајстор backgroundColor: theme.colors.card,
</Text> borderRadius: theme.radius.xl,
<Text style={{ fontSize: 16, color: theme.colors.textSecondary, marginTop: theme.spacing.sm }}> padding: theme.spacing.lg,
Најдете мајстор во вашата близина borderWidth: 1,
</Text> borderColor: theme.colors.borderLight,
</View> ...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 selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, marginTop: theme.spacing.xs }}>
Најдете мајстор во вашата близина
</Text>
</View>
<Input <Input
placeholder="Е-пошта" placeholder="Е-пошта"
value={email} value={email}
onChangeText={setEmail} onChangeText={setEmail}
autoCapitalize="none" autoCapitalize="none"
keyboardType="email-address" keyboardType="email-address"
accessibilityLabel="Е-пошта" accessibilityLabel="Е-пошта"
/> label="Е-пошта"
/>
<Input <Input
placeholder="Лозинка" placeholder="Лозинка"
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
secureTextEntry secureTextEntry
accessibilityLabel="Лозинка" accessibilityLabel="Лозинка"
/> label="Лозинка"
/>
<View style={{ marginTop: theme.spacing.sm }}> <Button
<Button loading={loading} onPress={handleLogin} accessibilityLabel="Најави се"> loading={loading}
onPress={handleLogin}
accessibilityLabel="Најави се"
fullWidth
size="lg"
>
Најави се Најави се
</Button> </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 style={{ color: theme.colors.textSecondary }}>Немате профил? </Text>
<Text <Text
accessible accessible
accessibilityLabel="Регистрирајте се" accessibilityLabel="Регистрирајте се"
accessibilityRole="link" accessibilityRole="link"
style={{ color: theme.colors.primary, fontWeight: "600" }} style={{ color: theme.colors.primary, fontWeight: "600" }}
onPress={() => router.push("/(auth)/register")} onPress={() => router.push("/(auth)/register")}
> >
Регистрирајте се Регистрирајте се
</Text> </Text>
</View>
</View> </View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>

View File

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

View File

@ -39,16 +39,27 @@ export default function ExploreScreen() {
refreshControl={ refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} /> <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 }}> <View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm }}>
<Link href="/(tabs)/(explore)" asChild> <Link href="/(tabs)/(explore)" asChild>
<Pressable accessible accessibilityLabel="Назад кон сите категории" accessibilityRole="link" style={{ marginRight: theme.spacing.xs }}> <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> </Pressable>
</Link> </Link>
<Text style={{ fontSize: 28 }}>{catEmoji}</Text> <View
<Text selectable style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}> 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} {catName}
</Text> </Text>
</View> </View>
@ -56,9 +67,12 @@ export default function ExploreScreen() {
{categoryAdsResult.status === "LoadingFirstPage" ? ( {categoryAdsResult.status === "LoadingFirstPage" ? (
<Loading /> <Loading />
) : categoryAdsResult.results.length === 0 ? ( ) : 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> <Text style={{ ...theme.typography.body, color: theme.colors.textTertiary, textAlign: "center" }}>
Нема огласи во оваа категорија
</Text>
</View>
) : ( ) : (
<View style={{ gap: theme.spacing.sm }}> <View style={{ gap: theme.spacing.sm }}>
{categoryAdsResult.results.map((ad) => ( {categoryAdsResult.results.map((ad) => (
@ -74,15 +88,15 @@ export default function ExploreScreen() {
accessibilityRole="button" accessibilityRole="button"
onPress={() => categoryAdsResult.loadMore(20)} onPress={() => categoryAdsResult.loadMore(20)}
style={{ style={{
backgroundColor: theme.colors.surface, backgroundColor: theme.colors.surfaceAlt,
borderRadius: theme.radius.md, borderRadius: theme.radius.md,
paddingVertical: theme.spacing.md, paddingVertical: theme.spacing.md,
alignItems: "center", alignItems: "center",
borderWidth: 1, 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> </Pressable>
)} )}
</ScrollView> </ScrollView>
@ -95,24 +109,27 @@ export default function ExploreScreen() {
refreshControl={ refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} /> <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> </Text>
<CategoryGrid categories={displayCategories} /> <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> </Text>
{allAdsResult.status === "LoadingFirstPage" ? ( {allAdsResult.status === "LoadingFirstPage" ? (
<Loading /> <Loading />
) : allAdsResult.results.length === 0 ? ( ) : 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> <Text style={{ ...theme.typography.body, color: theme.colors.textTertiary, textAlign: "center" }}>
Нема огласи
</Text>
</View>
) : ( ) : (
<View style={{ gap: theme.spacing.sm }}> <View style={{ gap: theme.spacing.sm }}>
{allAdsResult.results.map((ad) => ( {allAdsResult.results.map((ad) => (
@ -128,15 +145,15 @@ export default function ExploreScreen() {
accessibilityRole="button" accessibilityRole="button"
onPress={() => allAdsResult.loadMore(20)} onPress={() => allAdsResult.loadMore(20)}
style={{ style={{
backgroundColor: theme.colors.surface, backgroundColor: theme.colors.surfaceAlt,
borderRadius: theme.radius.md, borderRadius: theme.radius.md,
paddingVertical: theme.spacing.md, paddingVertical: theme.spacing.md,
alignItems: "center", alignItems: "center",
borderWidth: 1, 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> </Pressable>
)} )}
</ScrollView> </ScrollView>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,6 +15,13 @@ interface CategoryGridProps {
columns?: number; 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) { export function CategoryGrid({ categories, columns = 3 }: CategoryGridProps) {
const theme = useTheme(); const theme = useTheme();
@ -22,6 +29,7 @@ export function CategoryGrid({ categories, columns = 3 }: CategoryGridProps) {
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: theme.spacing.sm }}> <View style={{ flexDirection: "row", flexWrap: "wrap", gap: theme.spacing.sm }}>
{categories.map((cat) => { {categories.map((cat) => {
const emoji = CATEGORY_EMOJI[cat.slug] ?? cat.icon ?? "📋"; const emoji = CATEGORY_EMOJI[cat.slug] ?? cat.icon ?? "📋";
const color = getCategoryColor(cat.slug);
return ( return (
<Link key={cat.slug} href={`/(tabs)/(explore)?category=${cat.slug}`} asChild> <Link key={cat.slug} href={`/(tabs)/(explore)?category=${cat.slug}`} asChild>
<Pressable <Pressable
@ -32,22 +40,35 @@ export function CategoryGrid({ categories, columns = 3 }: CategoryGridProps) {
backgroundColor: theme.colors.surface, backgroundColor: theme.colors.surface,
borderRadius: theme.radius.md, borderRadius: theme.radius.md,
paddingVertical: theme.spacing.md, paddingVertical: theme.spacing.md,
paddingHorizontal: theme.spacing.sm, paddingHorizontal: theme.spacing.xs,
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.border, borderColor: theme.colors.borderLight,
width: `${Math.floor((100 - (columns - 1) * 3.3) / columns)}%` as any, width: `${Math.floor((100 - (columns - 1) * 3.3) / columns)}%` as any,
alignItems: "center", 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 <Text
style={{ style={{
color: theme.colors.text, color: theme.colors.text,
fontSize: 12, fontSize: 12,
fontWeight: "500", fontWeight: "600",
textAlign: "center", textAlign: "center",
lineHeight: 16,
}} }}
numberOfLines={2}
> >
{cat.name} {cat.name}
</Text> </Text>

View File

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

View File

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

View File

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

View File

@ -4,17 +4,53 @@ import { useColorScheme } from "react-native";
type ThemeColors = { type ThemeColors = {
primary: string; primary: string;
primaryLight: string; primaryLight: string;
primaryDark: string;
secondary: string; secondary: string;
secondaryLight: string;
background: string; background: string;
surface: string; surface: string;
surfaceAlt: string;
card: string; card: string;
border: string; border: string;
borderLight: string;
text: string; text: string;
textSecondary: string; textSecondary: string;
textTertiary: string; textTertiary: string;
textInverse: string;
error: string; error: string;
errorLight: string; errorLight: string;
success: string;
successLight: string;
star: 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 = { type Theme = {
@ -25,56 +61,117 @@ type Theme = {
md: number; md: number;
lg: number; lg: number;
xl: number; xl: number;
xxl: number;
}; };
radius: { radius: {
sm: number; sm: number;
md: number; md: number;
lg: number; lg: number;
xl: 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 = { const LightTheme: Theme = {
colors: { colors: {
primary: "#2563EB", primary: "#D4764A",
primaryLight: "#DBEAFE", primaryLight: "#FDF0EA",
secondary: "#059669", primaryDark: "#B85E34",
background: "#FFFFFF", secondary: "#1A2A3A",
surface: "#F9FAFB", secondaryLight: "#E8ECF0",
background: "#FDF8F3",
surface: "#FFFFFF",
surfaceAlt: "#F5F0EB",
card: "#FFFFFF", card: "#FFFFFF",
border: "#E5E7EB", border: "#E8E2DA",
text: "#111827", borderLight: "#F0EBE5",
textSecondary: "#6B7280", text: "#1A1A2E",
textTertiary: "#9CA3AF", textSecondary: "#8B7E74",
textTertiary: "#B0A69C",
textInverse: "#FFFFFF",
error: "#DC2626", error: "#DC2626",
errorLight: "#FEE2E2", errorLight: "#FEE2E2",
success: "#4A8C6F",
successLight: "#E8F3EE",
star: "#F59E0B", star: "#F59E0B",
starLight: "#FEF3C7",
tabActive: "#D4764A",
tabInactive: "#B0A69C",
}, },
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }, spacing,
radius: { sm: 6, md: 12, lg: 16, xl: 24 }, 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 = { const DarkTheme: Theme = {
colors: { colors: {
primary: "#60A5FA", primary: "#E89B78",
primaryLight: "#1E3A5F", primaryLight: "#2D1F18",
secondary: "#34D399", primaryDark: "#D4764A",
background: "#111827", secondary: "#E8ECF0",
surface: "#1F2937", secondaryLight: "#2A3040",
card: "#1F2937", background: "#0F0F1A",
border: "#374151", surface: "#1A1A2E",
text: "#F9FAFB", surfaceAlt: "#222240",
card: "#1A1A2E",
border: "#2A2A45",
borderLight: "#222240",
text: "#F0EDE8",
textSecondary: "#9CA3AF", textSecondary: "#9CA3AF",
textTertiary: "#6B7280", textTertiary: "#6B7280",
textInverse: "#FFFFFF",
error: "#F87171", error: "#F87171",
errorLight: "#451111", errorLight: "#451111",
success: "#6EC99B",
successLight: "#1A3A2E",
star: "#FBBF24", star: "#FBBF24",
starLight: "#3A2E1A",
tabActive: "#E89B78",
tabInactive: "#6B7280",
}, },
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }, spacing,
radius: { sm: 6, md: 12, lg: 16, xl: 24 }, 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); const ThemeContext = createContext<Theme>(LightTheme);

View File

@ -3,28 +3,41 @@ import { useTheme } from "../theme";
interface BadgeProps { interface BadgeProps {
label: string; 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 theme = useTheme();
const variants = { const styles = {
default: { bg: theme.colors.surface, text: theme.colors.textSecondary }, default: { bg: theme.colors.surfaceAlt, text: theme.colors.textSecondary, border: theme.colors.borderLight },
primary: { bg: theme.colors.primaryLight, text: theme.colors.primary }, primary: { bg: theme.colors.primaryLight, text: theme.colors.primaryDark, border: theme.colors.primaryLight },
success: { bg: "#D1FAE5", text: "#059669" }, success: { bg: theme.colors.successLight, text: theme.colors.success, border: theme.colors.successLight },
error: { bg: theme.colors.errorLight, text: theme.colors.error }, 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 ( return (
<View <View
accessible accessible
accessibilityLabel={label} 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>
); );
} }

View File

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

View File

@ -1,27 +1,52 @@
import { View, type ViewStyle } from "react-native"; import { View, Pressable, type ViewStyle } from "react-native";
import { useTheme } from "../theme"; import { useTheme } from "../theme";
interface CardProps { interface CardProps {
children: React.ReactNode; children: React.ReactNode;
style?: ViewStyle; style?: ViewStyle;
onPress?: () => void; 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(); const theme = useTheme();
const variantStyles: Record<string, ViewStyle> = {
default: {
backgroundColor: theme.colors.card,
borderRadius: theme.radius.lg,
padding: theme.spacing.md,
borderWidth: 1,
borderColor: theme.colors.borderLight,
},
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 ( return (
<View <View style={[variantStyles[variant], style]}>
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} {children}
</View> </View>
); );

View File

@ -1,34 +1,48 @@
import { TextInput, type TextInputProps, type ViewStyle } from "react-native"; import { View, TextInput, Text, type TextInputProps, type ViewStyle } from "react-native";
import { useTheme } from "../theme"; import { useTheme } from "../theme";
interface InputProps extends TextInputProps { interface InputProps extends TextInputProps {
error?: string; 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(); const theme = useTheme();
return ( return (
<TextInput <View style={[{ gap: theme.spacing.xs }, containerStyle]}>
accessible {label && (
accessibilityLabel={props.placeholder || "Внесете текст"} <Text style={{ color: theme.colors.textSecondary, ...theme.typography.captionBold }}>
accessibilityRole="none" {label}
placeholderTextColor={theme.colors.textTertiary} </Text>
style={[ )}
{ <TextInput
backgroundColor: theme.colors.surface, accessible
borderColor: error ? theme.colors.error : theme.colors.border, accessibilityLabel={label || props.placeholder || "Внесете текст"}
borderWidth: 1, accessibilityRole="none"
borderRadius: theme.radius.md, placeholderTextColor={theme.colors.textTertiary}
paddingVertical: 12, style={[
paddingHorizontal: theme.spacing.md, {
fontSize: 16, backgroundColor: theme.colors.surface,
color: theme.colors.text, borderColor: error ? theme.colors.error : theme.colors.border,
}, borderWidth: 1.5,
error && { borderColor: theme.colors.error }, borderRadius: theme.radius.md,
typeof style === "object" ? style : undefined, paddingVertical: 14,
]} paddingHorizontal: theme.spacing.md,
{...props} fontSize: 16,
/> color: theme.colors.text,
},
error && { borderColor: theme.colors.error },
typeof style === "object" ? style : undefined,
]}
{...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", "version": "1.0.0",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@react-native-async-storage/async-storage": "^2.2.0",
"convex": "^1.24.0", "convex": "^1.24.0",
"expo": "~55.0.26", "expo": "~55.0.26",
"expo-constants": "~55.0.16", "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": { "node_modules/@react-native/assets-registry": {
"version": "0.83.6", "version": "0.83.6",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.83.6.tgz", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.83.6.tgz",
@ -5888,6 +5901,15 @@
"node": ">=0.12.0" "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": { "node_modules/is-wsl": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@ -6352,9 +6374,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -6375,9 +6394,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -6398,9 +6414,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -6421,9 +6434,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -6626,6 +6636,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT" "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": { "node_modules/merge-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",

View File

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