From ce7e400720925cca7b79b35bcbd4b0f2d1c8ae7a Mon Sep 17 00:00:00 2001 From: echo Date: Fri, 29 May 2026 18:24:01 +0200 Subject: [PATCH] feat: Phase 2 - home screen, categories, explore, AdCard - Add seedCategories mutation (idempotent) and getBySlug query - Add ad queries: list, getByCategory, getById, search, getByHandyman - Home screen: search bar, CategoryGrid, recent ads list with real-time Convex queries and search-by-keyword - Explore screen: category filter via search param, filtered ad list, full category grid when unfiltered - AdCard component: title, category badge, location, rating stars, price range, navigation to ad detail - CategoryGrid component: 3-column emoji grid linking to filtered explore - Add CATEGORY_EMOJI map and getCategoryName() helper in constants All UI text in Macedonian. TypeScript clean, Convex functions deployed. --- app/(tabs)/(explore)/index.tsx | 98 +++++++++++++++++++++++++++------- app/(tabs)/(home)/index.tsx | 76 +++++++++++++++++--------- components/ad-card.tsx | 58 ++++++++++++++++++++ components/category-grid.tsx | 57 ++++++++++++++++++++ convex/ads.ts | 57 +++++++++++++++----- convex/categories.ts | 43 ++++++++++++++- lib/constants.ts | 21 +++++++- 7 files changed, 350 insertions(+), 60 deletions(-) create mode 100644 components/ad-card.tsx create mode 100644 components/category-grid.tsx diff --git a/app/(tabs)/(explore)/index.tsx b/app/(tabs)/(explore)/index.tsx index f52bc3c..4945044 100644 --- a/app/(tabs)/(explore)/index.tsx +++ b/app/(tabs)/(explore)/index.tsx @@ -1,32 +1,92 @@ -import { View, Text, ScrollView } from "react-native"; +import { View, Text, Pressable, ScrollView } from "react-native"; +import { useLocalSearchParams, Link } from "expo-router"; import { useTheme } from "../../../components/theme"; -import { CATEGORIES } from "../../../lib/constants"; +import { useQuery } from "convex/react"; +import { api } from "../../../convex/_generated/api"; +import { CategoryGrid } from "../../../components/category-grid"; +import { AdCard } from "../../../components/ad-card"; +import { Loading } from "../../../components/ui/loading"; +import { CATEGORIES, CATEGORY_EMOJI, getCategoryName } from "../../../lib/constants"; export default function ExploreScreen() { const theme = useTheme(); + const { category } = useLocalSearchParams<{ category?: string }>(); + + const categories = useQuery(api.categories.list); + const categoryAds = useQuery( + api.ads.getByCategory, + category ? { category } : "skip" + ); + const allAds = useQuery(api.ads.list); + + const displayCategories = categories ?? CATEGORIES; + + if (category) { + const catName = getCategoryName(category); + const catEmoji = CATEGORY_EMOJI[category] ?? "📋"; + + return ( + + + + + ← Сите + + + {catEmoji} + + {catName} + + + + {categoryAds === undefined ? ( + + ) : categoryAds.length === 0 ? ( + + Нема огласи во оваа категорија + + ) : ( + + {categoryAds.map((ad) => ( + + ))} + + )} + + ); + } return ( - - {CATEGORIES.map((cat) => ( - - {cat.icon === "construct-outline" ? "🔧" : "📋"} - {cat.name} - - ))} - + + Пребарувај по категорија + + + + + + Сите огласи + + + {allAds === undefined ? ( + + ) : allAds.length === 0 ? ( + + Нема огласи + + ) : ( + + {allAds.map((ad) => ( + + ))} + + )} ); } \ No newline at end of file diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index 06295d0..e46811f 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -1,10 +1,26 @@ -import { View, Text, Pressable, ScrollView } from "react-native"; -import { Link } from "expo-router"; +import { View, Text, TextInput, ScrollView } from "react-native"; +import { useState } from "react"; import { useTheme } from "../../../components/theme"; +import { useQuery } from "convex/react"; +import { api } from "../../../convex/_generated/api"; +import { CategoryGrid } from "../../../components/category-grid"; +import { AdCard } from "../../../components/ad-card"; +import { Loading } from "../../../components/ui/loading"; import { CATEGORIES } from "../../../lib/constants"; export default function HomeScreen() { const theme = useTheme(); + const [search, setSearch] = useState(""); + + const categories = useQuery(api.categories.list); + const allAds = useQuery(api.ads.list); + const searchResults = useQuery( + api.ads.search, + search.trim() ? { query: search.trim() } : "skip" + ); + + const ads = search.trim() ? searchResults : allAds; + const displayCategories = categories ?? CATEGORIES; return ( - - - 🔍 Пребарувај мајстори... - - + }} + /> + + {!search.trim() && ( + <> + + Категории + + + + )} - Категории + {search.trim() ? "Резултати од пребарување" : "Неодамна додадени"} - - {CATEGORIES.map((cat) => ( - - - {cat.name} - - - ))} - + {ads === undefined ? ( + + ) : ads.length === 0 ? ( + + {search.trim() ? "Не се пронајдени резултати" : "Нема огласи"} + + ) : ( + + {ads.map((ad) => ( + + ))} + + )} ); } \ No newline at end of file diff --git a/components/ad-card.tsx b/components/ad-card.tsx new file mode 100644 index 0000000..2f04b30 --- /dev/null +++ b/components/ad-card.tsx @@ -0,0 +1,58 @@ +import { View, Text, Pressable } from "react-native"; +import { Link } from "expo-router"; +import { useTheme } from "./theme"; +import { Badge } from "./ui/badge"; +import { Rating } from "./ui/rating"; +import { getCategoryName } from "../lib/constants"; + +interface AdCardProps { + ad: { + _id: string; + title: string; + category: string; + location: string; + priceRange?: string; + ratingAvg?: number; + reviewCount: number; + }; +} + +export function AdCard({ ad }: AdCardProps) { + const theme = useTheme(); + const categoryName = getCategoryName(ad.category); + + return ( + + + + + {ad.title} + + {ad.priceRange && ( + + {ad.priceRange} + + )} + + + + + 📍 {ad.location} + + + {ad.ratingAvg != null && ad.ratingAvg > 0 && ( + + )} + + + ); +} \ No newline at end of file diff --git a/components/category-grid.tsx b/components/category-grid.tsx new file mode 100644 index 0000000..04f8e13 --- /dev/null +++ b/components/category-grid.tsx @@ -0,0 +1,57 @@ +import { View, Text, Pressable } from "react-native"; +import { Link } from "expo-router"; +import { useTheme } from "./theme"; +import { CATEGORY_EMOJI } from "../lib/constants"; + +interface CategoryItem { + slug: string; + name: string; + icon?: string; + sortOrder: number; +} + +interface CategoryGridProps { + categories: readonly CategoryItem[]; + columns?: number; +} + +export function CategoryGrid({ categories, columns = 3 }: CategoryGridProps) { + const theme = useTheme(); + + return ( + + {categories.map((cat) => { + const emoji = CATEGORY_EMOJI[cat.slug] ?? cat.icon ?? "📋"; + return ( + + + {emoji} + + {cat.name} + + + + ); + })} + + ); +} \ No newline at end of file diff --git a/convex/ads.ts b/convex/ads.ts index 3fc2571..87a79dd 100644 --- a/convex/ads.ts +++ b/convex/ads.ts @@ -1,26 +1,20 @@ import { query } from "./_generated/server"; import { v } from "convex/values"; -export const search = query({ - args: { query: v.string(), category: v.optional(v.string()) }, - handler: async (ctx, args) => { - if (args.category) { - return await ctx.db - .query("ads") - .withIndex("by_category", (q) => q.eq("category", args.category!)) - .order("desc") - .collect(); - } - return await ctx.db.query("ads").order("desc").take(50); +export const list = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query("ads").order("desc").collect(); }, }); -export const getByHandyman = query({ - args: { handymanId: v.id("users") }, +export const getByCategory = query({ + args: { category: v.string() }, handler: async (ctx, args) => { return await ctx.db .query("ads") - .withIndex("by_handyman", (q) => q.eq("handymanId", args.handymanId)) + .withIndex("by_category", (q) => q.eq("category", args.category)) + .order("desc") .collect(); }, }); @@ -30,4 +24,39 @@ export const getById = query({ handler: async (ctx, args) => { return await ctx.db.get(args.id); }, +}); + +export const search = query({ + args: { query: v.string(), category: v.optional(v.string()) }, + handler: async (ctx, args) => { + const searchLower = args.query.toLowerCase(); + + let ads; + if (args.category) { + ads = await ctx.db + .query("ads") + .withIndex("by_category", (q) => q.eq("category", args.category!)) + .collect(); + } else { + ads = await ctx.db.query("ads").order("desc").collect(); + } + + return ads.filter( + (ad) => + ad.title.toLowerCase().includes(searchLower) || + ad.description.toLowerCase().includes(searchLower) || + ad.location.toLowerCase().includes(searchLower) + ); + }, +}); + +export const getByHandyman = query({ + args: { handymanId: v.id("users") }, + handler: async (ctx, args) => { + return await ctx.db + .query("ads") + .withIndex("by_handyman", (q) => q.eq("handymanId", args.handymanId)) + .order("desc") + .collect(); + }, }); \ No newline at end of file diff --git a/convex/categories.ts b/convex/categories.ts index 2dba4a2..289b7e7 100644 --- a/convex/categories.ts +++ b/convex/categories.ts @@ -1,8 +1,49 @@ -import { query } from "./_generated/server"; +import { query, mutation } from "./_generated/server"; +import { v } from "convex/values"; + +const SEED_CATEGORIES = [ + { slug: "majstor-za-se", name: "Мајстор за сѐ", icon: "🔧", sortOrder: 0 }, + { slug: "vodoinstalater", name: "Водоинсталатер", icon: "🚿", sortOrder: 1 }, + { slug: "elektrichar", name: "Електричар", icon: "⚡", sortOrder: 2 }, + { slug: "teskar", name: "Тескар", icon: "🔨", sortOrder: 3 }, + { slug: "farbar", name: "Фарбар", icon: "🖌️", sortOrder: 4 }, + { slug: "keramichar", name: "Керамичар", icon: "🧱", sortOrder: 5 }, + { slug: "zidar", name: "Зидар", icon: "🏗️", sortOrder: 6 }, + { slug: "gradezhnik", name: "Градежник", icon: "🏢", sortOrder: 7 }, + { slug: "stolar", name: "Столар", icon: "🪵", sortOrder: 8 }, + { slug: "moler", name: "Молер", icon: "🎨", sortOrder: 9 }, + { slug: "klima-montazher", name: "Клима монтажер", icon: "❄️", sortOrder: 10 }, + { slug: "drugo", name: "Друго", icon: "📋", sortOrder: 11 }, +]; export const list = query({ args: {}, handler: async (ctx) => { return await ctx.db.query("categories").withIndex("by_slug").order("asc").collect(); }, +}); + +export const getBySlug = query({ + args: { slug: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query("categories") + .withIndex("by_slug", (q) => q.eq("slug", args.slug)) + .first(); + }, +}); + +export const seedCategories = mutation({ + args: {}, + handler: async (ctx) => { + for (const cat of SEED_CATEGORIES) { + const existing = await ctx.db + .query("categories") + .withIndex("by_slug", (q) => q.eq("slug", cat.slug)) + .first(); + if (!existing) { + await ctx.db.insert("categories", cat); + } + } + }, }); \ No newline at end of file diff --git a/lib/constants.ts b/lib/constants.ts index 81bfe8c..8e2f041 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -13,4 +13,23 @@ export const CATEGORIES = [ { slug: "drugo", name: "Друго", icon: "ellipsis-horizontal-outline", sortOrder: 11 }, ] as const; -export type CategorySlug = (typeof CATEGORIES)[number]["slug"]; \ No newline at end of file +export type CategorySlug = (typeof CATEGORIES)[number]["slug"]; + +export const CATEGORY_EMOJI: Record = { + "majstor-za-se": "🔧", + "vodoinstalater": "🚿", + "elektrichar": "⚡", + "teskar": "🔨", + "farbar": "🖌️", + "keramichar": "🧱", + "zidar": "🏗️", + "gradezhnik": "🏢", + "stolar": "🪵", + "moler": "🎨", + "klima-montazher": "❄️", + "drugo": "📋", +}; + +export function getCategoryName(slug: string): string { + return CATEGORIES.find((c) => c.slug === slug)?.name ?? slug; +} \ No newline at end of file