From e8f816916d7fe232baa92dd4df081f9c7cbc8565 Mon Sep 17 00:00:00 2001 From: echo Date: Thu, 4 Jun 2026 20:37:09 +0200 Subject: [PATCH] checkpoint --- AGENTS.md | 17 ++- CLAUDE.md | 14 ++ app/(auth)/login.tsx | 110 +++++++++------ app/(auth)/register.tsx | 235 +++++++++++++++++++++------------ app/(tabs)/(explore)/index.tsx | 57 +++++--- app/(tabs)/(home)/index.tsx | 179 +++++++++++++++---------- app/(tabs)/(posts)/index.tsx | 51 +++---- app/(tabs)/(profile)/index.tsx | 141 +++++++++++--------- app/(tabs)/_layout.tsx | 23 +++- app/_layout.tsx | 13 +- app/ad/[id].tsx | 112 +++++++++++----- components/ad-card.tsx | 68 +++++++--- components/category-grid.tsx | 33 ++++- components/chat-list-item.tsx | 32 ++--- components/post-card.tsx | 70 ++++++---- components/review-card.tsx | 12 +- components/theme.tsx | 143 ++++++++++++++++---- components/ui/badge.tsx | 35 +++-- components/ui/button.tsx | 43 ++++-- components/ui/card.tsx | 53 ++++++-- components/ui/input.tsx | 62 +++++---- convex/tsconfig.json | 25 ++++ mise.toml | 2 + package-lock.json | 46 +++++-- package.json | 1 + 25 files changed, 1047 insertions(+), 530 deletions(-) create mode 100644 convex/tsconfig.json create mode 100644 mise.toml diff --git a/AGENTS.md b/AGENTS.md index 22f3064..2187522 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ``` diff --git a/CLAUDE.md b/CLAUDE.md index 43c994c..04c7e56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,15 @@ @AGENTS.md + + + +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`. + + diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx index 2e75789..eea3f23 100644 --- a/app/(auth)/login.tsx +++ b/app/(auth)/login.tsx @@ -51,55 +51,85 @@ export default function LoginScreen() { style={{ flex: 1, backgroundColor: theme.colors.background }} > - - - МојМајстор - - - Најдете мајстор во вашата близина - - + + + + 🔧 + + + МојМајстор + + + Најдете мајстор во вашата близина + + - + - + - - - - - Немате профил? - router.push("/(auth)/register")} - > - Регистрирајте се - + + Немате профил? + router.push("/(auth)/register")} + > + Регистрирајте се + + ); -} \ No newline at end of file +} diff --git a/app/(auth)/register.tsx b/app/(auth)/register.tsx index 5fdf155..986998b 100644 --- a/app/(auth)/register.tsx +++ b/app/(auth)/register.tsx @@ -67,104 +67,163 @@ export default function RegisterScreen() { style={{ flex: 1, backgroundColor: theme.colors.background }} > - - - Креирајте профил - - - Изберете ја вашата улога - - - - - setRole("customer")} - style={{ - flex: 1, - paddingVertical: theme.spacing.md, - paddingHorizontal: theme.spacing.sm, - borderRadius: theme.radius.md, - borderWidth: 2, - borderColor: role === "customer" ? theme.colors.primary : theme.colors.border, - backgroundColor: role === "customer" ? theme.colors.primaryLight : theme.colors.surface, - alignItems: "center", - gap: theme.spacing.sm, - }} - > - 🏠 - Клиент - - Барајте мајстори + + + + 👤 + + + Креирајте профил - - - 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, - }} - > - 🔧 - Мајстор - - Нудете услуги + + Изберете ја вашата улога - - + - - - - + + setRole("customer")} + style={{ + flex: 1, + paddingVertical: theme.spacing.lg, + paddingHorizontal: theme.spacing.sm, + borderRadius: theme.radius.md, + borderWidth: 2, + borderColor: role === "customer" ? theme.colors.primary : theme.colors.borderLight, + backgroundColor: role === "customer" ? theme.colors.primaryLight : theme.colors.surface, + alignItems: "center", + gap: theme.spacing.sm, + }} + > + + 🏠 + + Клиент + + Барајте мајстори + + - - - - - Веќе имате профил? - router.back()}> - Најавете се - + + Веќе имате профил? + router.back()} + > + Најавете се + + ); -} \ No newline at end of file +} diff --git a/app/(tabs)/(explore)/index.tsx b/app/(tabs)/(explore)/index.tsx index 6598f51..b9ffe70 100644 --- a/app/(tabs)/(explore)/index.tsx +++ b/app/(tabs)/(explore)/index.tsx @@ -39,16 +39,27 @@ export default function ExploreScreen() { refreshControl={ } - contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }} + contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 40 }} > - ← Сите + ← Сите - {catEmoji} - + + {catEmoji} + + {catName} @@ -56,9 +67,12 @@ export default function ExploreScreen() { {categoryAdsResult.status === "LoadingFirstPage" ? ( ) : categoryAdsResult.results.length === 0 ? ( - - Нема огласи во оваа категорија - + + 🔎 + + Нема огласи во оваа категорија + + ) : ( {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, }} > - Вчитај повеќе + Вчитај повеќе )} @@ -95,24 +109,27 @@ export default function ExploreScreen() { refreshControl={ } - contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }} + contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 40 }} > - + Пребарувај по категорија - + Сите огласи {allAdsResult.status === "LoadingFirstPage" ? ( ) : allAdsResult.results.length === 0 ? ( - - Нема огласи - + + 📦 + + Нема огласи + + ) : ( {allAdsResult.results.map((ad) => ( @@ -128,17 +145,17 @@ 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, }} > - Вчитај повеќе + Вчитај повеќе )} ); -} \ No newline at end of file +} diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index b9d9e6a..8803cc6 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -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,81 +45,121 @@ export default function HomeScreen() { refreshControl={ } - contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 40 }} + contentContainerStyle={{ paddingBottom: 40 }} > - - - Добредојдовте 🏠 - - - Најдете мајстор во вашата близина - - - - - - {!search.trim() && ( - <> - - Категории + + + + Добредојдовте + + + Најдете мајстор во вашата близина - - - )} - - - {search.trim() ? "Резултати од пребарување" : "Неодамна додадени"} - - - {(search.trim() ? searchResults === undefined : isLoading) ? ( - - ) : displayAds.length === 0 ? ( - - {search.trim() ? "Не се пронајдени резултати" : "Нема огласи"} - - ) : ( - - {displayAds.map((ad) => ( - - ))} - )} - {!search.trim() && adsStatus === "CanLoadMore" && ( - loadMoreAds(20)} + - Вчитај повеќе - - )} + 🔍 + + {search.length > 0 && ( + setSearch("")} + style={{ padding: 4 }} + > + + + )} + + + {!search.trim() && ( + <> + + + Категории + + router.push("/(tabs)/(explore)")} + > + + Сите + + + + + + )} + + + {search.trim() ? "Резултати од пребарување" : "Неодамна додадени"} + + + {(search.trim() ? searchResults === undefined : isLoading) ? ( + + ) : displayAds.length === 0 ? ( + + 🔎 + + {search.trim() ? "Не се пронајдени резултати" : "Нема огласи"} + + + ) : ( + + {displayAds.map((ad) => ( + + ))} + + )} + + {!search.trim() && adsStatus === "CanLoadMore" && ( + loadMoreAds(20)} + style={{ + backgroundColor: theme.colors.surfaceAlt, + borderRadius: theme.radius.md, + paddingVertical: theme.spacing.md, + alignItems: "center", + borderWidth: 1, + borderColor: theme.colors.borderLight, + }} + > + + Вчитај повеќе + + + )} + ); -} \ No newline at end of file +} diff --git a/app/(tabs)/(posts)/index.tsx b/app/(tabs)/(posts)/index.tsx index 8c4912c..0b22659 100644 --- a/app/(tabs)/(posts)/index.tsx +++ b/app/(tabs)/(posts)/index.tsx @@ -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 && ( - router.push("/post/create" as any)} + size="lg" + accessibilityLabel="Креирај ново побарување" > - - - + Ново побарување - - - + + Ново побарување + )} - + {TABS.map((tab) => ( ) : postsResult.results.length === 0 ? ( - 📋 - + 📋 + Нема побарувања - + {activeTab === "open" ? "Нема отворени побарувања во моментот." - : "Сеуште нема побарувања."} + : "Сè уште нема побарувања."} ) : ( @@ -132,17 +123,17 @@ 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, }} > - Вчитај повеќе + Вчитај повеќе )} ); -} \ No newline at end of file +} diff --git a/app/(tabs)/(profile)/index.tsx b/app/(tabs)/(profile)/index.tsx index a37c597..e7120a8 100644 --- a/app/(tabs)/(profile)/index.tsx +++ b/app/(tabs)/(profile)/index.tsx @@ -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 ( - - 👤 - + + 👤 + Најавете се - + За да ги видите вашиот профил и податоци. - + ); } @@ -106,52 +112,55 @@ export default function ProfileScreen() { } contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }} > - + - + {user.name || "Корисник"} {user.email && ( - + {user.email} )} {user.phone && ( - + {user.phone} )} - + {isHandyman && ( <> - + Мои огласи - router.push("/ad/create" as any)} + accessibilityLabel="Креирај нов оглас" > - - + Нов оглас - - + + Нов оглас + {myAdsResult.status === "LoadingFirstPage" ? ( ) : myAds.length === 0 ? ( - - - 📋 - - Сеуште немате огласи. Креирајте нов оглас за да се прикажете на клиентите. - - + + 📋 + + Сè уште немате огласи. + + ) : ( @@ -171,7 +180,7 @@ export default function ProfileScreen() { paddingVertical: 10, alignItems: "center", borderWidth: 1, - borderColor: theme.colors.border, + borderColor: theme.colors.borderLight, }} > Уреди @@ -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, }} > - Вчитај повеќе + Вчитај повеќе )} @@ -221,31 +230,34 @@ export default function ProfileScreen() { {!isHandyman && ( <> - + Мои побарувања - router.push("/post/create" as any)} + accessibilityLabel="Креирај ново побарување" > - - + Ново побарување - - + + Ново + {myPostsResult.status === "LoadingFirstPage" ? ( ) : myPosts.length === 0 ? ( - - - 📝 - - Сеуште немате побарувања. Креирајте ново побарување за да најдете мајстор. - - + + 📝 + + Сè уште немате побарувања. + + ) : ( @@ -262,32 +274,43 @@ 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, }} > - Вчитај повеќе + Вчитај повеќе )} )} - - - Оцени + + + Поставки - - Поставки + + + Оцени ја апликацијата - + - ); -} \ No newline at end of file +} diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 5ce21ea..091d160 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -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 ( , tabBarAccessibilityLabel: "Почетна", }} /> @@ -28,6 +37,7 @@ export default function TabLayout() { name="(explore)" options={{ title: "Пребарување", + tabBarIcon: ({ color, size }) => , tabBarAccessibilityLabel: "Пребарување", }} /> @@ -35,6 +45,7 @@ export default function TabLayout() { name="(posts)" options={{ title: "Побарувања", + tabBarIcon: ({ color, size }) => , tabBarAccessibilityLabel: "Побарувања", }} /> @@ -42,6 +53,7 @@ export default function TabLayout() { name="(chat)" options={{ title: "Чат", + tabBarIcon: ({ color, size }) => , tabBarAccessibilityLabel: "Чат", }} /> @@ -49,9 +61,10 @@ export default function TabLayout() { name="(profile)" options={{ title: "Профил", + tabBarIcon: ({ color, size }) => , tabBarAccessibilityLabel: "Профил", }} /> ); -} \ No newline at end of file +} diff --git a/app/_layout.tsx b/app/_layout.tsx index 0783958..9ab3d59 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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); }, []); diff --git a/app/ad/[id].tsx b/app/ad/[id].tsx index 30f1a3f..06f5359 100644 --- a/app/ad/[id].tsx +++ b/app/ad/[id].tsx @@ -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", }} > - {categoryEmoji} - + + {categoryEmoji} + + Нема слики - - {ad.title} - {ad.priceRange && ( - - {ad.priceRange} + + + + {ad.title} + + {ad.priceRange && ( + + + {ad.priceRange} + + + )} + + + + + + 📍 {ad.location} + + + {ad.ratingAvg != null && ad.ratingAvg > 0 && ( + )} - - - 📍 {ad.location} - - - {ad.ratingAvg != null && ad.ratingAvg > 0 && ( - - )} - {ad.availability && ( - + Расположивост - {ad.availability} + + {ad.availability} + )} - + Опис - {ad.description} + + {ad.description} + @@ -205,24 +236,33 @@ export default function AdDetailScreen() { > - + {handyman?.name || "Мајстор"} - Мајстор + + Мајстор + + {!isOwner && isAuthenticated && ( - + {!existingUserReview && ( - )} {existingUserReview && ( - + Веќе напишавте оценка за овој оглас )} @@ -234,10 +274,11 @@ export default function AdDetailScreen() { - @@ -245,27 +286,28 @@ export default function AdDetailScreen() { {!isOwner && !isAuthenticated && ( - + Најавете се за да започнете разговор или да напишете оценка. - + )} - {/* Reviews section */} {reviews && reviews.length > 0 && ( - + Оцени - + {ad.ratingAvg?.toFixed(1)} - + ({ad.reviewCount} {ad.reviewCount === 1 ? "оценка" : "оцени"}) @@ -287,4 +329,4 @@ export default function AdDetailScreen() { ); -} \ No newline at end of file +} diff --git a/components/ad-card.tsx b/components/ad-card.tsx index 2708eb7..4472808 100644 --- a/components/ad-card.tsx +++ b/components/ad-card.tsx @@ -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 ( @@ -30,33 +31,58 @@ 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", }} > - - - - {ad.title} - - {ad.ratingAvg != null && ad.ratingAvg > 0 && ( - + + + + {emoji} + + + + {ad.title} + + {ad.ratingAvg != null && ad.ratingAvg > 0 && ( + + )} + + {ad.priceRange && ( + + + {ad.priceRange} + + )} - {ad.priceRange && ( - - {ad.priceRange} - - )} - - - - 📍 {ad.location} + + + + 📍 {ad.location} + + ); -} \ No newline at end of file +} diff --git a/components/category-grid.tsx b/components/category-grid.tsx index 40a6b96..6ffb94c 100644 --- a/components/category-grid.tsx +++ b/components/category-grid.tsx @@ -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) { {categories.map((cat) => { const emoji = CATEGORY_EMOJI[cat.slug] ?? cat.icon ?? "📋"; + const color = getCategoryColor(cat.slug); return ( - {emoji} + + {emoji} + {cat.name} @@ -57,4 +78,4 @@ export function CategoryGrid({ categories, columns = 3 }: CategoryGridProps) { })} ); -} \ No newline at end of file +} diff --git a/components/chat-list-item.tsx b/components/chat-list-item.tsx index 9dcb6be..c0834e0 100644 --- a/components/chat-list-item.tsx +++ b/components/chat-list-item.tsx @@ -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, }} > - - - + + + {chat.otherUser?.name ?? "Непознат"} {chat.lastMessage && ( - + {formatRelativeTime(chat.lastMessage.createdAt)} )} - + {previewText} @@ -102,4 +88,4 @@ export function ChatListItem({ chat, currentUserId }: ChatListItemProps) { ); } -export { formatRelativeTime }; \ No newline at end of file +export { formatRelativeTime }; diff --git a/components/post-card.tsx b/components/post-card.tsx index 991ec1a..9196707 100644 --- a/components/post-card.tsx +++ b/components/post-card.tsx @@ -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,35 +32,56 @@ 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", }} > - - - {post.title} + + + + + {emoji} + + + {post.title} + + + + + + + {post.description} - - - - {post.description} - - - - {categoryName && } - {post.location && ( - 📍 {post.location} - )} - {post.budget && ( - - 💰 {post.budget} - - )} + + {categoryName && } + {post.location && ( + + 📍 {post.location} + + )} + {post.budget && ( + + 💰 {post.budget} + + )} + ); -} \ No newline at end of file +} diff --git a/components/review-card.tsx b/components/review-card.tsx index 51497d1..46f9493 100644 --- a/components/review-card.tsx +++ b/components/review-card.tsx @@ -45,28 +45,28 @@ 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, }} > - + {customerName} - + {formatTimeAgo(review.createdAt)} - + {review.comment ? ( - + {review.comment} ) : null} ); -} \ No newline at end of file +} diff --git a/components/theme.tsx b/components/theme.tsx index cdaad88..a86078b 100644 --- a/components/theme.tsx +++ b/components/theme.tsx @@ -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 = { + 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(LightTheme); @@ -86,4 +183,4 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { export function useTheme() { return useContext(ThemeContext); -} \ No newline at end of file +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx index 2e8f652..3bdc830 100644 --- a/components/ui/badge.tsx +++ b/components/ui/badge.tsx @@ -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 ( - {label} + + {label} + ); -} \ No newline at end of file +} diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 5bbf942..0fb53a8 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -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 = { - 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 = { - 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 = { - 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 ? ( - + ) : typeof children === "string" ? ( - + {children} ) : ( @@ -75,4 +92,4 @@ export function Button({ )} ); -} \ No newline at end of file +} diff --git a/components/ui/card.tsx b/components/ui/card.tsx index 63486f1..1f43a56 100644 --- a/components/ui/card.tsx +++ b/components/ui/card.tsx @@ -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(); + const variantStyles: Record = { + 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 ( + + {children} + + ); + } + return ( - + {children} ); -} \ No newline at end of file +} diff --git a/components/ui/input.tsx b/components/ui/input.tsx index 4290888..05445eb 100644 --- a/components/ui/input.tsx +++ b/components/ui/input.tsx @@ -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"; 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 ( - + + {label && ( + + {label} + + )} + + {error && ( + + {error} + + )} + ); -} \ No newline at end of file +} diff --git a/convex/tsconfig.json b/convex/tsconfig.json new file mode 100644 index 0000000..41bfbb9 --- /dev/null +++ b/convex/tsconfig.json @@ -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"] +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..a8c096f --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +java = "21.0.2" diff --git a/package-lock.json b/package-lock.json index b87dfe5..e346d90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 690a4ed..120fcb5 100644 --- a/package.json +++ b/package.json @@ -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",